harness-dispatch 0.7.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CHANGELOG.md +1170 -1
  2. package/README.md +52 -6
  3. package/config.default.yaml +49 -5
  4. package/dist/auth.d.ts +29 -0
  5. package/dist/auth.d.ts.map +1 -1
  6. package/dist/auth.js +58 -5
  7. package/dist/auth.js.map +1 -1
  8. package/dist/billing.d.ts +24 -0
  9. package/dist/billing.d.ts.map +1 -1
  10. package/dist/billing.js +24 -0
  11. package/dist/billing.js.map +1 -1
  12. package/dist/bin.d.ts.map +1 -1
  13. package/dist/bin.js +529 -31
  14. package/dist/bin.js.map +1 -1
  15. package/dist/breaker-store.d.ts +25 -1
  16. package/dist/breaker-store.d.ts.map +1 -1
  17. package/dist/breaker-store.js +193 -25
  18. package/dist/breaker-store.js.map +1 -1
  19. package/dist/circuit-breaker.d.ts +9 -1
  20. package/dist/circuit-breaker.d.ts.map +1 -1
  21. package/dist/circuit-breaker.js +15 -1
  22. package/dist/circuit-breaker.js.map +1 -1
  23. package/dist/client-register.d.ts +157 -0
  24. package/dist/client-register.d.ts.map +1 -0
  25. package/dist/client-register.js +389 -0
  26. package/dist/client-register.js.map +1 -0
  27. package/dist/config/validation.d.ts +41 -0
  28. package/dist/config/validation.d.ts.map +1 -1
  29. package/dist/config/validation.js +215 -1
  30. package/dist/config/validation.js.map +1 -1
  31. package/dist/config.d.ts +4 -2
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +143 -6
  34. package/dist/config.js.map +1 -1
  35. package/dist/configure-yaml.d.ts +2 -0
  36. package/dist/configure-yaml.d.ts.map +1 -1
  37. package/dist/configure-yaml.js +69 -3
  38. package/dist/configure-yaml.js.map +1 -1
  39. package/dist/dispatch-log.d.ts +14 -0
  40. package/dist/dispatch-log.d.ts.map +1 -1
  41. package/dist/dispatch-log.js +3 -0
  42. package/dist/dispatch-log.js.map +1 -1
  43. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  44. package/dist/dispatchers/generic-cli.js +92 -15
  45. package/dist/dispatchers/generic-cli.js.map +1 -1
  46. package/dist/dispatchers/openai-compatible.d.ts +20 -4
  47. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  48. package/dist/dispatchers/openai-compatible.js +167 -19
  49. package/dist/dispatchers/openai-compatible.js.map +1 -1
  50. package/dist/dispatchers/shared/harness-login.d.ts +25 -0
  51. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
  52. package/dist/dispatchers/shared/harness-login.js +68 -0
  53. package/dist/dispatchers/shared/harness-login.js.map +1 -0
  54. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  55. package/dist/dispatchers/shared/stream-subprocess.js +70 -4
  56. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  57. package/dist/file-lock.d.ts +29 -1
  58. package/dist/file-lock.d.ts.map +1 -1
  59. package/dist/file-lock.js +22 -1
  60. package/dist/file-lock.js.map +1 -1
  61. package/dist/http/answer-stream.d.ts +52 -0
  62. package/dist/http/answer-stream.d.ts.map +1 -0
  63. package/dist/http/answer-stream.js +47 -0
  64. package/dist/http/answer-stream.js.map +1 -0
  65. package/dist/http/parse.d.ts.map +1 -1
  66. package/dist/http/parse.js +41 -0
  67. package/dist/http/parse.js.map +1 -1
  68. package/dist/http/server.d.ts +2 -0
  69. package/dist/http/server.d.ts.map +1 -1
  70. package/dist/http/server.js +281 -23
  71. package/dist/http/server.js.map +1 -1
  72. package/dist/jobs/context.d.ts +0 -7
  73. package/dist/jobs/context.d.ts.map +1 -1
  74. package/dist/jobs/context.js +95 -7
  75. package/dist/jobs/context.js.map +1 -1
  76. package/dist/jobs/store.d.ts.map +1 -1
  77. package/dist/jobs/store.js +45 -4
  78. package/dist/jobs/store.js.map +1 -1
  79. package/dist/jobs/types.d.ts +8 -1
  80. package/dist/jobs/types.d.ts.map +1 -1
  81. package/dist/jobs.d.ts +39 -0
  82. package/dist/jobs.d.ts.map +1 -1
  83. package/dist/jobs.js +207 -15
  84. package/dist/jobs.js.map +1 -1
  85. package/dist/leaderboard.d.ts.map +1 -1
  86. package/dist/leaderboard.js +39 -27
  87. package/dist/leaderboard.js.map +1 -1
  88. package/dist/mcp/config-hot-reload.d.ts +14 -3
  89. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  90. package/dist/mcp/config-hot-reload.js +33 -5
  91. package/dist/mcp/config-hot-reload.js.map +1 -1
  92. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  93. package/dist/mcp/dispatcher-factory.js +14 -3
  94. package/dist/mcp/dispatcher-factory.js.map +1 -1
  95. package/dist/mcp/near-miss-guard.d.ts +45 -0
  96. package/dist/mcp/near-miss-guard.d.ts.map +1 -0
  97. package/dist/mcp/near-miss-guard.js +98 -0
  98. package/dist/mcp/near-miss-guard.js.map +1 -0
  99. package/dist/mcp/server.d.ts.map +1 -1
  100. package/dist/mcp/server.js +36 -0
  101. package/dist/mcp/server.js.map +1 -1
  102. package/dist/mcp/tool-schemas.d.ts +1 -1
  103. package/dist/mcp/tool-schemas.d.ts.map +1 -1
  104. package/dist/mcp/tool-schemas.js +27 -7
  105. package/dist/mcp/tool-schemas.js.map +1 -1
  106. package/dist/mcp/tools.d.ts +33 -4
  107. package/dist/mcp/tools.d.ts.map +1 -1
  108. package/dist/mcp/tools.js +85 -8
  109. package/dist/mcp/tools.js.map +1 -1
  110. package/dist/mcp-clients.d.ts +72 -0
  111. package/dist/mcp-clients.d.ts.map +1 -0
  112. package/dist/mcp-clients.js +121 -0
  113. package/dist/mcp-clients.js.map +1 -0
  114. package/dist/near-miss.d.ts +63 -0
  115. package/dist/near-miss.d.ts.map +1 -0
  116. package/dist/near-miss.js +132 -0
  117. package/dist/near-miss.js.map +1 -0
  118. package/dist/quota.d.ts +31 -1
  119. package/dist/quota.d.ts.map +1 -1
  120. package/dist/quota.js +84 -8
  121. package/dist/quota.js.map +1 -1
  122. package/dist/route-policy.d.ts +12 -1
  123. package/dist/route-policy.d.ts.map +1 -1
  124. package/dist/route-policy.js +74 -2
  125. package/dist/route-policy.js.map +1 -1
  126. package/dist/router.d.ts +61 -2
  127. package/dist/router.d.ts.map +1 -1
  128. package/dist/router.js +160 -29
  129. package/dist/router.js.map +1 -1
  130. package/dist/safety.d.ts.map +1 -1
  131. package/dist/safety.js +37 -1
  132. package/dist/safety.js.map +1 -1
  133. package/dist/state-dir.d.ts +10 -0
  134. package/dist/state-dir.d.ts.map +1 -1
  135. package/dist/state-dir.js +12 -0
  136. package/dist/state-dir.js.map +1 -1
  137. package/dist/status.d.ts +30 -21
  138. package/dist/status.d.ts.map +1 -1
  139. package/dist/status.js +152 -8
  140. package/dist/status.js.map +1 -1
  141. package/dist/types.d.ts +49 -2
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/working-dir.d.ts.map +1 -1
  144. package/dist/working-dir.js +16 -0
  145. package/dist/working-dir.js.map +1 -1
  146. package/dist/workspace-resolve.d.ts.map +1 -1
  147. package/dist/workspace-resolve.js +196 -15
  148. package/dist/workspace-resolve.js.map +1 -1
  149. package/dist/workspaces.d.ts +98 -0
  150. package/dist/workspaces.d.ts.map +1 -1
  151. package/dist/workspaces.js +609 -20
  152. package/dist/workspaces.js.map +1 -1
  153. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,1173 @@ pre-1.0, so minor versions can carry behaviour changes.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] — 2026-09-02
