harness-dispatch 0.7.8 → 0.8.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 (114) hide show
  1. package/CHANGELOG.md +706 -1
  2. package/README.md +35 -5
  3. package/config.default.yaml +30 -2
  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/bin.d.ts.map +1 -1
  9. package/dist/bin.js +449 -21
  10. package/dist/bin.js.map +1 -1
  11. package/dist/breaker-store.d.ts +25 -1
  12. package/dist/breaker-store.d.ts.map +1 -1
  13. package/dist/breaker-store.js +193 -25
  14. package/dist/breaker-store.js.map +1 -1
  15. package/dist/client-register.d.ts +147 -0
  16. package/dist/client-register.d.ts.map +1 -0
  17. package/dist/client-register.js +380 -0
  18. package/dist/client-register.js.map +1 -0
  19. package/dist/config/validation.d.ts +29 -0
  20. package/dist/config/validation.d.ts.map +1 -1
  21. package/dist/config/validation.js +88 -0
  22. package/dist/config/validation.js.map +1 -1
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +55 -2
  25. package/dist/config.js.map +1 -1
  26. package/dist/dispatch-log.d.ts +14 -0
  27. package/dist/dispatch-log.d.ts.map +1 -1
  28. package/dist/dispatch-log.js +3 -0
  29. package/dist/dispatch-log.js.map +1 -1
  30. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  31. package/dist/dispatchers/generic-cli.js +35 -0
  32. package/dist/dispatchers/generic-cli.js.map +1 -1
  33. package/dist/dispatchers/openai-compatible.d.ts +20 -4
  34. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  35. package/dist/dispatchers/openai-compatible.js +145 -17
  36. package/dist/dispatchers/openai-compatible.js.map +1 -1
  37. package/dist/dispatchers/shared/rate-limit-headers.d.ts +4 -2
  38. package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -1
  39. package/dist/dispatchers/shared/rate-limit-headers.js +4 -2
  40. package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -1
  41. package/dist/http/answer-stream.d.ts +52 -0
  42. package/dist/http/answer-stream.d.ts.map +1 -0
  43. package/dist/http/answer-stream.js +47 -0
  44. package/dist/http/answer-stream.js.map +1 -0
  45. package/dist/http/parse.d.ts.map +1 -1
  46. package/dist/http/parse.js +41 -0
  47. package/dist/http/parse.js.map +1 -1
  48. package/dist/http/server.d.ts.map +1 -1
  49. package/dist/http/server.js +102 -9
  50. package/dist/http/server.js.map +1 -1
  51. package/dist/jobs/store.d.ts.map +1 -1
  52. package/dist/jobs/store.js +45 -4
  53. package/dist/jobs/store.js.map +1 -1
  54. package/dist/jobs.d.ts +24 -0
  55. package/dist/jobs.d.ts.map +1 -1
  56. package/dist/jobs.js +101 -1
  57. package/dist/jobs.js.map +1 -1
  58. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  59. package/dist/mcp/config-hot-reload.js +10 -0
  60. package/dist/mcp/config-hot-reload.js.map +1 -1
  61. package/dist/mcp/near-miss-guard.d.ts +45 -0
  62. package/dist/mcp/near-miss-guard.d.ts.map +1 -0
  63. package/dist/mcp/near-miss-guard.js +98 -0
  64. package/dist/mcp/near-miss-guard.js.map +1 -0
  65. package/dist/mcp/server.d.ts.map +1 -1
  66. package/dist/mcp/server.js +36 -0
  67. package/dist/mcp/server.js.map +1 -1
  68. package/dist/mcp/tool-schemas.d.ts.map +1 -1
  69. package/dist/mcp/tool-schemas.js +21 -4
  70. package/dist/mcp/tool-schemas.js.map +1 -1
  71. package/dist/mcp/tools.d.ts +32 -0
  72. package/dist/mcp/tools.d.ts.map +1 -1
  73. package/dist/mcp/tools.js +41 -0
  74. package/dist/mcp/tools.js.map +1 -1
  75. package/dist/mcp-clients.d.ts +63 -0
  76. package/dist/mcp-clients.d.ts.map +1 -0
  77. package/dist/mcp-clients.js +117 -0
  78. package/dist/mcp-clients.js.map +1 -0
  79. package/dist/near-miss.d.ts +63 -0
  80. package/dist/near-miss.d.ts.map +1 -0
  81. package/dist/near-miss.js +132 -0
  82. package/dist/near-miss.js.map +1 -0
  83. package/dist/quota.d.ts +31 -1
  84. package/dist/quota.d.ts.map +1 -1
  85. package/dist/quota.js +68 -6
  86. package/dist/quota.js.map +1 -1
  87. package/dist/route-policy.d.ts +10 -0
  88. package/dist/route-policy.d.ts.map +1 -1
  89. package/dist/route-policy.js +20 -2
  90. package/dist/route-policy.js.map +1 -1
  91. package/dist/router.d.ts +61 -2
  92. package/dist/router.d.ts.map +1 -1
  93. package/dist/router.js +165 -29
  94. package/dist/router.js.map +1 -1
  95. package/dist/safety.d.ts.map +1 -1
  96. package/dist/safety.js +31 -1
  97. package/dist/safety.js.map +1 -1
  98. package/dist/status.d.ts +30 -21
  99. package/dist/status.d.ts.map +1 -1
  100. package/dist/status.js +152 -8
  101. package/dist/status.js.map +1 -1
  102. package/dist/types.d.ts +43 -1
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/working-dir.d.ts.map +1 -1
  105. package/dist/working-dir.js +16 -0
  106. package/dist/working-dir.js.map +1 -1
  107. package/dist/workspace-resolve.d.ts.map +1 -1
  108. package/dist/workspace-resolve.js +119 -15
  109. package/dist/workspace-resolve.js.map +1 -1
  110. package/dist/workspaces.d.ts +83 -0
  111. package/dist/workspaces.d.ts.map +1 -1
  112. package/dist/workspaces.js +288 -15
  113. package/dist/workspaces.js.map +1 -1
  114. package/package.json +4 -3
package/CHANGELOG.md CHANGED
@@ -6,6 +6,709 @@ pre-1.0, so minor versions can carry behaviour changes.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] — 2026-08-31
10
+
11
+ ### Added
12
+
13
+ - `GET /health` — liveness on the HTTP surface, and the only route served
14
+ without a token. Every endpoint required the bearer token, so a deploy gate
15
+ or container probe could not ask whether the process was up without being
16
+ handed a credential, and a health check that needs a secret is one most
17
+ orchestrators will not perform. It answers `{"status","service","version"}`
18
+ and nothing else; `/v1/status` keeps the richer answer behind the token.
19
+
20
+ - `OPERATIONS.md` — the signals worth watching, what there is to alert on (and
21
+ honestly, that there is nobody to page for a tool that runs on one laptop),
22
+ the failure modes that have actually happened here, and how to recover from
23
+ each.
24
+
25
+ - `harness-dispatch connect` registers this server with the MCP clients on your
26
+ machine, and `configure --yes` now offers it as the last step of setup instead
27
+ of printing JSON for you to paste. `connect --remove` takes the entry back out.
28
+
29
+ Setting this up was a copy-paste job that nobody owned, and the paths in it
30
+ later moved. On the maintainer's machine that produced, simultaneously: a
31
+ Claude Code entry launching a directory renamed away months earlier, a session
32
+ hook pointing at the same dead path, and a working Cursor entry with no
33
+ `--config` — so the two clients disagreed about which routes existed while
34
+ both appeared to work.
35
+
36
+ Careful with other applications' files, because this project has already got
37
+ this wrong once (v0.1.0's setup wrote instructions and a hook; v0.2.0 removed
38
+ the command and left both behind for seven minor versions). Only the two
39
+ config shapes actually opened on a real machine are written. Every write is
40
+ backed up next to the file, merged rather than replaced — other servers and
41
+ our own entry's `env`, which holds live API keys, are preserved — and swapped
42
+ in atomically after being parsed back, so a half-written `~/.claude.json` is
43
+ not a possible outcome. An entry that already exists and differs is shown and
44
+ left alone unless you say otherwise; that Cursor entry was the working one.
45
+ Re-running changes nothing. With no terminal and no `--clients`, it reports
46
+ what it would do and writes nothing rather than prompting into the void.
47
+
48
+ `connect --dev` registers the checkout you are running instead of the
49
+ published package, for anyone developing against this repo. Without it that
50
+ case is not merely unsupported but actively wrong: on a machine with nothing
51
+ installed globally, the package form resolves through `npx` to whatever is on
52
+ the registry, so registering it swaps a checkout that is commits ahead for an
53
+ older release and reports success. Found by running `connect` on the
54
+ maintainer's machine, where it correctly declined to do exactly that. The
55
+ trade — an absolute path breaks silently when the directory moves — is why it
56
+ is opt-in per run, prints the path before writing it, and leans on the
57
+ `doctor` check that already fails on a client entry naming a path that has
58
+ gone.
59
+
60
+ - The dispatch log records `candidates` — what the picked route beat — so the
61
+ question the field was added to answer can be asked of a month of history
62
+ rather than one response. It recorded `reason` ("tier 1 best (3 available)")
63
+ and never what the choice was between, so the log could show the router had
64
+ been used and not whether it chose well.
65
+ - `doctor` fails when an MCP client on this machine is configured to launch
66
+ this server from a path that does not exist.
67
+
68
+ That failure is invisible from both ends: a client which cannot spawn its
69
+ server simply has no tools, which looks exactly like never having installed
70
+ one, and the server never runs so it cannot complain. On the maintainer's
71
+ machine Claude Code spent months launching a `dist/bin.js` under a directory
72
+ that had been renamed away, alongside a session hook pointing at the same
73
+ dead path. Neither said anything, and the tool had no way to notice because
74
+ nothing looked.
75
+
76
+ Read-only — it inspects Claude Code's and Cursor's config and never writes to
77
+ them. Not being registered with any client is fine and reported as such; only
78
+ a path that is genuinely absent fails. A bare command like `npx` is not
79
+ checked, because resolving it means replicating PATH and shim lookup, and
80
+ getting that wrong would report working installs as broken.
81
+
82
+ - `harness-dispatch dispatch "<prompt>"` — one routed task from the command
83
+ line, with `--service`, `--safety`, `--task-type`, `--no-fallback` and
84
+ `--json`. `route` still works; it is the same command under the name the MCP
85
+ tool already uses.
86
+
87
+ It existed as `route` and had no flags, which made it unusable for the job it
88
+ is most needed for: an acceptance pass has to exercise the build in the
89
+ working tree, and the MCP tool runs in whatever server process is already
90
+ connected — a different artifact from a different moment. Meanwhile `route`
91
+ hardcoded `execute` with two fallbacks, so asking for one read-only call on
92
+ one route could get an execute-profile run on up to three. A typo'd
93
+ `--safety` is refused by name rather than dropped to a default, which would
94
+ hand the delegate more access than was asked for.
95
+
96
+ ### Changed
97
+
98
+ - Cursor dispatches send the prompt on STDIN too — the route the ceiling
99
+ actually broke. It is a `cursor-agent.CMD` wrapper, so cmd.exe caps its
100
+ command line, and a 9,031-character prompt once died with the bare "The
101
+ command line is too long." that the check exists to replace. A
102
+ 13,554-character prompt now dispatches and answers. Antigravity stays on argv:
103
+ `agy --print` requires an inline argument and its only stdin path needs a
104
+ different output parser, so the length machinery still has one route to
105
+ protect.
106
+ - Claude Code dispatches send the prompt on STDIN instead of in the command
107
+ line. Every command-line defect this project has fixed — replicating
108
+ cross-spawn escaping, the 8,191-character cmd.exe ceiling, the npm-shim
109
+ double-escape, a refusal band that was too tight and then too loose — applies
110
+ only to routes that pass the prompt as an argument, and this was one of three
111
+ that did. Codex has always used stdin. A 21,670-character prompt — 2.65x the
112
+ 8,191-character ceiling — refused outright by the old form, now dispatches and
113
+ answers.
114
+
115
+ ### Fixed
116
+
117
+ - The CLI no longer aborts with exit 127 after printing a correct answer. A
118
+ routed fallback — the first route answering but unusably, the second
119
+ succeeding — ended in a libuv assertion on Windows and an exit status every
120
+ shell reads as "command not found". The work was done and the report of it
121
+ was a crash. Three acceptance passes reproduced it 3/3 and it was the oldest
122
+ confirmed defect here; the cause was tearing the event loop down with
123
+ `process.exit()` while two HTTP connections were still closing. Verified at
124
+ the built binary: 127 with the assertion on all three runs before, 0 with
125
+ none on all three after.
126
+
127
+ - `base_url` values with a path of their own are no longer mangled. `/v1` was
128
+ appended to anything not already ending in it, so this project's own
129
+ documented `https://generativelanguage.googleapis.com/v1beta/openai` became
130
+ `/v1beta/openai/v1/chat/completions`, which Google does not serve, and an
131
+ `anthropic_messages` host on a non-`/v1` path was unconfigurable. A bare
132
+ origin still gets `/v1`; a path you supplied is now used as you wrote it.
133
+
134
+ - `configure` writes `config.yaml` with 0600. It can contain a literal
135
+ `api_key` — `configure` deliberately preserves one rather than replacing it
136
+ with a `${VAR}` reference — and it was written with default permissions,
137
+ 0644 under a typical umask, in a module family that is careful everywhere
138
+ else. POSIX only; Windows ignores the mode.
139
+
140
+ - Usage counters that cannot reach disk now say so. The in-process view kept
141
+ serving the numbers it had accumulated, so nothing looked wrong until the
142
+ next restart showed every count at zero, with `usage` reporting them as fact
143
+ in between. Reported under `status`'s "State problems", never thrown: a
144
+ dispatch that produced a real answer must not fail over a counter.
145
+
146
+ - `doctor` reports unreadable saved state. It read the config warnings
147
+ directly, so a corrupt breaker record or unwritable usage counters were
148
+ invisible to it — eleven green checks over state it could not read. `status`
149
+ grew a heading for this and `doctor` had not followed.
150
+
151
+ - Two messages that reached the right conclusion by the wrong description: a
152
+ route blocked because its billing CONFIDENCE is unknown was told "billing
153
+ source is unknown", for a route `status` prints as `billing=metered_api`;
154
+ and `doctor` described a dead `--config` path as somewhere the client
155
+ "launches from".
156
+
157
+ - The test suite no longer dispatches to your real subscription harnesses.
158
+ Config entries are additive, so a test config declaring empty CLI and
159
+ endpoint lists did NOT isolate it from the harnesses installed on the
160
+ machine — and one boundary test reached the real Claude Code CLI on every
161
+ `npm test`, every `npm run check` and every CI run, spending quota to prove a
162
+ schema check. An acceptance pass measured it: 6.4 seconds, 47k input tokens.
163
+ The test carried a comment asserting the opposite, which nobody had checked.
164
+ Verified fixed by measurement rather than by reading — the dispatch log stood
165
+ at 425 lines before a full run and 425 after.
166
+
167
+ - A mistyped safety setting no longer gets you MORE access than you asked for.
168
+ `safteyProfile: "read_only"` at the top level of an MCP call was accepted in
169
+ silence and the dispatch then ran at the `workspace_edit` default — an
170
+ acceptance pass measured it writing a file into the project. The HTTP surface
171
+ had refused the same input all along, so one input got two opposite answers.
172
+
173
+ It could not be fixed in the schema: the MCP SDK validates arguments before
174
+ any handler runs and discards unknown keys, so nothing downstream could see
175
+ what was sent. `hints` is strict, which is why the nested form was always
176
+ caught; the outer object cannot be, because MCP carries its own `_meta`
177
+ there. The fix wraps the SDK's tool-call handler and inspects the raw
178
+ arguments before delegating, leaving routing, validation and progress
179
+ reporting untouched. Both surfaces now run one shared check, and the parity
180
+ suite asserts it on both rather than recording the difference as deliberate.
181
+
182
+ The refusal says WHERE the key belongs, per key and per surface, because the
183
+ two differ: the HTTP surface reads all seven hint names from the top level of
184
+ the request, while on MCP only two are top-level and the rest go inside
185
+ `hints`. A single "did you mean X?" sent the caller to a second rejection -
186
+ the same failure this project already recorded from its snake_case traps, "a
187
+ refusal that confidently points at the wrong landing spot costs the round
188
+ trip it exists to save". On the five tools that take no hints at all the
189
+ message says so, rather than warning about access it cannot grant.
190
+
191
+ - An endpoint credential embedded in `base_url` no longer reaches the terminal
192
+ or `logs/dispatches.jsonl` through an error message. The redaction only ever
193
+ cleaned URLs this code assembles; Node embeds the URL it was handed inside
194
+ its OWN exception text, which was then passed through verbatim with a
195
+ redacted URL appended beside it — the two forms side by side, under a comment
196
+ promising the result was safe to paste into a bug report. Userinfo, query
197
+ values and the host are now removed from wrapped messages too.
198
+
199
+ - Applying the same `git_worktree` job twice now says "already applied"
200
+ instead of accusing you of a conflict with your own apply. That answer lived
201
+ only on the empty-patch path, which a worktree patch never reaches — it is
202
+ `git diff <baseCommit>` inside the worktree, so it does not empty out the way
203
+ a rebuilt `copy` patch does. The second apply fell through to the conflict
204
+ check, which saw the file differ from its recorded base (the difference the
205
+ first apply had just made) and pointed the user at `force: true`. The
206
+ walkthrough says both policies answer "already applied"; only one did.
207
+
208
+ The refusal message also claimed "unlike a worktree patch there is no common
209
+ commit for git to merge against" — while refusing a worktree patch, which
210
+ has one. That clause now appears only for `copy`, which it describes.
211
+
212
+ - `workspace apply` no longer refuses on a clean tree when the dispatch ran in
213
+ a SUBDIRECTORY and `HARNESS_DISPATCH_WORKSPACES_DIR` points inside the
214
+ project. `git status` prints repository-root-relative paths; they were being
215
+ resolved against the dispatch directory, so the workspaces folder failed to
216
+ match and read as your uncommitted work. The same defect fixed at the repo
217
+ root a day earlier, surviving one level down.
218
+
219
+ - A `git_worktree` dispatch now explains the three ordinary ways it cannot
220
+ start — no git on PATH, not a git repository, no commits yet — instead of
221
+ handing back raw git internals with no route taken. A freshly initialised
222
+ project is a normal state, not an error.
223
+
224
+ - The streaming HTTP surface no longer emits an SSE `error` frame for a route
225
+ failure that a fallback then recovers. The frame was written the moment a
226
+ route failed, before the next route had been tried, so a request that
227
+ SUCCEEDED still carried an error ahead of its own answer. The OpenAI
228
+ streaming contract has no non-fatal error frame, so a client treating one as
229
+ terminal reported a failure for a request that worked. The frame is now held
230
+ and sent only if nothing succeeds.
231
+
232
+ - A route reporting only `remaining` in its rate-limit headers no longer wipes
233
+ the limit already known for it. Both fields were assigned under a guard that
234
+ only asks whether EITHER arrived, so a partial update nulled the limit — and
235
+ with no limit there is no ratio, so the quota score went back to a full
236
+ 1.0. A route with two requests left scored the same as an untouched one, and
237
+ the router preferred it. Headers are a partial update, not a replacement.
238
+
239
+ - An unreadable `quota_state.json` is now moved aside rather than replaced.
240
+ "No file yet" and "a file I could not read" both produced an empty map, and
241
+ the caller applies its delta to that and writes the result back — so a single
242
+ bad read did not merely stop counting, it REPLACED every route's history.
243
+ Measured: two routes at 5 and 3 calls became one route at 1, with `usage`
244
+ reporting that as fact. Counters are informational, so this does not try to
245
+ recover them; it declines to be the thing that destroys them.
246
+
247
+ - A fanout where NO route can run now errors instead of answering
248
+ `completed: true` with an empty results array. `completed` is `every()` over
249
+ the arms, so zero arms was vacuously true, on the field the tool description
250
+ tells agents to branch on. An unknown route NAME already errored, so the same
251
+ mistake produced two opposite shapes depending on whether the route named
252
+ happened to exist and be disabled. The error names each route and why.
253
+
254
+ - `connect` no longer rewrites a client config whose shape it does not
255
+ understand — at any level, not just the outermost one. "Does it parse" and
256
+ "is it the shape I am about to merge into" are different questions and only
257
+ the first was asked, so an array-rooted file came back as
258
+ `{"0":…,"1":…,"mcpServers":{…}}` with success reported.
259
+
260
+ The first version of this fix guarded the JSON root alone, and an acceptance
261
+ pass the next day found `{"mcpServers":"oops"}` still having its string
262
+ rekeyed the same way — the same defect one level down. The question is now
263
+ asked wherever this spreads: the root, the servers map, and our own existing
264
+ entry. A backup was always taken, so both were recoverable, but this writes
265
+ another application's config, which is the highest-consequence thing here.
266
+
267
+ - `workspace apply` no longer overwrites a file you wrote and committed
268
+ yourself, when the agent CREATED a file at the same path. The conflict check
269
+ compares each touched file against how it looked when the dispatch started;
270
+ an added file has no such record, because it did not exist — and every change
271
+ without one was skipped, so the protection was simply absent for the change
272
+ kind that creates new files. Its ABSENCE is the base: if the path is there
273
+ now, the project gained it. This is verbatim the failure the check was added
274
+ for, live for one of the three change kinds, and committing your work is what
275
+ the dirty-tree refusal tells you to do. `force: true` still overrides.
276
+
277
+ - A route can no longer claim a safety profile it has no flags for by pinning
278
+ `effective_safety`. The pin returned before the flag check ever ran, so a
279
+ route declaring `effective_safety: read_only` while defining flags for only
280
+ `workspace_edit` launched its harness with no safety argument at all, reported
281
+ `read_only` everywhere including the audit log, and wrote a file into the
282
+ project under a read-only dispatch. The flag check now runs first — nothing
283
+ declared can conjure a flag that does not exist. A pin is still honoured for a
284
+ route that is not flag-controlled at all, which is the case it exists for. The
285
+ shipped harnesses are unaffected. Found by an acceptance pass, which also
286
+ found the test pinning the old behaviour describing a fixture it did not have.
287
+
288
+ - A `copy` workspace now SAYS which directories it left out. `bin`, `dist`,
289
+ `build`, `target`, `obj` and `.venv` are excluded by name as build output —
290
+ and are real source directories in some projects. The omission was invisible
291
+ on every surface: the agent reasoned from an incomplete tree, and a change to
292
+ an excluded file could not appear in the patch or the changed-file count.
293
+
294
+ - `workspace apply` no longer refuses on a clean tree when
295
+ `HARNESS_DISPATCH_WORKSPACES_DIR` points inside the project — the
296
+ configuration README recommends, for reflinks. Only the legacy hard-coded
297
+ `.harness-dispatch` name was filtered from the dirty check, so the configured
298
+ workspaces root read as your uncommitted work and blocked every apply.
299
+
300
+ - A failed config hot-reload now reaches `status`, under a `State problems`
301
+ heading, instead of only stderr — which no MCP client and no HTTP caller ever
302
+ sees. The comment at that code described the bug as "nothing on stderr and
303
+ nothing in status" while closing only the first half. Breaker warnings moved
304
+ to the same heading: they were filed under config warnings, which says "these
305
+ change behaviour" and means ignored config entries.
306
+
307
+ - `workspace` operations with `git` missing from PATH now say that, instead of
308
+ `spawn git ENOENT`.
309
+
310
+ - `safeEqual` in the HTTP auth path did its length-mismatch comparison against
311
+ the caller-supplied buffer, so the work scaled with a length an attacker
312
+ chose while the comment claimed the opposite. Not reproduced as an
313
+ exploitable signal, and the result was always correct.
314
+
315
+ - An endpoint that answers HTTP 200 without an answer is now a failure on both
316
+ request paths, not a successful empty answer. A success heals the circuit
317
+ breaker, so a route serving nothing but empty 200s was recorded as healthy
318
+ indefinitely and never tripped.
319
+
320
+ The streaming path — the only one `dispatch` and `job_status` ever take, so
321
+ the surface an orchestrating agent branches on — reported `success: true`
322
+ with an empty output for a body carrying no content at all. The buffered path
323
+ (what the CLI uses) refused that one, but accepted a well-formed response
324
+ whose `content` was the empty string, returning success with nothing in it.
325
+ Both now refuse both, which is what makes the two surfaces agree; an earlier
326
+ entry here claimed the buffered path already refused empty answers, and that
327
+ was wrong.
328
+
329
+ Both paths now answer the same two questions in the same order, in one
330
+ shared function so that the agreement is enforced rather than asserted: was
331
+ the body readable, and did any bytes arrive? A read that FAILED is reported
332
+ as a failed read — saying "no body" there would be a claim about what the
333
+ server sent, and a reset connection is not evidence of it. Otherwise the
334
+ body is quoted if one arrived, and reported as absent if not. The dispatcher
335
+ does not try to work out WHY a response was unusable.
336
+
337
+ Two earlier versions did try, and each was wrong in ways only an acceptance
338
+ pass found. The first called anything that produced no text "no content", so
339
+ an HTML error page, plain prose and a gateway that ignored `stream: true`
340
+ were all described as empty. The second tested whether the body looked like
341
+ SSE, and got three more cases backwards: a stream in a dialect the route was
342
+ not configured for had its real answer thrown away and called empty; SSE
343
+ comment keepalives — which a real provider sends while thinking — were
344
+ called an unexpected shape; and an HTML page containing any `data:` line was
345
+ called empty. Worse, the same body was described two different ways
346
+ depending on how the network happened to split it.
347
+
348
+ A well-formed but empty stream now quotes its own terminator instead of
349
+ being described in nicer words. The message says only that no answer came
350
+ out of the body and shows what did arrive; it does not call the shape
351
+ unexpected, because a stream that carried nothing had exactly the expected
352
+ shape.
353
+
354
+ Reproduced by acceptance passes.
355
+
356
+ - A corrupt circuit-breaker record is now reported as unknown state instead of
357
+ rendering as a healthy route. `status` said `breaker=closed failures=0` — an
358
+ assertion the process had no basis for — and a single bad file therefore
359
+ un-tripped a live cooldown in silence, which is the exact failure this
360
+ persistence layer was added to prevent.
361
+
362
+ Rather than enumerate the ways a record can be corrupt — a list acceptance
363
+ passes kept finding entries missing from — this checks the invariant the
364
+ module already holds: a healthy route has NO file, because a healthy save
365
+ deletes one. So a file that reads back fully healthy is a contradiction, and
366
+ that catches the `[]`, `{}` and foreign-schema records a list of type checks
367
+ had missed, without having to name them.
368
+
369
+ It does NOT catch everything, and an earlier version of this entry claimed
370
+ it did. A record carrying a *deadline* is not fully healthy, so the
371
+ invariant never looks at it — leaving a nonsense deadline to read as a
372
+ normal route. Deadlines are now checked against what the code can actually
373
+ produce: `snapshot()` emits `null` or `Date.now() + remaining`, capped at
374
+ `MAX_COOLDOWN_SEC`, so zero, negative and far-future values were never
375
+ written here. That last one matters most — an unchecked far-future deadline
376
+ would have blocked a route for years rather than merely un-blocking it.
377
+
378
+ What remains, stated rather than papered over: a corrupt deadline that
379
+ happens to look plausible is indistinguishable from a real cooldown that
380
+ has simply expired, because nothing rewrites the file until the route's next
381
+ event. Type checks remain for a wrong-typed field on a record that is
382
+ otherwise not-healthy. A field that is simply ABSENT is still tolerated,
383
+ because older builds wrote fewer of them.
384
+
385
+ The same validation runs on the pre-split `breaker_state.json` read during
386
+ an upgrade, which had none of it: a bad entry there was coerced to healthy,
387
+ skipped as nothing-to-migrate, and the file deleted — so upgrading, the
388
+ moment a live cooldown is most likely to be sitting on disk, destroyed it
389
+ silently. The per-route "healthy is a contradiction" rule cannot apply
390
+ there, because the old format wrote healthy entries legitimately, so the
391
+ shared validator gained a floor instead: a record naming none of the fields
392
+ it understands is not one it understands. Without that floor, `{}` and a
393
+ foreign schema — including the snake_case shape of the Python implementation
394
+ this was ported from — still passed and were still destroyed.
395
+
396
+ A blob is now rewritten with only the entries this could not consume, so
397
+ each entry is read exactly once and the file disappears on a clean upgrade.
398
+ Keeping the WHOLE blob whenever one entry was bad meant the good entries
399
+ were replayed on every read, forever — recreating a per-route record after
400
+ the route had recovered and its record was deleted, leaving it one failure
401
+ from tripping and unable to heal.
402
+
403
+ An unreadable record whose name is not a configured route (every corrupt
404
+ blob, since it has no route name) is reported under its own `status`
405
+ heading, separate from config warnings — nothing here was misconfigured or
406
+ ignored, and `doctor` and the CLI's "ignored config entries" list both read
407
+ the config warnings directly.
408
+
409
+ The lost count cannot be recovered and this does not guess at it — failing
410
+ closed would strand a route until someone deleted a file by hand. `status`
411
+ says the saved state was unreadable and may be stale, in the text output and
412
+ as `breaker.stateUnreadable` in the JSON. `doctor` does not report it.
413
+
414
+ - Workspace reclamation no longer deletes directories it did not create. The
415
+ sweep added earlier in this cycle judged a directory by age alone, so pointed
416
+ at a shared `HARNESS_DISPATCH_WORKSPACES_DIR` it would recursively delete
417
+ anything sitting there untouched for a day. That override is not an obscure
418
+ escape hatch — the README recommends it — and nothing said the directory
419
+ would become this tool's exclusively. An acceptance pass reproduced the loss
420
+ against the built artifact: two unrelated directories with real content
421
+ destroyed by a single dispatch. Default installs were never affected, since
422
+ the default base is dedicated.
423
+
424
+ A directory is now reclaimed only if it carries a marker file this tool
425
+ writes into every root it creates. The first attempt matched the generated
426
+ NAME shape instead, `-[0-9a-f]{8}$` — and eight decimal digits are valid hex,
427
+ so every `<name>-<YYYYMMDD>` still matched: a second acceptance pass planted
428
+ `backup-20260401` beside the directories that now survived and watched one
429
+ dispatch delete it recursively. A heuristic cannot answer "did I create
430
+ this". Roots made before the marker existed are still reclaimed, but only
431
+ when every child is a generated run directory and there is at least one, so
432
+ the earlier disk leak does not return through the back door.
433
+
434
+ The original test passed only because its fixture happened to be named
435
+ `gone-project-deadbeef`, matching that shape by accident. It now has two
436
+ companions that must survive: an ordinary directory, and one named to
437
+ collide with the shape check that failed.
438
+
439
+ - Endpoint redaction actually redacts. `redactEndpointHost` replaced the
440
+ hostname by assigning to `url.hostname`, and the WHATWG URL setter silently
441
+ rejects a value containing `<` and `>` — so it returned its input verbatim,
442
+ every time, while three call sites presented the result as scrubbed (one
443
+ commented "safe to paste into a bug report"). An acceptance pass measured a
444
+ failed dispatch reporting `https://api.secret-internal.example.com/v1?key=…`
445
+ into both the error and the dispatch log. Userinfo, query and fragment are
446
+ now dropped on every path including loopback, since a key embedded in a URL
447
+ is a credential wherever the host points. It shipped inert because nothing
448
+ tested it; it has tests now.
449
+
450
+ - `auth rotate` invalidates the old token for a running server. The token was
451
+ read once at startup and held, so rotation was a lie in both directions: the
452
+ old token kept returning 200 and the newly issued one was refused with 401 —
453
+ measured. Invalidating the old value is the only reason anyone rotates a
454
+ credential. The token file is re-read when its mtime moves, so the common
455
+ path stays a stat.
456
+
457
+ - `connect` will not replace a client entry you edited by hand without your
458
+ say-so. `connect --remove` always refused this; `connect` did not, so the
459
+ protection existed on the half where the cost is lower — and `OPERATIONS.md`
460
+ promised it for both. Running `connect` with no `--clients` shows the
461
+ difference and asks; `--force` overrides. Naming a client is not consent to
462
+ overwrite what is there.
463
+
464
+ - A job stranded in the slot queue by a server that exited is reported as
465
+ orphaned instead of reading `queued` forever. Slot-queued jobs are exempt
466
+ from orphan detection because nothing heartbeats for them, and the only
467
+ things that drained the queue were a runner exiting or a new dispatch
468
+ arriving. Reported, deliberately, rather than resumed: resuming was tried
469
+ first and an acceptance pass demonstrated the cost — kill a server with a job
470
+ queued, restart, and it runs to completion in its original working directory
471
+ at up to `full_auto`, unattended, bounded only by the 7-day retention window.
472
+ `retry_job` re-runs it as a decision.
473
+
474
+ Only when no supervisor is alive to run it. The first version of this reasoned
475
+ that a starting server means any queued job belongs to a dead session — false
476
+ in the configuration this ships by default, where `connect` registers Claude
477
+ Code and Cursor and `serve` is a third, all sharing one jobs root. Measured:
478
+ with one server live and holding a legitimately queued job, starting a second
479
+ marked it orphaned within a second and removed it from the drain queue,
480
+ killing live work with an error stating a cause that was not true.
481
+
482
+ - A streamed request stops rather than falling back once any answer text has
483
+ been sent. It fell back anyway and then discarded what the fallback produced:
484
+ an acceptance pass measured an endpoint streaming `he`, `llo `, dying, and the
485
+ fallback route succeeding with 49 characters the client never saw — produced,
486
+ charged for, thrown away. A fresh answer cannot be spliced onto a half-sent
487
+ one without garbling it, so the client now gets a truthful error and no second
488
+ route is billed. Falling back before anything is sent still happens, which is
489
+ the case fallback exists for.
490
+
491
+ - `git_worktree` no longer leaves a worktree registered in your repository when
492
+ an attempt failed without changing anything. Retention deliberately never
493
+ removes worktrees — unregistering one needs git, and only the owning repo can
494
+ do it — so they accumulated per attempt, and a failed FALLBACK arm is not
495
+ named in the response at all, so its worktree had no cleanup hint anywhere.
496
+ Measured: one HTTP request leaving two entries in `git worktree list`. A
497
+ failure that DID change files is still kept, because it may hold work worth
498
+ recovering.
499
+
500
+ - `connect --yes` no longer overwrites a hand-edited client entry. The consent
501
+ gate added for `--clients` treated "no client named" as consent, and `--yes`
502
+ takes that path — so the flag that skips the question was accepted as an
503
+ answer to it. Consent is answering the prompt, or `--force`.
504
+
505
+ - Streaming returns the ANSWER, not the harness's protocol. `POST
506
+ /v1/chat/completions` with `stream: true` forwarded every stdout chunk into
507
+ `delta.content`, so a client concatenating deltas from a CLI harness received
508
+ `{"type":"thread.started",...}` and internal thread ids — while the
509
+ non-streaming call on the same endpoint returned the parsed result. One
510
+ endpoint, two answers, and the streaming one was unusable by the clients the
511
+ OpenAI envelope exists for. An endpoint route still streams its text as it
512
+ arrives; a CLI harness sends its answer once, at completion.
513
+
514
+ Streaming still creates no job record, so there is no `jobId` and an
515
+ interrupted stream cannot be recovered. Now stated in the README and
516
+ OPERATIONS rather than left to be discovered.
517
+
518
+ - The HTTP surface refuses a bare `safety` key instead of dropping it. This
519
+ product's own CLI flag is `--safety`, so it is the most plausible slip anyone
520
+ will make — and at seven edits from `safetyProfile` the near-miss rule
521
+ correctly declines to guess, which left it accepted and silently ignored while
522
+ the dispatch ran at the default profile.
523
+
524
+ - `models: []` is refused instead of fanning out to every route. An explicit
525
+ empty array fell through to the same branch as omitting the field, so a
526
+ caller whose filter matched nothing got one dispatch per configured route —
527
+ eight arms where an acceptance pass measured it. Omitting `models` is still
528
+ how you ask for that, and it stays a deliberate keystroke.
529
+
530
+ - Job retention no longer deletes directories this tool never created. The
531
+ sweep removed every stale directory under the jobs root recursively, with no
532
+ check of any kind — the same defect workspace reclamation shipped twice in
533
+ this release, found by an acceptance pass in the one place nobody had looked.
534
+ Pointed at a directory holding `backup-20260401` and `my-notes`, it destroyed
535
+ both. Only `job-<timestamp>-<8 hex>` directories are eligible now. Narrower
536
+ in practice than the workspace case — neither `HARNESS_DISPATCH_JOBS_DIR` nor
537
+ `HARNESS_DISPATCH_STATE_DIR` is documented in the README, unlike the
538
+ workspaces override — but "narrower" is not a property anyone can rely on.
539
+
540
+ - `workingDir` must be an absolute path. A relative one was resolved against
541
+ the SERVER's working directory rather than the caller's — and `../..` exists,
542
+ so every check passed and a real dispatch ran somewhere neither party chose.
543
+ The omitted-value warning could not fire either, since the value was not
544
+ omitted. The caller and the server are different processes with different
545
+ working directories, so there is no correct relative value to accept.
546
+
547
+ - The HTTP surface rejects a top-level key that is nearly a hint name, instead
548
+ of accepting and dropping it. The outer body cannot be strict — it carries
549
+ OpenAI's own fields — so `safteyProfile` (a transposition) and hints wrapped
550
+ in `harness_dispatch` (the key this endpoint uses in its own *responses*, so
551
+ the natural wrong guess) both returned HTTP 200 and dispatched at the default
552
+ `workspace_edit`: more access than the caller asked for, with no signal,
553
+ while the correct spelling produced `read_only`. Now refused by name with the
554
+ intended spelling. One typo apart, transpositions included; unrelated keys
555
+ and every OpenAI field stay legitimate.
556
+
557
+ - A CLI route asked for a safety profile its protocol has no flags for is now
558
+ refused instead of run unconstrained. `{{safety}}` expands to the protocol's
559
+ arguments for the requested profile and to nothing when the profile is
560
+ missing, so a user-added route defining `workspace_edit` and `full_auto` but
561
+ not `read_only` launched the harness with NO safety arguments — and every
562
+ surface said `read_only`, including the dispatch log. An acceptance pass
563
+ measured the child's argv: just the prompt. A route that cannot show it
564
+ constrains anything is now treated as constraining nothing, which makes the
565
+ existing compatibility check refuse it. The shipped harnesses are unaffected;
566
+ they define all three profiles or pin the gaps with `effective_safety`.
567
+
568
+ - `status --json` and the `harness-dispatch://status.json` MCP resource no
569
+ longer emit a route's `base_url` verbatim. Credentials embedded in the URL —
570
+ `?key=…`, which is Google AI Studio's own shape — reached both, and that
571
+ resource is one this server's instructions tell agents to read, so the
572
+ credential landed in an agent's context. The text rendering was always
573
+ redacted, which is how it hid. `redactEndpointHost` is also idempotent now:
574
+ applying it twice used to be worse than once, degrading the model-discovery
575
+ hint to a bare placeholder.
576
+
577
+ - `overrides:` gets the same value and unknown-key checks as the route blocks.
578
+ It was left out when they were added, and it is the block most likely to carry
579
+ the fields they exist for — the shipped config presents `overrides:` as the
580
+ way to adjust `tier` and `weight` without writing a full config.
581
+
582
+ - A recognised config key carrying the wrong TYPE of value now says so instead
583
+ of silently taking the default. The unknown-key warning covered a misspelled
584
+ key; it never covered a correctly-spelled one whose value cannot be read,
585
+ because the key is not unknown — `coercions.ts` drops on mismatch and the
586
+ caller supplies a default. Found live on the maintainer's own machine by an
587
+ acceptance pass: four routes carrying `tier: metered`, which is not a number,
588
+ silently running at the default tier 3, with nothing ever having said so.
589
+ `weight: very-high` becomes 1.0 the same way, and both feed routing.
590
+
591
+ - A route name declared twice now warns that everything the earlier entry set is
592
+ discarded. Measured: a first entry setting `safety_profile: read_only` and
593
+ `workspace_policy: copy`, replaced wholesale by a second with neither, left
594
+ the surviving route running `workspace_edit` / `shared_locked` — silently
595
+ LESS restrictive than what was written, with no warning on any surface.
596
+
597
+ - `services:` written as a YAML list no longer fails silently. It must be a map
598
+ of route id to settings, but `typeof [] === "object"`, so a list slipped
599
+ through and became routes called `0`, `1`, … with each item's `name:`
600
+ ignored. Nothing looked wrong — `doctor` reported the routes and `status`
601
+ listed them — until `--service my_route` answered "Unknown service". The
602
+ mistake is a natural one: the sibling keys `clis:` and `endpoints:` ARE lists
603
+ whose items carry `name:`. The behaviour is unchanged; it now says what
604
+ happened, what the ids became, which names were dropped, and how to write it.
605
+
606
+ - `connect` keeps the last three backups of a client config rather than one per
607
+ run forever. Every write and every removal takes one, and `~/.claude.json`
608
+ holds live API keys, so the old behaviour accumulated copies of someone's
609
+ secrets with nothing to prune them. They are bounded rather than deleted by
610
+ `--remove`: undoing a registration is the worst moment to destroy the record
611
+ of what it replaced. Backups anyone else made are not touched.
612
+
613
+ - Retrying a job on a DIFFERENT route no longer carries the old route's model,
614
+ which defeated the one thing retargeting exists for. A model name belongs to
615
+ the route it was picked for, so reusing it verbatim made the retry fail for
616
+ the same reason as the original: observed end to end, a Cursor run that died
617
+ on `Cannot use this model` was retried onto Claude and died on
618
+ `unrecognized_model`, never reaching the task. That same job now completes.
619
+
620
+ Narrow, and reported rather than silent. The model is kept when the retry
621
+ stays on the original route (a plain "try that again") and when the new route
622
+ declares it anyway; only a model the destination does not know is left
623
+ behind, and the response says so as `droppedModel`.
624
+
625
+ - Workspaces belonging to projects that never dispatch again are reclaimed.
626
+ Retention only ever swept inside one project's own directory, and only when
627
+ that project dispatched again — so a project renamed, deleted, or created as
628
+ a throwaway temp directory kept its workspaces forever, because the code that
629
+ would reclaim them was reachable only from a project that no longer existed.
630
+ Measured on the maintainer's machine: 840 project directories, 839 of them
631
+ still holding runs five days past a 24-hour window. This project has already
632
+ lost a disk to leaked scratch directories once.
633
+
634
+ Conservative by construction: a project directory is removed only when every
635
+ run inside it is past retention, never the caller's own, and never one
636
+ holding a git worktree — those need git's own removal, which only the owning
637
+ repository can do.
638
+
639
+ - `taskType: "local"` now actually reaches a local endpoint. It could not: the
640
+ preference was a score bonus, a bonus only reorders routes within a tier, and
641
+ local endpoints sit in the cheap tier — so any healthy top-tier route won
642
+ before the bonus was ever consulted. Measured on a real config, every task
643
+ type including `local` resolved to the same top-tier CLI, and a configured
644
+ local box had zero calls in a month. It is a cross-tier selection rule now,
645
+ and the only one: tier gating still stops plan and review work drifting onto
646
+ a weaker route, because those task types are about capability and this one is
647
+ explicitly not.
648
+ - "Local" means one thing again, and it is what a route DECLARES — provider,
649
+ surface, auth source, billing kind — exactly as `routePolicy: "local_only"`
650
+ has always decided it. `taskType: "local"` used its own narrower test of a
651
+ loopback URL, so a real box on a LAN or tailnet address was local enough to
652
+ be the only thing `local_only` would run, and not local enough for the task
653
+ type named after it.
654
+
655
+ A URL shape no longer overrides a declaration. Briefly it did both, and that
656
+ was worse: a metered proxy on 127.0.0.1 — LiteLLM, OpenRouter, anything
657
+ fronting a paid API — declares itself metered, and the loopback check
658
+ overruled it, so the one task type meaning "free local endpoint" preferred
659
+ the PAID route over a free subscription CLI. Nothing was lost by removing it:
660
+ a local box that declares the fields is already covered, one declaring
661
+ nothing on a known runtime port is inferred local from the port, and one on
662
+ any other port never reaches candidacy at all.
663
+
664
+ ## [0.7.9] — 2026-08-28
665
+
666
+ **Upgrade from 0.7.8.** Fixes found by using the thing: two from the release
667
+ pass 0.7.8 shipped with as open, the rest from reading a month of real
668
+ dispatch logs.
669
+
670
+ ### Added
671
+
672
+ - `routing.candidates` — what the picked route beat, and by how much, best
673
+ first. `reason` counted the alternatives ("tier 1 best (3 available)") and
674
+ never named them, so the one question anyone has about an automatic choice
675
+ went unanswered. Measured over a month of real dispatch logs: 85% of live
676
+ dispatches named a route outright and the scoring ran on about one dispatch
677
+ in seven. An unauditable chooser does not get used.
678
+ - `--version` / `-v`. It exited 1 with "unknown option", which reads like the
679
+ binary is broken rather than like the flag is missing. Answered before config
680
+ loading, because a version is what you ask for when something is already
681
+ wrong.
682
+ - `doctor` reports whether `git` is on PATH, without failing over it — a
683
+ machine without git is supported, so it must not change the exit code.
684
+ Dispatch never needed it, but the
685
+ `workspace` tool shells out to git to diff and apply an isolated run's
686
+ changes — so without it a delegate's work COMPLETED and the tool that
687
+ retrieves it died with `spawn git ENOENT`, naming a program the README never
688
+ said you needed. Reported at setup, not a hard failure: the response carries
689
+ `workspaceRoot`, so the changes are recoverable by hand.
690
+
691
+ ### Fixed
692
+
693
+ - A harness that streams and then stops now says so, instead of handing back its
694
+ own output as the error. Found in a month of real dispatch logs: nine Codex
695
+ failures on one day recorded ~300 characters of raw JSONL — truncated
696
+ mid-sentence — as the caller's only explanation, after waits of 11 to 88
697
+ seconds. The message now names what happened (how many events streamed, the
698
+ last one, the exit code) and says plainly that the output is not an error
699
+ message and there was no result to return.
700
+
701
+ Deliberately NOT a new event rule for the nested error frame those streams
702
+ carry, which is the obvious-looking fix: a structured error overrides the
703
+ exit code, so the benign notice in that frame ("Codex can still see every
704
+ skill") would mark HEALTHY runs failed, charge the route and move the
705
+ breaker. A test pins that negative.
706
+ - The plugin manifest claimed version 0.4.0 while bundling the 0.7.x server.
707
+ Last touched at the rename and never bumped through three minor versions —
708
+ and unlike `package.json`, which the publish workflow rewrites at tag time,
709
+ nothing corrected it. Now pinned to `package.json` by a test, because
710
+ "remember to bump the other file" is what produced the drift.
711
+
9
712
  ## [0.7.8] — 2026-08-23
10
713
 
11
714
  **Upgrade from 0.7.7.** Two of 0.7.7's five fixes were wrong, and one of them
@@ -741,7 +1444,9 @@ the MCP surface to three tools: `dispatch`, `job_status`, `usage`.
741
1444
  Known issues in this release, fixed in 0.5.0: `configure` writes resolved API keys into
742
1445
  its output, and `configure --yes --force` can delete user-added harnesses.
743
1446
 
744
- [Unreleased]: https://github.com/fstubner/harness-dispatch/compare/v0.7.8...HEAD
1447
+ [Unreleased]: https://github.com/fstubner/harness-dispatch/compare/v0.8.0...HEAD
1448
+ [0.8.0]: https://github.com/fstubner/harness-dispatch/compare/v0.7.9...v0.8.0
1449
+ [0.7.9]: https://github.com/fstubner/harness-dispatch/compare/v0.7.8...v0.7.9
745
1450
  [0.7.8]: https://github.com/fstubner/harness-dispatch/compare/v0.7.7...v0.7.8
746
1451
  [0.7.7]: https://github.com/fstubner/harness-dispatch/compare/v0.7.6...v0.7.7
747
1452
  [0.7.6]: https://github.com/fstubner/harness-dispatch/compare/v0.7.5...v0.7.6