10
+
11
+ ### Changed (breaking)
12
+
13
+ - **A config file that defines routes is now authoritative about them.**
14
+ Auto-detection used to run unconditionally, so a file with `clis:` or
15
+ `endpoints:` entries ADDED to the installed harnesses rather than replacing
16
+ them, and only `disabled:` — naming every route, including ones you might
17
+ not know existed — subtracted.
18
+
19
+ That default caught three acceptance passes in a row despite explicit
20
+ warnings, and caught this project's own test suite: one test dispatched to
21
+ the real Claude Code CLI on every `npm test` and every CI run, under a
22
+ comment asserting it could not reach a route. It also decayed — adding
23
+ support for a new harness would auto-add it to every existing config, so a
24
+ `disabled:` list written today quietly stopped isolating tomorrow.
25
+
26
+ The legacy `services:` format has always behaved this way, so the two shapes
27
+ now agree rather than this being a new rule.
28
+
29
+ **What changes for you.** A config that has a `clis:` or `endpoints:` key
30
+ gets exactly the routes listed under it — including `clis: []`, which means
31
+ no CLI routes. Add `detect: true` to keep the old behaviour. A config
32
+ mentioning NEITHER key — one carrying only `overrides:`, `disabled:` or
33
+ settings — still auto-detects, because a file cannot be authoritative about
34
+ routes it does not describe; it now says so in a warning. `detect: false`
35
+ turns detection off outright.
36
+
37
+ Note that `disabled:` and `overrides:` tune AUTO-DETECTION, so they do
38
+ nothing in a config that lists its own routes — remove the entry instead.
39
+ The config the README tells you to copy said the opposite of this until it
40
+ was corrected in the same release; check yours if you wrote it from that.
41
+
42
+ ### Fixed
43
+
44
+ - **Re-running `configure` after installing a harness no longer needs
45
+ `--force`.** The natural first-run order — install this tool, configure,
46
+ find out a harness is needed, install one, configure again — was refused
47
+ at the last step: "already exists ... --force". And had the first run found
48
+ anything, the re-run would have loaded that file as authoritative and not
49
+ detected the new harness at all. `configure` now stamps what it writes
50
+ with a fingerprint; a re-run that finds its own unedited output regenerates
51
+ it from a fresh detection, and says so. A file that has been edited, or
52
+ that configure did not write, is still loaded (so its settings migrate)
53
+ and still refused without `--force` — and since such a file lists its own
54
+ routes, `--force` regenerates it from the file, not from a detection. It
55
+ used to print "Detected N harness routes" there anyway; an acceptance pass
56
+ measured that line with a second harness on PATH that never appeared. It
57
+ now says detection did not run and how to merge (`detect: true`). The
58
+ non-interactive hint at the end of `configure --yes` also told the user to
59
+ pass `--yes`, which they just had; it now names `connect`. Seen on the
60
+ cold-install walk.
61
+
62
+ - **`connect` now registers with a Claude Code that has never been opened.**
63
+ A client counted as installed only if its config file existed, and Claude
64
+ Code writes `~/.claude.json` on its first interactive launch — so a user who
65
+ installed Claude Code and this tool together was told "No MCP clients found
66
+ on this machine" with `claude` on PATH, handed JSON to paste by hand, and
67
+ `claude mcp list` stayed empty. A client whose command is on PATH now counts
68
+ as installed; when its file is missing, `connect` creates it holding only
69
+ our entry (0600), which Claude Code accepts as a user-scope registration.
70
+ Doctor's `mcp-clients` line names the installed-but-unregistered client
71
+ instead of a generic "not registered". Seen on the cold-install walk.
72
+
73
+ - **`configure` writes its config where every later command can find it.**
74
+ It wrote `./config.yaml` into whatever directory it was run from, and
75
+ lookup stopped at the current directory, so a config written from `~` was
76
+ invisible to `doctor` or `dispatch` run inside a project — "0 configured
77
+ route(s)", no hint why — while the MCP client, handed the absolute path,
78
+ saw it fine. The default target is now the state directory
79
+ (`~/.harness-dispatch/config.yaml`, or under `HARNESS_DISPATCH_STATE_DIR`),
80
+ lookup falls back to that file after `./config.yaml` (a per-project file
81
+ still wins), `connect` looks in the same place, and doctor's `config` line
82
+ names the file it loaded. `--config` and `HARNESS_DISPATCH_CONFIG` are
83
+ unchanged. Seen on the cold-install walk, where a run from `/` produced
84
+ `/config.yaml`.
85
+
86
+ - **`doctor` now notices a Codex that is installed but not logged in.** A
87
+ ready route meant "the CLI is on PATH", so a never-logged-in Codex passed
88
+ routes, billing and safety, and the first dispatch failed with a raw OpenAI
89
+ `401 Unauthorized ... Missing bearer` that never mentioned `codex login`.
90
+ A new `harness-login` check asks `codex login status` and fails with the
91
+ command to run when it answers "Not logged in". The CLI is asked rather
92
+ than its credential file read, because Codex accepts a ChatGPT login or an
93
+ API key and honours its own home directory, and only its own answer is
94
+ right in every case. Any answer other than a definite "Not logged in" —
95
+ an older Codex without the subcommand, a spawn failure — is reported as
96
+ undetermined and does not fail the install. Codex only: the other
97
+ harnesses have no equivalent subcommand this tool has verified.
98
+
99
+ - **The installed command did nothing on Linux and macOS.** `npm install -g`
100
+ puts `harness-dispatch` on PATH as a symlink to `dist/bin.js`, and node
101
+ hands that unresolved link path to the program, so a run-if-entrypoint guard
102
+ that only looked for a filename ending in `bin.js` decided it was being
103
+ imported, ran nothing, and exited 0. Every documented command — `configure`,
104
+ `doctor`, `connect`, `dispatch`, even `--help` — printed nothing and reported
105
+ success on every non-Windows `npm install -g` or `npx` since the first
106
+ release. Windows was unaffected only because npm's `.cmd` shim passes the
107
+ real file path. The guard now also accepts an invoked path that resolves to
108
+ this file. Found by walking the README's three install steps in a clean
109
+ container, which nothing before had done: every prior walk ran `node
110
+ dist/bin.js` on this Windows machine.
111
+
112
+ - **SECURITY: workspace paths are now built and verified rather than checked
113
+ and trusted.** Four consecutive releases patched a guard that inspected a
114
+ path STRING and then let every later write re-resolve it, and each patch was
115
+ found incomplete by the next review: it followed the link it was checking,
116
+ then covered one of the two isolation policies, then only the last path
117
+ segment, then stopped one directory short of the one the release notes named
118
+ — and was silently inert altogether if the workspaces setting had a trailing
119
+ slash or a `..` in it. Validating once also left the rest of the dispatch
120
+ trusting the result, so swapping a directory for a link mid-copy redirected
121
+ it.
122
+
123
+ Every directory from the anchor down is now created by this tool with a
124
+ non-recursive `mkdir`, which cannot traverse a link it did not make, and the
125
+ verified path is returned and used instead of being re-derived. Destructive
126
+ operations re-verify immediately before acting. The symlink refusal now
127
+ applies on Windows too, where junctions need no privileges and were being
128
+ followed.
129
+
130
+ Verified as two separate users on Linux across every shape reported: a link
131
+ at the workspace root, at the base, at the base's parent, and with the
132
+ setting spelled with a trailing slash or `..` — all refused, with nothing
133
+ written into the attacker's directory. Legitimate use is unaffected,
134
+ including a symlinked temp directory, which is normal on macOS.
135
+
136
+ - **A delegated "run the tests" task no longer blocks its own route.** The
137
+ rate-limit phrase check ran against the whole transcript with no filter, so
138
+ this project's own test output — which names rate limits in test titles —
139
+ flagged as a real limiter, tripping the breaker for 300 seconds and
140
+ recording a rate limit that never happened. It is now checked per line and
141
+ past the same filter the numeric check uses.
142
+
143
+ - **Real limiter messages that were being missed now register**, including
144
+ Anthropic's `rate_limit_error` and OpenAI's "You exceeded your current
145
+ quota" wording. A missed limit is the worse direction: the router keeps
146
+ hammering a route that has already said stop.
147
+
148
+ - A metered route that declares it cannot bill you is no longer told to allow
149
+ paid usage. The previous release put that advice in a branch such a route
150
+ never reaches, so it worked for local routes only.
151
+
152
+ - The claims checker now also scans MCP resource descriptions, every
153
+ route-refusal message, the rendered status body and the CLI's own help text
154
+ — surfaces a user or agent reads that it could not see.
155
+
156
+ - **SECURITY: the workspace symlink guard covered one of the two isolation
157
+ policies.** The previous release applied the `lstat` refusal, the
158
+ guard-before-prune ordering and the sweep's name check to `copy` only.
159
+ `git_worktree` kept all three defects, so the identical attack still
160
+ destroyed data — reproduced as two real users: `copy` refused, `git_worktree`
161
+ deleted the victim's directory and raised the guard's error afterwards. The
162
+ entry claiming the class was closed was true for one policy and false for the
163
+ other. The test shipped with it passed `policy: "copy"` only, so the suite
164
+ stayed green over a live vulnerability; it now runs against both.
165
+
166
+ - **The same guard only checked the last path segment.** `<tmp>/harness-dispatch`
167
+ and `<tmp>/harness-dispatch/workspaces` are fixed names under a
168
+ world-writable directory and were never examined, so a link planted one or
169
+ two levels up redirected the whole copy into an attacker's tree. Every
170
+ segment from the workspaces base down is now checked for links and ownership.
171
+
172
+ - **A rate-limit regression from the previous release.** The filter added to
173
+ stop false positives discarded any line containing "should", which threw away
174
+ real limiter messages — a missed 429 means the router keeps hammering an
175
+ exhausted route. Two other patterns in that filter could never match at all.
176
+
177
+ - **A free local route is no longer told it can bill you, with a fix that does
178
+ nothing.** A route with a declared kind and `paid_usage_possible: false` is
179
+ blocked when its `billing_confidence` is `unknown` — deliberate — but was
180
+ shown "route can incur paid usage" beside `paid=no` and advised to set a
181
+ field it already had. It now names the real cause and a remedy that works.
182
+
183
+ - The `usage` tool description and README told agents that `service` and
184
+ `models` are unvalidated. Both throw on an unknown route id. The same wrong
185
+ sentence was corrected in the plugin skill one release earlier and missed
186
+ here — `scripts/check-claims.mjs` could not see tool descriptions at all, and
187
+ now does.
188
+
189
+ - `scripts/fetch_benchmarks.py` no longer replaces good benchmark data with its
190
+ bundled fallback when the network fails. It swallowed every exception,
191
+ returned an empty set, wrote it out and exited 0 — turning a transient outage
192
+ into a permanent downgrade of the file that ships in the package.
193
+
194
+ - An env-var reference embedded in a larger value (`base_url:
195
+ https://${HOST}/v1`) is now stripped from spawned CLI environments. The
196
+ sanitizer anchored to whole-string matches, so a key inside a longer string
197
+ stayed visible — the exact leak it was written to close, one string shape
198
+ over.
199
+
200
+ - `OPERATIONS.md` described streamed fanout wrongly (its arms DO create jobs,
201
+ and are not aborted on disconnect), `safety.ts` claimed every shipped harness
202
+ defines all three safety profiles (`cursor_cli` declares one),
203
+ `ux-walkthrough.md` called `discard` the only destructive operation (the
204
+ retention sweep is the one that deleted files), and two module headers said
205
+ three tools where six are registered.
206
+
207
+ - **SECURITY: a `copy` dispatch could delete files outside its workspace.**
208
+ The ownership guard used `stat`, which follows symlinks, so it compared the
209
+ uid of whatever a link POINTED AT rather than the link's own — and on a
210
+ shared machine the workspace path is predictable, so another local user can
211
+ plant one. Worse, the retention sweep that deletes aged workspaces ran
212
+ BEFORE the guard and had no ownership, marker or name check of its own.
213
+ Reproduced end to end in a container: a victim's directory chmod'd, the
214
+ project copied into it, and their files removed. All three now fixed —
215
+ `lstat` with symlinks refused outright, the guard runs before anything
216
+ destructive, and the sweep only deletes directories matching the name shape
217
+ this tool generates. The previous release's entry claiming a foreign-owned
218
+ root was "refused outright" was false against exactly this attack.
219
+
220
+ - **Non-ASCII output was being corrupted.** Every stdout chunk was decoded on
221
+ its own, so a character split across two reads became replacement
222
+ characters — accented text, CJK, emoji, box-drawing. This is the path every
223
+ job uses, so it reached partial output, saved logs and delivered results,
224
+ and was invisible to anyone working in English.
225
+
226
+ - **An aborted streaming request now stops the run.** Nothing connected the
227
+ client disconnect to the dispatch, so an abandoned stream ran to completion,
228
+ spending quota — and being the one path with no job record, it could not be
229
+ cancelled either. On a CLI route that meant an agent still editing files for
230
+ a caller that had gone.
231
+
232
+ - **HTTP fanout with no eligible route now refuses** instead of answering 200
233
+ with an empty list. CI and cron read 200 as success; the MCP surface already
234
+ refused the identical request by name.
235
+
236
+ - A configured `timeout_ms` now covers reading an endpoint's response body,
237
+ not just its headers — the same defect as the leaderboard timeout fixed last
238
+ release, in the path the router uses for its primary route.
239
+
240
+ - Endpoint credentials can no longer leak through streaming error text: undici
241
+ embeds the request URL, and this branch returned it unscrubbed while its
242
+ sibling scrubbed it deliberately.
243
+
244
+ - A rate-limit false positive no longer trips the breaker on text ABOUT a 429
245
+ — a line number, or a test assertion the delegate ran. One flag blocks a
246
+ route for 300 seconds and records a limit that never happened.
247
+
248
+ - Config hot-reload now notices a config whose timestamp moves BACKWARDS
249
+ (restored from a backup, `cp -p`, extracted from an archive), and documents
250
+ the two cases where it does nothing at all.
251
+
252
+ - The MCP snippet `configure` prints is now the entry `connect` actually
253
+ writes. Pasting the documented snippet then running `connect` was answered
254
+ "has an entry we did not write" — the tool called its own output
255
+ hand-edited and refused to update or remove it.
256
+
257
+ - The chained-context omission notice named the wrong jobs when a job id
258
+ repeated, listing ones whose output was directly above it.
259
+
260
+ - **An existing `copy` workspace directory is now actually secured.** The
261
+ previous release set 0700 with `mkdir`'s `mode` option, which applies only to
262
+ directories it CREATES — so everyone who had used `copy` before kept a
263
+ world-readable root holding a full copy of their source, and the entry
264
+ claiming otherwise was wrong. Measured on Linux: 0755 before, 0755 after. An
265
+ explicit chmod fixes them. A root owned by ANOTHER user is now refused
266
+ outright rather than copied into: the path is deterministic inside a shared
267
+ temp directory, so on the multi-user machine this guard exists for, someone
268
+ else can create it first.
269
+
270
+ - **A half-dead leaderboard endpoint no longer hangs every dispatch.** The 8s
271
+ timeout was cancelled as soon as response headers arrived, leaving the body
272
+ read unbounded — measured still pending after 15s. Quality scores are awaited
273
+ per routing candidate, so this stalled routing indefinitely. Off by default,
274
+ which is why it was not worse.
275
+
276
+ - **The HTTP server no longer leaks an MCP server per rejected request.** A
277
+ request carrying an unknown session id is answered 400 without initialising,
278
+ so nothing ever closed the server built for it in advance — five such
279
+ requests left five alive until shutdown. Sessions are also expired after 30
280
+ minutes idle: nothing removed them before except an explicit HTTP DELETE,
281
+ which the standard client never sends, so a clean shutdown left its session
282
+ resident forever.
283
+
284
+ - **`retry_job` on an abandoned job no longer races the original.** Its guard
285
+ read the derived status while the claim path reads the raw status file, so a
286
+ supervisor could pick up the original while the retry ran — the exact outcome
287
+ the command's own error text says it prevents. The original is now marked
288
+ cancelled.
289
+
290
+ - **Chained context says what it left out.** Jobs past the character budget
291
+ were dropped with no header and no note, contradicting the contract stated in
292
+ the same file. The docblock also claimed oldest entries truncate first, which
293
+ was never true — the last ones go.
294
+
295
+ - The `copy` size refusal named the parent of your project rather than the
296
+ workspace holding the work, and the `force` flag's description still promised
297
+ an uncommitted-changes refusal that only exists inside a git repository.
298
+
299
+ - **`configure` no longer deletes `detect: false`.** That key is the only
300
+ setting that isolates a machine from its installed paid CLIs, and it had no
301
+ field on the internal config object, so regenerating a file silently dropped
302
+ it. Measured: `detect: false` plus `max_concurrent_runs: 2` came back as the
303
+ latter alone, and reloading it with all four harness CLIs present produced
304
+ four routes on real subscriptions — under a "Wrote" message. A bare
305
+ `detect: false` regenerated as an empty document, where even the
306
+ "this config defines no routes" warning is suppressed, because its trigger
307
+ requires a non-empty file. `detect` now round-trips, and is written only when
308
+ the file stated it — carrying the resolved value would stamp `detect: true`
309
+ into every config that merely omitted it.
310
+
311
+ - **`connect --remove` now removes the entry.** Without `--clients` it printed
312
+ "our entry is here — will be removed", exited 0, and left the file
313
+ byte-identical. The chooser filtered out plans whose entry already matches —
314
+ correct when registering, and exactly backwards when removing, where a
315
+ matching entry is the one being removed. `--clients` bypassed that function
316
+ and always worked; the broken form is the one documented in README and
317
+ OPERATIONS.md. A hand-edited entry is still refused without `--force`, and
318
+ now exits non-zero rather than reporting success for work it did not do.
319
+ This command had no test coverage at all; it does now.
320
+
321
+ - **The test suite can no longer reach a real route.** Test setup already
322
+ sandboxed the log, state and jobs directories — where the suite WRITES — but
323
+ not config, which is what it DISCOVERS. A test that loaded config without
324
+ naming a file or stubbing detection picked up whatever the developer's
325
+ machine offers. Measured while closing this: on the maintainer's machine
326
+ that is the repo's own `config.yaml`, yielding four API routes with real
327
+ keys; on a machine with the CLIs installed it is the harness fleet, on real
328
+ subscriptions. One boundary test really did dispatch to Claude Code on every
329
+ CI run once. Setup now pins `HARNESS_DISPATCH_CONFIG` at a `detect: false`
330
+ sandbox, so an un-stubbed load gets an empty route table; a test wanting
331
+ routes passes its own path, which still wins.
332
+
333
+ - **An orphaned job can be cancelled again — when it is still able to run.**
334
+ There are two kinds: one written to disk when the server exits before a
335
+ queued job starts (genuinely terminal), and one DERIVED from a stale
336
+ heartbeat while the file still says `queued`. The second is not inert — a
337
+ supervisor reclaims it once the dead owner's claim ages out — and `cancel`
338
+ answered "had already finished; nothing to cancel" about work that could
339
+ still start, leaving no way to stop it. It now settles the status itself,
340
+ since an orphaned job has no runner to notice a marker. The written kind
341
+ still reports nothing to cancel.
342
+
343
+ - **A `copy` workspace of many files can no longer build an unbounded patch.**
344
+ The per-file diff was already bounded; the concatenation of those files was
345
+ not, so files each under the limit still summed past it. Both limits now
346
+ explain themselves — the worktree path used to surface the cap as
347
+ `stdout maxBuffer length exceeded`, which says nothing about patches or
348
+ about the work still being safe on disk.
349
+
350
+ - **Isolated workspaces are created 0700**, like the state and job
351
+ directories. A `copy` workspace holds a full copy of your source and the
352
+ default base is in the shared temp directory, so on a multi-user POSIX
353
+ machine it was readable by everyone. No effect on Windows.
354
+
355
+ - **Chained context says which project it came from.** `contextJobs` inlines
356
+ any job from the machine-wide store with no working-directory scoping, so a
357
+ job from one project could be pulled into a dispatch for another with
358
+ nothing indicating it. Disclosed rather than blocked: chaining across
359
+ projects is legitimate and the caller passes the id explicitly — what was
360
+ missing is that nobody could see it happen.
361
+
362
+ - **Dead supervisor heartbeats are swept.** A killed supervisor's file stopped
363
+ being counted but stayed forever, and the liveness check reads every file on
364
+ every drain. The sweep is limited to heartbeats: the crash log beside them
365
+ exists to explain the very supervisor that died.
366
+
367
+ - `workspace apply`'s description no longer claims a refusal it cannot always
368
+ make: the uncommitted-changes check needs a git repository. Outside one,
369
+ only the per-file check applies — a file the patch touches is still
370
+ refused, unrelated edits cannot be seen.
371
+
372
+ - A `timeout_ms` or token-cap warning no longer claims routing reads the
373
+ field. Routing does not; the range branch was fixed for this and the
374
+ not-a-number branch beside it was not.
375
+
376
+ - **Usage counters are no longer silently lost when two dispatches finish at
377
+ once.** `withFileLock` runs its critical section unlocked after a 2s
378
+ timeout — deliberate, and still right for the circuit breaker, which has
379
+ nothing to fall back on. It was wrong for the quota counters, which keep a
380
+ pending delta and clear it only on success: an unserialised write landed,
381
+ the delta was cleared as though serialised, and the process actually holding
382
+ the lock then overwrote the file with a value computed before that write
383
+ existed. Reproduced against the built artifact: five recorded calls gone,
384
+ no error. The counters now defer instead, and the delta lands on the next
385
+ result. A busy moment is no longer reported as "counters not reaching
386
+ disk" — they are delayed, not lost, and saying otherwise makes a working
387
+ system look broken.
388
+
389
+ - **A route knocked out by a 429 no longer reports `tripped: true,
390
+ failures: 0`.** `trip()` set the cooldown without counting the failure, so
391
+ `usage` — the surface an orchestrator is told to consult before delegating
392
+ — showed a contradiction that reads as a bookkeeping bug rather than a real
393
+ trip.
394
+
395
+ - **Discarding a workspace whose directory is already gone now clears the
396
+ registration git is still holding.** The early return skipped the block that
397
+ removes a worktree through git, so a workspace pruned by retention or
398
+ deleted by hand left `.git/worktrees/<name>` in the user's repo
399
+ permanently — the exact outcome `discardWorkspace`'s own documentation says
400
+ it exists to prevent. Reproduced: `git worktree list` still showing the path
401
+ as `prunable` after discard reported success.
402
+
403
+ - **An orphaned or cancelled job now hands back its partial work through the
404
+ tool an orchestrator actually calls.** The progress was on disk and
405
+ `getAsyncJob` read it, but `job_status` answered `output: ""`: orphaned and
406
+ cancelled count as terminal, and the poll response attached `partialOutput`
407
+ only on the NOT-terminal branch. So a commit titled "an orphaned job hands
408
+ back its progress" was correct in the module it edited and had no effect at
409
+ the surface a caller touches — verified at the function, not at the tool.
410
+ The crash path (a `failed` status with no result) was losing its output the
411
+ same way. The HTTP surface already salvaged this; MCP now matches it, and
412
+ the answer says the output is PARTIAL so salvage is not read as a result.
413
+
414
+ - **A gitignored file the agent wrote is no longer reported as applied while
415
+ being left behind.** Under `git_worktree`, the patch came from `git add -A
416
+ -N`, which obeys `.gitignore`, while the changed-file list came from a
417
+ filesystem fingerprint, which does not. A job that wrote a `.env` or any
418
+ ignored file got `applied: true` naming it, with the file absent from the
419
+ patch and from the project. It compounded: the already-applied guard needs
420
+ every recorded change present, so it never fired, and the next apply refused
421
+ with "changed since dispatch" — blaming the caller for the first apply's own
422
+ writes, the misleading refusal an earlier fix had removed. The paths already
423
+ recorded as changed are now force-added, which also makes `git_worktree`
424
+ agree with `copy`, whose per-file patch always carried them.
425
+
426
+ - **`clis: []` now isolates a config, which is what the previous release said
427
+ it did.** Authoritativeness keyed off a NON-EMPTY list, so the most explicit
428
+ way to write "no CLI routes" still loaded every harness on the machine —
429
+ the exact failure the change was written for, described in its own comment
430
+ in the past tense while remaining true. Presence of `clis:`/`endpoints:` is
431
+ now what makes a config authoritative, empty or not. A config that mentions
432
+ neither still auto-detects, unchanged.
433
+
434
+ - **An infinite or NaN route field is no longer accepted, and the warning
435
+ about it is no longer false.** The new range check covered negative values
436
+ but not `.inf`, `.nan`, or `1e999` (which YAML types as a string, and
437
+ `Number()` overflows to `Infinity`). Two paths were wrong: `1e999` produced
438
+ no warning at all, and `tier: -.inf, weight: .inf` loaded as
439
+ `-Infinity`/`Infinity` — ahead of every tier, above every score —
440
+ underneath a warning reading "IGNORED, and the built-in default applies
441
+ instead". A message asserting the opposite of what happened is worse than
442
+ the silence it replaced. Warnings also now name the value the operator
443
+ actually wrote (`JSON.stringify(Infinity)` is `null`, so they reported
444
+ `tier is null` for a file saying `-.inf`), and explain the right mechanism
445
+ per field rather than describing routing for `timeout_ms`.
446
+
447
+ - **`configure` no longer generates a config that fails its own `doctor`.**
448
+ It carried `disabled:` forward alongside the `clis:` it generates; in an
449
+ authoritative config that control does nothing, so `doctor` reported it had
450
+ no effect and exited 1. The disabled route is already absent from the
451
+ generated list, so the name was saying nothing. It is still emitted for a
452
+ generated config that lists no routes, where it does the work.
453
+
454
+ - **A `clis:` or `endpoints:` written as a mapping instead of a list now
455
+ says so.** The entries vanished, the config counted as defining no routes,
456
+ detection ran, and someone trying to name their own routes silently got
457
+ every installed paid harness instead — under a warning claiming their
458
+ config defined no routes, which contradicted the file in front of them.
459
+
460
+ - Legacy `services:` configs now get the same top-level key warnings as the
461
+ modern shape. That path returned before the check ran, so `policy: copy`
462
+ warned twice in one format and not at all in the other.
463
+
464
+ - **A negative `tier`, `weight` or `cli_capability` no longer hands a route
465
+ every dispatch.** These were type-checked but never range-checked, and
466
+ routing multiplies `weight` and `cli_capability` into the score while
467
+ ordering `tier` ascending — so a negative pair does not demote a route, it
468
+ promotes it past every legitimate one. An acceptance pass measured
469
+ `tier: -5, weight: -100, cli_capability: -3` scoring 299.8 against a normal
470
+ route's 0.88, from a tier sorting ahead of them all, with no warning
471
+ anywhere. Out-of-range values are now reported and ignored, so the built-in
472
+ default applies — the same outcome an unreadable value already got. Upper
473
+ bounds were deliberately not added: `cli_capability: 1.1` ships in this
474
+ repo's own default config as real tuning, and the defect is sign, not
475
+ magnitude.
476
+
477
+ - Top-level `policy:` and `workspace_policy:` now say they have no effect.
478
+ Both were allow-listed and read nowhere — and both ARE valid per-route keys,
479
+ which is what makes the top-level spelling easy to write: it looks like a
480
+ global default for the per-route setting, and there is no such default. An
481
+ isolation control that silently does nothing is the failure the config
482
+ validator exists to prevent. The per-route keys are unaffected.
483
+
484
+ - An `execute` task can no longer be routed to an HTTP endpoint. An endpoint
485
+ has no agent loop, no file access and no shell — PRODUCT.md states this as
486
+ design rather than gap — but an undeclared capability defaults to 1.0 and no
487
+ endpoint example declares any, so endpoints scored PERFECT for execute. An
488
+ acceptance pass measured a `--task-type execute` dispatch routed to an
489
+ endpoint, returning prose with exit 0: execution reported as succeeded when
490
+ none happened. It surfaced exactly when the CLI routes were busy or tripped,
491
+ which is the case a caller is least able to check.
492
+
493
+ Refused outright rather than scored low, on both counts deliberately: a score
494
+ of 0 still leaves a route selectable when it is the only candidate — the
495
+ failing case itself — and a declared capability must not override it, the
496
+ same rule as the safety-flag check. Endpoints remain full members of the mix
497
+ for plan, review and second opinions.
498
+
499
+ - An orphaned job now hands back the progress it saved. When a supervisor dies
500
+ there is no result, but its output is on disk in `stdout.partial.log` — and
501
+ the orphan branch returned above the code that reads it, so the response was
502
+ empty while the trail sat there. Chaining had the same gap, reporting "no
503
+ result available" for a job whose partial output was recoverable; it now
504
+ carries that output, labelled INCOMPLETE. PRODUCT.md names losing this trail
505
+ as the defining failure: "a wasted attempt with no trail".
506
+
507
+ - HTTP fanout arms are now job-backed, like the MCP surface has always been.
508
+ They called `routeTo` directly, so an arm's work existed only inside the
509
+ request — no job directory, no manifest, no partial log. Killing the client
510
+ or the server mid-fanout lost every arm's output with nothing on disk to
511
+ salvage, which is the failure PRODUCT.md names as defining, on one of two
512
+ surfaces.
513
+
514
+ The response shape is unchanged: arms are awaited and the same rows are
515
+ returned, so an OpenAI-compatible client sees exactly what it saw before.
516
+ Each row additionally carries the arm's `jobId`, which is what makes salvage
517
+ possible. This was briefly recorded here as deferred "because it changes what
518
+ a client receives" — that was wrong, and re-reading the MCP path showed why:
519
+ durability and the response contract are independent.
520
+
521
+ ## [0.8.0] — 2026-08-31
522
+
523
+ ### Added
524
+
525
+ - `GET /health` — liveness on the HTTP surface, and the only route served
526
+ without a token. Every endpoint required the bearer token, so a deploy gate
527
+ or container probe could not ask whether the process was up without being
528
+ handed a credential, and a health check that needs a secret is one most
529
+ orchestrators will not perform. It answers `{"status","service","version"}`
530
+ and nothing else; `/v1/status` keeps the richer answer behind the token.
531
+
532
+ - `OPERATIONS.md` — the signals worth watching, what there is to alert on (and
533
+ honestly, that there is nobody to page for a tool that runs on one laptop),
534
+ the failure modes that have actually happened here, and how to recover from
535
+ each.
536
+
537
+ - `harness-dispatch connect` registers this server with the MCP clients on your
538
+ machine, and `configure --yes` now offers it as the last step of setup instead
539
+ of printing JSON for you to paste. `connect --remove` takes the entry back out.
540
+
541
+ Setting this up was a copy-paste job that nobody owned, and the paths in it
542
+ later moved. On the maintainer's machine that produced, simultaneously: a
543
+ Claude Code entry launching a directory renamed away months earlier, a session
544
+ hook pointing at the same dead path, and a working Cursor entry with no
545
+ `--config` — so the two clients disagreed about which routes existed while
546
+ both appeared to work.
547
+
548
+ Careful with other applications' files, because this project has already got
549
+ this wrong once (v0.1.0's setup wrote instructions and a hook; v0.2.0 removed
550
+ the command and left both behind for seven minor versions). Only the two
551
+ config shapes actually opened on a real machine are written. Every write is
552
+ backed up next to the file, merged rather than replaced — other servers and
553
+ our own entry's `env`, which holds live API keys, are preserved — and swapped
554
+ in atomically after being parsed back, so a half-written `~/.claude.json` is
555
+ not a possible outcome. An entry that already exists and differs is shown and
556
+ left alone unless you say otherwise; that Cursor entry was the working one.
557
+ Re-running changes nothing. With no terminal and no `--clients`, it reports
558
+ what it would do and writes nothing rather than prompting into the void.
559
+
560
+ `connect --dev` registers the checkout you are running instead of the
561
+ published package, for anyone developing against this repo. Without it that
562
+ case is not merely unsupported but actively wrong: on a machine with nothing
563
+ installed globally, the package form resolves through `npx` to whatever is on
564
+ the registry, so registering it swaps a checkout that is commits ahead for an
565
+ older release and reports success. Found by running `connect` on the
566
+ maintainer's machine, where it correctly declined to do exactly that. The
567
+ trade — an absolute path breaks silently when the directory moves — is why it
568
+ is opt-in per run, prints the path before writing it, and leans on the
569
+ `doctor` check that already fails on a client entry naming a path that has
570
+ gone.
571
+
572
+ - The dispatch log records `candidates` — what the picked route beat — so the
573
+ question the field was added to answer can be asked of a month of history
574
+ rather than one response. It recorded `reason` ("tier 1 best (3 available)")
575
+ and never what the choice was between, so the log could show the router had
576
+ been used and not whether it chose well.
577
+ - `doctor` fails when an MCP client on this machine is configured to launch
578
+ this server from a path that does not exist.
579
+
580
+ That failure is invisible from both ends: a client which cannot spawn its
581
+ server simply has no tools, which looks exactly like never having installed
582
+ one, and the server never runs so it cannot complain. On the maintainer's
583
+ machine Claude Code spent months launching a `dist/bin.js` under a directory
584
+ that had been renamed away, alongside a session hook pointing at the same
585
+ dead path. Neither said anything, and the tool had no way to notice because
586
+ nothing looked.
587
+
588
+ Read-only — it inspects Claude Code's and Cursor's config and never writes to
589
+ them. Not being registered with any client is fine and reported as such; only
590
+ a path that is genuinely absent fails. A bare command like `npx` is not
591
+ checked, because resolving it means replicating PATH and shim lookup, and
592
+ getting that wrong would report working installs as broken.
593
+
594
+ - `harness-dispatch dispatch "<prompt>"` — one routed task from the command
595
+ line, with `--service`, `--safety`, `--task-type`, `--no-fallback` and
596
+ `--json`. `route` still works; it is the same command under the name the MCP
597
+ tool already uses.
598
+
599
+ It existed as `route` and had no flags, which made it unusable for the job it
600
+ is most needed for: an acceptance pass has to exercise the build in the
601
+ working tree, and the MCP tool runs in whatever server process is already
602
+ connected — a different artifact from a different moment. Meanwhile `route`
603
+ hardcoded `execute` with two fallbacks, so asking for one read-only call on
604
+ one route could get an execute-profile run on up to three. A typo'd
605
+ `--safety` is refused by name rather than dropped to a default, which would
606
+ hand the delegate more access than was asked for.
607
+
608
+ ### Changed
609
+
610
+ - Cursor dispatches send the prompt on STDIN too — the route the ceiling
611
+ actually broke. It is a `cursor-agent.CMD` wrapper, so cmd.exe caps its
612
+ command line, and a 9,031-character prompt once died with the bare "The
613
+ command line is too long." that the check exists to replace. A
614
+ 13,554-character prompt now dispatches and answers. Antigravity stays on argv:
615
+ `agy --print` requires an inline argument and its only stdin path needs a
616
+ different output parser, so the length machinery still has one route to
617
+ protect.
618
+ - Claude Code dispatches send the prompt on STDIN instead of in the command
619
+ line. Every command-line defect this project has fixed — replicating
620
+ cross-spawn escaping, the 8,191-character cmd.exe ceiling, the npm-shim
621
+ double-escape, a refusal band that was too tight and then too loose — applies
622
+ only to routes that pass the prompt as an argument, and this was one of three
623
+ that did. Codex has always used stdin. A 21,670-character prompt — 2.65x the
624
+ 8,191-character ceiling — refused outright by the old form, now dispatches and
625
+ answers.
626
+
627
+ ### Fixed
628
+
629
+ - The CLI no longer aborts with exit 127 after printing a correct answer. A
630
+ routed fallback — the first route answering but unusably, the second
631
+ succeeding — ended in a libuv assertion on Windows and an exit status every
632
+ shell reads as "command not found". The work was done and the report of it
633
+ was a crash. Three acceptance passes reproduced it 3/3 and it was the oldest
634
+ confirmed defect here; the cause was tearing the event loop down with
635
+ `process.exit()` while two HTTP connections were still closing. Verified at
636
+ the built binary: 127 with the assertion on all three runs before, 0 with
637
+ none on all three after.
638
+
639
+ - `base_url` values with a path of their own are no longer mangled. `/v1` was
640
+ appended to anything not already ending in it, so this project's own
641
+ documented `https://generativelanguage.googleapis.com/v1beta/openai` became
642
+ `/v1beta/openai/v1/chat/completions`, which Google does not serve, and an
643
+ `anthropic_messages` host on a non-`/v1` path was unconfigurable. A bare
644
+ origin still gets `/v1`; a path you supplied is now used as you wrote it.
645
+
646
+ - `configure` writes `config.yaml` with 0600. It can contain a literal
647
+ `api_key` — `configure` deliberately preserves one rather than replacing it
648
+ with a `${VAR}` reference — and it was written with default permissions,
649
+ 0644 under a typical umask, in a module family that is careful everywhere
650
+ else. POSIX only; Windows ignores the mode.
651
+
652
+ - Usage counters that cannot reach disk now say so. The in-process view kept
653
+ serving the numbers it had accumulated, so nothing looked wrong until the
654
+ next restart showed every count at zero, with `usage` reporting them as fact
655
+ in between. Reported under `status`'s "State problems", never thrown: a
656
+ dispatch that produced a real answer must not fail over a counter.
657
+
658
+ - `doctor` reports unreadable saved state. It read the config warnings
659
+ directly, so a corrupt breaker record or unwritable usage counters were
660
+ invisible to it — eleven green checks over state it could not read. `status`
661
+ grew a heading for this and `doctor` had not followed.
662
+
663
+ - Two messages that reached the right conclusion by the wrong description: a
664
+ route blocked because its billing CONFIDENCE is unknown was told "billing
665
+ source is unknown", for a route `status` prints as `billing=metered_api`;
666
+ and `doctor` described a dead `--config` path as somewhere the client
667
+ "launches from".
668
+
669
+ - The test suite no longer dispatches to your real subscription harnesses.
670
+ Config entries are additive, so a test config declaring empty CLI and
671
+ endpoint lists did NOT isolate it from the harnesses installed on the
672
+ machine — and one boundary test reached the real Claude Code CLI on every
673
+ `npm test`, every `npm run check` and every CI run, spending quota to prove a
674
+ schema check. An acceptance pass measured it: 6.4 seconds, 47k input tokens.
675
+ The test carried a comment asserting the opposite, which nobody had checked.
676
+ Verified fixed by measurement rather than by reading — the dispatch log stood
677
+ at 425 lines before a full run and 425 after.
678
+
679
+ - A mistyped safety setting no longer gets you MORE access than you asked for.
680
+ `safteyProfile: "read_only"` at the top level of an MCP call was accepted in
681
+ silence and the dispatch then ran at the `workspace_edit` default — an
682
+ acceptance pass measured it writing a file into the project. The HTTP surface
683
+ had refused the same input all along, so one input got two opposite answers.
684
+
685
+ It could not be fixed in the schema: the MCP SDK validates arguments before
686
+ any handler runs and discards unknown keys, so nothing downstream could see
687
+ what was sent. `hints` is strict, which is why the nested form was always
688
+ caught; the outer object cannot be, because MCP carries its own `_meta`
689
+ there. The fix wraps the SDK's tool-call handler and inspects the raw
690
+ arguments before delegating, leaving routing, validation and progress
691
+ reporting untouched. Both surfaces now run one shared check, and the parity
692
+ suite asserts it on both rather than recording the difference as deliberate.
693
+
694
+ The refusal says WHERE the key belongs, per key and per surface, because the
695
+ two differ: the HTTP surface reads all seven hint names from the top level of
696
+ the request, while on MCP only two are top-level and the rest go inside
697
+ `hints`. A single "did you mean X?" sent the caller to a second rejection -
698
+ the same failure this project already recorded from its snake_case traps, "a
699
+ refusal that confidently points at the wrong landing spot costs the round
700
+ trip it exists to save". On the five tools that take no hints at all the
701
+ message says so, rather than warning about access it cannot grant.
702
+
703
+ - An endpoint credential embedded in `base_url` no longer reaches the terminal
704
+ or `logs/dispatches.jsonl` through an error message. The redaction only ever
705
+ cleaned URLs this code assembles; Node embeds the URL it was handed inside
706
+ its OWN exception text, which was then passed through verbatim with a
707
+ redacted URL appended beside it — the two forms side by side, under a comment
708
+ promising the result was safe to paste into a bug report. Userinfo, query
709
+ values and the host are now removed from wrapped messages too.
710
+
711
+ - Applying the same `git_worktree` job twice now says "already applied"
712
+ instead of accusing you of a conflict with your own apply. That answer lived
713
+ only on the empty-patch path, which a worktree patch never reaches — it is
714
+ `git diff <baseCommit>` inside the worktree, so it does not empty out the way
715
+ a rebuilt `copy` patch does. The second apply fell through to the conflict
716
+ check, which saw the file differ from its recorded base (the difference the
717
+ first apply had just made) and pointed the user at `force: true`. The
718
+ walkthrough says both policies answer "already applied"; only one did.
719
+
720
+ The refusal message also claimed "unlike a worktree patch there is no common
721
+ commit for git to merge against" — while refusing a worktree patch, which
722
+ has one. That clause now appears only for `copy`, which it describes.
723
+
724
+ - `workspace apply` no longer refuses on a clean tree when the dispatch ran in
725
+ a SUBDIRECTORY and `HARNESS_DISPATCH_WORKSPACES_DIR` points inside the
726
+ project. `git status` prints repository-root-relative paths; they were being
727
+ resolved against the dispatch directory, so the workspaces folder failed to
728
+ match and read as your uncommitted work. The same defect fixed at the repo
729
+ root a day earlier, surviving one level down.
730
+
731
+ - A `git_worktree` dispatch now explains the three ordinary ways it cannot
732
+ start — no git on PATH, not a git repository, no commits yet — instead of
733
+ handing back raw git internals with no route taken. A freshly initialised
734
+ project is a normal state, not an error.
735
+
736
+ - The streaming HTTP surface no longer emits an SSE `error` frame for a route
737
+ failure that a fallback then recovers. The frame was written the moment a
738
+ route failed, before the next route had been tried, so a request that
739
+ SUCCEEDED still carried an error ahead of its own answer. The OpenAI
740
+ streaming contract has no non-fatal error frame, so a client treating one as
741
+ terminal reported a failure for a request that worked. The frame is now held
742
+ and sent only if nothing succeeds.
743
+
744
+ - A route reporting only `remaining` in its rate-limit headers no longer wipes
745
+ the limit already known for it. Both fields were assigned under a guard that
746
+ only asks whether EITHER arrived, so a partial update nulled the limit — and
747
+ with no limit there is no ratio, so the quota score went back to a full
748
+ 1.0. A route with two requests left scored the same as an untouched one, and
749
+ the router preferred it. Headers are a partial update, not a replacement.
750
+
751
+ - An unreadable `quota_state.json` is now moved aside rather than replaced.
752
+ "No file yet" and "a file I could not read" both produced an empty map, and
753
+ the caller applies its delta to that and writes the result back — so a single
754
+ bad read did not merely stop counting, it REPLACED every route's history.
755
+ Measured: two routes at 5 and 3 calls became one route at 1, with `usage`
756
+ reporting that as fact. Counters are informational, so this does not try to
757
+ recover them; it declines to be the thing that destroys them.
758
+
759
+ - A fanout where NO route can run now errors instead of answering
760
+ `completed: true` with an empty results array. `completed` is `every()` over
761
+ the arms, so zero arms was vacuously true, on the field the tool description
762
+ tells agents to branch on. An unknown route NAME already errored, so the same
763
+ mistake produced two opposite shapes depending on whether the route named
764
+ happened to exist and be disabled. The error names each route and why.
765
+
766
+ - `connect` no longer rewrites a client config whose shape it does not
767
+ understand — at any level, not just the outermost one. "Does it parse" and
768
+ "is it the shape I am about to merge into" are different questions and only
769
+ the first was asked, so an array-rooted file came back as
770
+ `{"0":…,"1":…,"mcpServers":{…}}` with success reported.
771
+
772
+ The first version of this fix guarded the JSON root alone, and an acceptance
773
+ pass the next day found `{"mcpServers":"oops"}` still having its string
774
+ rekeyed the same way — the same defect one level down. The question is now
775
+ asked wherever this spreads: the root, the servers map, and our own existing
776
+ entry. A backup was always taken, so both were recoverable, but this writes
777
+ another application's config, which is the highest-consequence thing here.
778
+
779
+ - `workspace apply` no longer overwrites a file you wrote and committed
780
+ yourself, when the agent CREATED a file at the same path. The conflict check
781
+ compares each touched file against how it looked when the dispatch started;
782
+ an added file has no such record, because it did not exist — and every change
783
+ without one was skipped, so the protection was simply absent for the change
784
+ kind that creates new files. Its ABSENCE is the base: if the path is there
785
+ now, the project gained it. This is verbatim the failure the check was added
786
+ for, live for one of the three change kinds, and committing your work is what
787
+ the dirty-tree refusal tells you to do. `force: true` still overrides.
788
+
789
+ - A route can no longer claim a safety profile it has no flags for by pinning
790
+ `effective_safety`. The pin returned before the flag check ever ran, so a
791
+ route declaring `effective_safety: read_only` while defining flags for only
792
+ `workspace_edit` launched its harness with no safety argument at all, reported
793
+ `read_only` everywhere including the audit log, and wrote a file into the
794
+ project under a read-only dispatch. The flag check now runs first — nothing
795
+ declared can conjure a flag that does not exist. A pin is still honoured for a
796
+ route that is not flag-controlled at all, which is the case it exists for. The
797
+ shipped harnesses are unaffected. Found by an acceptance pass, which also
798
+ found the test pinning the old behaviour describing a fixture it did not have.
799
+
800
+ - A `copy` workspace now SAYS which directories it left out. `bin`, `dist`,
801
+ `build`, `target`, `obj` and `.venv` are excluded by name as build output —
802
+ and are real source directories in some projects. The omission was invisible
803
+ on every surface: the agent reasoned from an incomplete tree, and a change to
804
+ an excluded file could not appear in the patch or the changed-file count.
805
+
806
+ - `workspace apply` no longer refuses on a clean tree when
807
+ `HARNESS_DISPATCH_WORKSPACES_DIR` points inside the project — the
808
+ configuration README recommends, for reflinks. Only the legacy hard-coded
809
+ `.harness-dispatch` name was filtered from the dirty check, so the configured
810
+ workspaces root read as your uncommitted work and blocked every apply.
811
+
812
+ - A failed config hot-reload now reaches `status`, under a `State problems`
813
+ heading, instead of only stderr — which no MCP client and no HTTP caller ever
814
+ sees. The comment at that code described the bug as "nothing on stderr and
815
+ nothing in status" while closing only the first half. Breaker warnings moved
816
+ to the same heading: they were filed under config warnings, which says "these
817
+ change behaviour" and means ignored config entries.
818
+
819
+ - `workspace` operations with `git` missing from PATH now say that, instead of
820
+ `spawn git ENOENT`.
821
+
822
+ - `safeEqual` in the HTTP auth path did its length-mismatch comparison against
823
+ the caller-supplied buffer, so the work scaled with a length an attacker
824
+ chose while the comment claimed the opposite. Not reproduced as an
825
+ exploitable signal, and the result was always correct.
826
+
827
+ - An endpoint that answers HTTP 200 without an answer is now a failure on both
828
+ request paths, not a successful empty answer. A success heals the circuit
829
+ breaker, so a route serving nothing but empty 200s was recorded as healthy
830
+ indefinitely and never tripped.
831
+
832
+ The streaming path — the only one `dispatch` and `job_status` ever take, so
833
+ the surface an orchestrating agent branches on — reported `success: true`
834
+ with an empty output for a body carrying no content at all. The buffered path
835
+ (what the CLI uses) refused that one, but accepted a well-formed response
836
+ whose `content` was the empty string, returning success with nothing in it.
837
+ Both now refuse both, which is what makes the two surfaces agree; an earlier
838
+ entry here claimed the buffered path already refused empty answers, and that
839
+ was wrong.
840
+
841
+ Both paths now answer the same two questions in the same order, in one
842
+ shared function so that the agreement is enforced rather than asserted: was
843
+ the body readable, and did any bytes arrive? A read that FAILED is reported
844
+ as a failed read — saying "no body" there would be a claim about what the
845
+ server sent, and a reset connection is not evidence of it. Otherwise the
846
+ body is quoted if one arrived, and reported as absent if not. The dispatcher
847
+ does not try to work out WHY a response was unusable.
848
+
849
+ Two earlier versions did try, and each was wrong in ways only an acceptance
850
+ pass found. The first called anything that produced no text "no content", so
851
+ an HTML error page, plain prose and a gateway that ignored `stream: true`
852
+ were all described as empty. The second tested whether the body looked like
853
+ SSE, and got three more cases backwards: a stream in a dialect the route was
854
+ not configured for had its real answer thrown away and called empty; SSE
855
+ comment keepalives — which a real provider sends while thinking — were
856
+ called an unexpected shape; and an HTML page containing any `data:` line was
857
+ called empty. Worse, the same body was described two different ways
858
+ depending on how the network happened to split it.
859
+
860
+ A well-formed but empty stream now quotes its own terminator instead of
861
+ being described in nicer words. The message says only that no answer came
862
+ out of the body and shows what did arrive; it does not call the shape
863
+ unexpected, because a stream that carried nothing had exactly the expected
864
+ shape.
865
+
866
+ Reproduced by acceptance passes.
867
+
868
+ - A corrupt circuit-breaker record is now reported as unknown state instead of
869
+ rendering as a healthy route. `status` said `breaker=closed failures=0` — an
870
+ assertion the process had no basis for — and a single bad file therefore
871
+ un-tripped a live cooldown in silence, which is the exact failure this
872
+ persistence layer was added to prevent.
873
+
874
+ Rather than enumerate the ways a record can be corrupt — a list acceptance
875
+ passes kept finding entries missing from — this checks the invariant the
876
+ module already holds: a healthy route has NO file, because a healthy save
877
+ deletes one. So a file that reads back fully healthy is a contradiction, and
878
+ that catches the `[]`, `{}` and foreign-schema records a list of type checks
879
+ had missed, without having to name them.
880
+
881
+ It does NOT catch everything, and an earlier version of this entry claimed
882
+ it did. A record carrying a *deadline* is not fully healthy, so the
883
+ invariant never looks at it — leaving a nonsense deadline to read as a
884
+ normal route. Deadlines are now checked against what the code can actually
885
+ produce: `snapshot()` emits `null` or `Date.now() + remaining`, capped at
886
+ `MAX_COOLDOWN_SEC`, so zero, negative and far-future values were never
887
+ written here. That last one matters most — an unchecked far-future deadline
888
+ would have blocked a route for years rather than merely un-blocking it.
889
+
890
+ What remains, stated rather than papered over: a corrupt deadline that
891
+ happens to look plausible is indistinguishable from a real cooldown that
892
+ has simply expired, because nothing rewrites the file until the route's next
893
+ event. Type checks remain for a wrong-typed field on a record that is
894
+ otherwise not-healthy. A field that is simply ABSENT is still tolerated,
895
+ because older builds wrote fewer of them.
896
+
897
+ The same validation runs on the pre-split `breaker_state.json` read during
898
+ an upgrade, which had none of it: a bad entry there was coerced to healthy,
899
+ skipped as nothing-to-migrate, and the file deleted — so upgrading, the
900
+ moment a live cooldown is most likely to be sitting on disk, destroyed it
901
+ silently. The per-route "healthy is a contradiction" rule cannot apply
902
+ there, because the old format wrote healthy entries legitimately, so the
903
+ shared validator gained a floor instead: a record naming none of the fields
904
+ it understands is not one it understands. Without that floor, `{}` and a
905
+ foreign schema — including the snake_case shape of the Python implementation
906
+ this was ported from — still passed and were still destroyed.
907
+
908
+ A blob is now rewritten with only the entries this could not consume, so
909
+ each entry is read exactly once and the file disappears on a clean upgrade.
910
+ Keeping the WHOLE blob whenever one entry was bad meant the good entries
911
+ were replayed on every read, forever — recreating a per-route record after
912
+ the route had recovered and its record was deleted, leaving it one failure
913
+ from tripping and unable to heal.
914
+
915
+ An unreadable record whose name is not a configured route (every corrupt
916
+ blob, since it has no route name) is reported under its own `status`
917
+ heading, separate from config warnings — nothing here was misconfigured or
918
+ ignored, and `doctor` and the CLI's "ignored config entries" list both read
919
+ the config warnings directly.
920
+
921
+ The lost count cannot be recovered and this does not guess at it — failing
922
+ closed would strand a route until someone deleted a file by hand. `status`
923
+ says the saved state was unreadable and may be stale, in the text output and
924
+ as `breaker.stateUnreadable` in the JSON. `doctor` does not report it.
925
+
926
+ - Workspace reclamation no longer deletes directories it did not create. The
927
+ sweep added earlier in this cycle judged a directory by age alone, so pointed
928
+ at a shared `HARNESS_DISPATCH_WORKSPACES_DIR` it would recursively delete
929
+ anything sitting there untouched for a day. That override is not an obscure
930
+ escape hatch — the README recommends it — and nothing said the directory
931
+ would become this tool's exclusively. An acceptance pass reproduced the loss
932
+ against the built artifact: two unrelated directories with real content
933
+ destroyed by a single dispatch. Default installs were never affected, since
934
+ the default base is dedicated.
935
+
936
+ A directory is now reclaimed only if it carries a marker file this tool
937
+ writes into every root it creates. The first attempt matched the generated
938
+ NAME shape instead, `-[0-9a-f]{8}$` — and eight decimal digits are valid hex,
939
+ so every `<name>-<YYYYMMDD>` still matched: a second acceptance pass planted
940
+ `backup-20260401` beside the directories that now survived and watched one
941
+ dispatch delete it recursively. A heuristic cannot answer "did I create
942
+ this". Roots made before the marker existed are still reclaimed, but only
943
+ when every child is a generated run directory and there is at least one, so
944
+ the earlier disk leak does not return through the back door.
945
+
946
+ The original test passed only because its fixture happened to be named
947
+ `gone-project-deadbeef`, matching that shape by accident. It now has two
948
+ companions that must survive: an ordinary directory, and one named to
949
+ collide with the shape check that failed.
950
+
951
+ - Endpoint redaction actually redacts. `redactEndpointHost` replaced the
952
+ hostname by assigning to `url.hostname`, and the WHATWG URL setter silently
953
+ rejects a value containing `<` and `>` — so it returned its input verbatim,
954
+ every time, while three call sites presented the result as scrubbed (one
955
+ commented "safe to paste into a bug report"). An acceptance pass measured a
956
+ failed dispatch reporting `https://api.secret-internal.example.com/v1?key=…`
957
+ into both the error and the dispatch log. Userinfo, query and fragment are
958
+ now dropped on every path including loopback, since a key embedded in a URL
959
+ is a credential wherever the host points. It shipped inert because nothing
960
+ tested it; it has tests now.
961
+
962
+ - `auth rotate` invalidates the old token for a running server. The token was
963
+ read once at startup and held, so rotation was a lie in both directions: the
964
+ old token kept returning 200 and the newly issued one was refused with 401 —
965
+ measured. Invalidating the old value is the only reason anyone rotates a
966
+ credential. The token file is re-read when its mtime moves, so the common
967
+ path stays a stat.
968
+
969
+ - `connect` will not replace a client entry you edited by hand without your
970
+ say-so. `connect --remove` always refused this; `connect` did not, so the
971
+ protection existed on the half where the cost is lower — and `OPERATIONS.md`
972
+ promised it for both. Running `connect` with no `--clients` shows the
973
+ difference and asks; `--force` overrides. Naming a client is not consent to
974
+ overwrite what is there.
975
+
976
+ - A job stranded in the slot queue by a server that exited is reported as
977
+ orphaned instead of reading `queued` forever. Slot-queued jobs are exempt
978
+ from orphan detection because nothing heartbeats for them, and the only
979
+ things that drained the queue were a runner exiting or a new dispatch
980
+ arriving. Reported, deliberately, rather than resumed: resuming was tried
981
+ first and an acceptance pass demonstrated the cost — kill a server with a job
982
+ queued, restart, and it runs to completion in its original working directory
983
+ at up to `full_auto`, unattended, bounded only by the 7-day retention window.
984
+ `retry_job` re-runs it as a decision.
985
+
986
+ Only when no supervisor is alive to run it. The first version of this reasoned
987
+ that a starting server means any queued job belongs to a dead session — false
988
+ in the configuration this ships by default, where `connect` registers Claude
989
+ Code and Cursor and `serve` is a third, all sharing one jobs root. Measured:
990
+ with one server live and holding a legitimately queued job, starting a second
991
+ marked it orphaned within a second and removed it from the drain queue,
992
+ killing live work with an error stating a cause that was not true.
993
+
994
+ - A streamed request stops rather than falling back once any answer text has
995
+ been sent. It fell back anyway and then discarded what the fallback produced:
996
+ an acceptance pass measured an endpoint streaming `he`, `llo `, dying, and the
997
+ fallback route succeeding with 49 characters the client never saw — produced,
998
+ charged for, thrown away. A fresh answer cannot be spliced onto a half-sent
999
+ one without garbling it, so the client now gets a truthful error and no second
1000
+ route is billed. Falling back before anything is sent still happens, which is
1001
+ the case fallback exists for.
1002
+
1003
+ - `git_worktree` no longer leaves a worktree registered in your repository when
1004
+ an attempt failed without changing anything. Retention deliberately never
1005
+ removes worktrees — unregistering one needs git, and only the owning repo can
1006
+ do it — so they accumulated per attempt, and a failed FALLBACK arm is not
1007
+ named in the response at all, so its worktree had no cleanup hint anywhere.
1008
+ Measured: one HTTP request leaving two entries in `git worktree list`. A
1009
+ failure that DID change files is still kept, because it may hold work worth
1010
+ recovering.
1011
+
1012
+ - `connect --yes` no longer overwrites a hand-edited client entry. The consent
1013
+ gate added for `--clients` treated "no client named" as consent, and `--yes`
1014
+ takes that path — so the flag that skips the question was accepted as an
1015
+ answer to it. Consent is answering the prompt, or `--force`.
1016
+
1017
+ - Streaming returns the ANSWER, not the harness's protocol. `POST
1018
+ /v1/chat/completions` with `stream: true` forwarded every stdout chunk into
1019
+ `delta.content`, so a client concatenating deltas from a CLI harness received
1020
+ `{"type":"thread.started",...}` and internal thread ids — while the
1021
+ non-streaming call on the same endpoint returned the parsed result. One
1022
+ endpoint, two answers, and the streaming one was unusable by the clients the
1023
+ OpenAI envelope exists for. An endpoint route still streams its text as it
1024
+ arrives; a CLI harness sends its answer once, at completion.
1025
+
1026
+ Streaming still creates no job record, so there is no `jobId` and an
1027
+ interrupted stream cannot be recovered. Now stated in the README and
1028
+ OPERATIONS rather than left to be discovered.
1029
+
1030
+ - The HTTP surface refuses a bare `safety` key instead of dropping it. This
1031
+ product's own CLI flag is `--safety`, so it is the most plausible slip anyone
1032
+ will make — and at seven edits from `safetyProfile` the near-miss rule
1033
+ correctly declines to guess, which left it accepted and silently ignored while
1034
+ the dispatch ran at the default profile.
1035
+
1036
+ - `models: []` is refused instead of fanning out to every route. An explicit
1037
+ empty array fell through to the same branch as omitting the field, so a
1038
+ caller whose filter matched nothing got one dispatch per configured route —
1039
+ eight arms where an acceptance pass measured it. Omitting `models` is still
1040
+ how you ask for that, and it stays a deliberate keystroke.
1041
+
1042
+ - Job retention no longer deletes directories this tool never created. The
1043
+ sweep removed every stale directory under the jobs root recursively, with no
1044
+ check of any kind — the same defect workspace reclamation shipped twice in
1045
+ this release, found by an acceptance pass in the one place nobody had looked.
1046
+ Pointed at a directory holding `backup-20260401` and `my-notes`, it destroyed
1047
+ both. Only `job-<timestamp>-<8 hex>` directories are eligible now. Narrower
1048
+ in practice than the workspace case — neither `HARNESS_DISPATCH_JOBS_DIR` nor
1049
+ `HARNESS_DISPATCH_STATE_DIR` is documented in the README, unlike the
1050
+ workspaces override — but "narrower" is not a property anyone can rely on.
1051
+
1052
+ - `workingDir` must be an absolute path. A relative one was resolved against
1053
+ the SERVER's working directory rather than the caller's — and `../..` exists,
1054
+ so every check passed and a real dispatch ran somewhere neither party chose.
1055
+ The omitted-value warning could not fire either, since the value was not
1056
+ omitted. The caller and the server are different processes with different
1057
+ working directories, so there is no correct relative value to accept.
1058
+
1059
+ - The HTTP surface rejects a top-level key that is nearly a hint name, instead
1060
+ of accepting and dropping it. The outer body cannot be strict — it carries
1061
+ OpenAI's own fields — so `safteyProfile` (a transposition) and hints wrapped
1062
+ in `harness_dispatch` (the key this endpoint uses in its own *responses*, so
1063
+ the natural wrong guess) both returned HTTP 200 and dispatched at the default
1064
+ `workspace_edit`: more access than the caller asked for, with no signal,
1065
+ while the correct spelling produced `read_only`. Now refused by name with the
1066
+ intended spelling. One typo apart, transpositions included; unrelated keys
1067
+ and every OpenAI field stay legitimate.
1068
+
1069
+ - A CLI route asked for a safety profile its protocol has no flags for is now
1070
+ refused instead of run unconstrained. `{{safety}}` expands to the protocol's
1071
+ arguments for the requested profile and to nothing when the profile is
1072
+ missing, so a user-added route defining `workspace_edit` and `full_auto` but
1073
+ not `read_only` launched the harness with NO safety arguments — and every
1074
+ surface said `read_only`, including the dispatch log. An acceptance pass
1075
+ measured the child's argv: just the prompt. A route that cannot show it
1076
+ constrains anything is now treated as constraining nothing, which makes the
1077
+ existing compatibility check refuse it. The shipped harnesses are unaffected;
1078
+ they define all three profiles or pin the gaps with `effective_safety`.
1079
+
1080
+ - `status --json` and the `harness-dispatch://status.json` MCP resource no
1081
+ longer emit a route's `base_url` verbatim. Credentials embedded in the URL —
1082
+ `?key=…`, which is Google AI Studio's own shape — reached both, and that
1083
+ resource is one this server's instructions tell agents to read, so the
1084
+ credential landed in an agent's context. The text rendering was always
1085
+ redacted, which is how it hid. `redactEndpointHost` is also idempotent now:
1086
+ applying it twice used to be worse than once, degrading the model-discovery
1087
+ hint to a bare placeholder.
1088
+
1089
+ - `overrides:` gets the same value and unknown-key checks as the route blocks.
1090
+ It was left out when they were added, and it is the block most likely to carry
1091
+ the fields they exist for — the shipped config presents `overrides:` as the
1092
+ way to adjust `tier` and `weight` without writing a full config.
1093
+
1094
+ - A recognised config key carrying the wrong TYPE of value now says so instead
1095
+ of silently taking the default. The unknown-key warning covered a misspelled
1096
+ key; it never covered a correctly-spelled one whose value cannot be read,
1097
+ because the key is not unknown — `coercions.ts` drops on mismatch and the
1098
+ caller supplies a default. Found live on the maintainer's own machine by an
1099
+ acceptance pass: four routes carrying `tier: metered`, which is not a number,
1100
+ silently running at the default tier 3, with nothing ever having said so.
1101
+ `weight: very-high` becomes 1.0 the same way, and both feed routing.
1102
+
1103
+ - A route name declared twice now warns that everything the earlier entry set is
1104
+ discarded. Measured: a first entry setting `safety_profile: read_only` and
1105
+ `workspace_policy: copy`, replaced wholesale by a second with neither, left
1106
+ the surviving route running `workspace_edit` / `shared_locked` — silently
1107
+ LESS restrictive than what was written, with no warning on any surface.
1108
+
1109
+ - `services:` written as a YAML list no longer fails silently. It must be a map
1110
+ of route id to settings, but `typeof [] === "object"`, so a list slipped
1111
+ through and became routes called `0`, `1`, … with each item's `name:`
1112
+ ignored. Nothing looked wrong — `doctor` reported the routes and `status`
1113
+ listed them — until `--service my_route` answered "Unknown service". The
1114
+ mistake is a natural one: the sibling keys `clis:` and `endpoints:` ARE lists
1115
+ whose items carry `name:`. The behaviour is unchanged; it now says what
1116
+ happened, what the ids became, which names were dropped, and how to write it.
1117
+
1118
+ - `connect` keeps the last three backups of a client config rather than one per
1119
+ run forever. Every write and every removal takes one, and `~/.claude.json`
1120
+ holds live API keys, so the old behaviour accumulated copies of someone's
1121
+ secrets with nothing to prune them. They are bounded rather than deleted by
1122
+ `--remove`: undoing a registration is the worst moment to destroy the record
1123
+ of what it replaced. Backups anyone else made are not touched.
1124
+
1125
+ - Retrying a job on a DIFFERENT route no longer carries the old route's model,
1126
+ which defeated the one thing retargeting exists for. A model name belongs to
1127
+ the route it was picked for, so reusing it verbatim made the retry fail for
1128
+ the same reason as the original: observed end to end, a Cursor run that died
1129
+ on `Cannot use this model` was retried onto Claude and died on
1130
+ `unrecognized_model`, never reaching the task. That same job now completes.
1131
+
1132
+ Narrow, and reported rather than silent. The model is kept when the retry
1133
+ stays on the original route (a plain "try that again") and when the new route
1134
+ declares it anyway; only a model the destination does not know is left
1135
+ behind, and the response says so as `droppedModel`.
1136
+
1137
+ - Workspaces belonging to projects that never dispatch again are reclaimed.
1138
+ Retention only ever swept inside one project's own directory, and only when
1139
+ that project dispatched again — so a project renamed, deleted, or created as
1140
+ a throwaway temp directory kept its workspaces forever, because the code that
1141
+ would reclaim them was reachable only from a project that no longer existed.
1142
+ Measured on the maintainer's machine: 840 project directories, 839 of them
1143
+ still holding runs five days past a 24-hour window. This project has already
1144
+ lost a disk to leaked scratch directories once.
1145
+
1146
+ Conservative by construction: a project directory is removed only when every
1147
+ run inside it is past retention, never the caller's own, and never one
1148
+ holding a git worktree — those need git's own removal, which only the owning
1149
+ repository can do.
1150
+
1151
+ - `taskType: "local"` now actually reaches a local endpoint. It could not: the
1152
+ preference was a score bonus, a bonus only reorders routes within a tier, and
1153
+ local endpoints sit in the cheap tier — so any healthy top-tier route won
1154
+ before the bonus was ever consulted. Measured on a real config, every task
1155
+ type including `local` resolved to the same top-tier CLI, and a configured
1156
+ local box had zero calls in a month. It is a cross-tier selection rule now,
1157
+ and the only one: tier gating still stops plan and review work drifting onto
1158
+ a weaker route, because those task types are about capability and this one is
1159
+ explicitly not.
1160
+ - "Local" means one thing again, and it is what a route DECLARES — provider,
1161
+ surface, auth source, billing kind — exactly as `routePolicy: "local_only"`
1162
+ has always decided it. `taskType: "local"` used its own narrower test of a
1163
+ loopback URL, so a real box on a LAN or tailnet address was local enough to
1164
+ be the only thing `local_only` would run, and not local enough for the task
1165
+ type named after it.
1166
+
1167
+ A URL shape no longer overrides a declaration. Briefly it did both, and that
1168
+ was worse: a metered proxy on 127.0.0.1 — LiteLLM, OpenRouter, anything
1169
+ fronting a paid API — declares itself metered, and the loopback check
1170
+ overruled it, so the one task type meaning "free local endpoint" preferred
1171
+ the PAID route over a free subscription CLI. Nothing was lost by removing it:
1172
+ a local box that declares the fields is already covered, one declaring
1173
+ nothing on a known runtime port is inferred local from the port, and one on
1174
+ any other port never reaches candidacy at all.
1175
+
9
1176
  ## [0.7.9] — 2026-08-28
10
1177
 
11
1178
  **Upgrade from 0.7.8.** Fixes found by using the thing: two from the release
@@ -789,7 +1956,9 @@ the MCP surface to three tools: `dispatch`, `job_status`, `usage`.
789
1956
  Known issues in this release, fixed in 0.5.0: `configure` writes resolved API keys into
790
1957
  its output, and `configure --yes --force` can delete user-added harnesses.
791
1958
 
792
- [Unreleased]: https://github.com/fstubner/harness-dispatch/compare/v0.7.9...HEAD
1959
+ [Unreleased]: https://github.com/fstubner/harness-dispatch/compare/v0.9.0...HEAD
1960
+ [0.9.0]: https://github.com/fstubner/harness-dispatch/compare/v0.8.0...v0.9.0
1961
+ [0.8.0]: https://github.com/fstubner/harness-dispatch/compare/v0.7.9...v0.8.0
793
1962
  [0.7.9]: https://github.com/fstubner/harness-dispatch/compare/v0.7.8...v0.7.9
794
1963
  [0.7.8]: https://github.com/fstubner/harness-dispatch/compare/v0.7.7...v0.7.8
795
1964
  [0.7.7]: https://github.com/fstubner/harness-dispatch/compare/v0.7.6...v0.7.7