harness-dispatch 0.8.0 → 0.10.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 (174) hide show
  1. package/CHANGELOG.md +1074 -2
  2. package/README.md +92 -36
  3. package/config.default.yaml +19 -3
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js.map +1 -1
  6. package/dist/billing.d.ts +24 -0
  7. package/dist/billing.d.ts.map +1 -1
  8. package/dist/billing.js +24 -0
  9. package/dist/billing.js.map +1 -1
  10. package/dist/bin.d.ts.map +1 -1
  11. package/dist/bin.js +299 -48
  12. package/dist/bin.js.map +1 -1
  13. package/dist/circuit-breaker.d.ts +9 -1
  14. package/dist/circuit-breaker.d.ts.map +1 -1
  15. package/dist/circuit-breaker.js +15 -1
  16. package/dist/circuit-breaker.js.map +1 -1
  17. package/dist/client-register.d.ts +11 -1
  18. package/dist/client-register.d.ts.map +1 -1
  19. package/dist/client-register.js +15 -6
  20. package/dist/client-register.js.map +1 -1
  21. package/dist/config/coercions.d.ts +5 -0
  22. package/dist/config/coercions.d.ts.map +1 -1
  23. package/dist/config/coercions.js +8 -0
  24. package/dist/config/coercions.js.map +1 -1
  25. package/dist/config/protocol.js.map +1 -1
  26. package/dist/config/validation.d.ts +12 -0
  27. package/dist/config/validation.d.ts.map +1 -1
  28. package/dist/config/validation.js +133 -7
  29. package/dist/config/validation.js.map +1 -1
  30. package/dist/config.d.ts +15 -5
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/config.js +199 -152
  33. package/dist/config.js.map +1 -1
  34. package/dist/configure-yaml.d.ts +2 -0
  35. package/dist/configure-yaml.d.ts.map +1 -1
  36. package/dist/configure-yaml.js +114 -4
  37. package/dist/configure-yaml.js.map +1 -1
  38. package/dist/dispatch-log.d.ts.map +1 -1
  39. package/dist/dispatch-log.js +3 -1
  40. package/dist/dispatch-log.js.map +1 -1
  41. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  42. package/dist/dispatchers/generic-cli.js +281 -112
  43. package/dist/dispatchers/generic-cli.js.map +1 -1
  44. package/dist/dispatchers/openai-compatible.d.ts +0 -5
  45. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  46. package/dist/dispatchers/openai-compatible.js +150 -83
  47. package/dist/dispatchers/openai-compatible.js.map +1 -1
  48. package/dist/dispatchers/shared/harness-login.d.ts +25 -0
  49. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
  50. package/dist/dispatchers/shared/harness-login.js +73 -0
  51. package/dist/dispatchers/shared/harness-login.js.map +1 -0
  52. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  53. package/dist/dispatchers/shared/stream-subprocess.js +71 -58
  54. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  55. package/dist/file-lock.d.ts +29 -1
  56. package/dist/file-lock.d.ts.map +1 -1
  57. package/dist/file-lock.js +53 -20
  58. package/dist/file-lock.js.map +1 -1
  59. package/dist/harness-presets.d.ts +51 -0
  60. package/dist/harness-presets.d.ts.map +1 -0
  61. package/dist/harness-presets.js +154 -0
  62. package/dist/harness-presets.js.map +1 -0
  63. package/dist/http/parse.d.ts +8 -1
  64. package/dist/http/parse.d.ts.map +1 -1
  65. package/dist/http/parse.js +10 -3
  66. package/dist/http/parse.js.map +1 -1
  67. package/dist/http/server.d.ts +2 -0
  68. package/dist/http/server.d.ts.map +1 -1
  69. package/dist/http/server.js +188 -16
  70. package/dist/http/server.js.map +1 -1
  71. package/dist/job-runner.js +6 -0
  72. package/dist/job-runner.js.map +1 -1
  73. package/dist/jobs/context.d.ts +0 -7
  74. package/dist/jobs/context.d.ts.map +1 -1
  75. package/dist/jobs/context.js +95 -7
  76. package/dist/jobs/context.js.map +1 -1
  77. package/dist/jobs/lifecycle.d.ts +100 -0
  78. package/dist/jobs/lifecycle.d.ts.map +1 -0
  79. package/dist/jobs/lifecycle.js +263 -0
  80. package/dist/jobs/lifecycle.js.map +1 -0
  81. package/dist/jobs/read.d.ts +17 -0
  82. package/dist/jobs/read.d.ts.map +1 -0
  83. package/dist/jobs/read.js +103 -0
  84. package/dist/jobs/read.js.map +1 -0
  85. package/dist/jobs/run.d.ts +45 -0
  86. package/dist/jobs/run.d.ts.map +1 -0
  87. package/dist/jobs/run.js +351 -0
  88. package/dist/jobs/run.js.map +1 -0
  89. package/dist/jobs/start.d.ts +9 -0
  90. package/dist/jobs/start.d.ts.map +1 -0
  91. package/dist/jobs/start.js +126 -0
  92. package/dist/jobs/start.js.map +1 -0
  93. package/dist/jobs/store.d.ts.map +1 -1
  94. package/dist/jobs/store.js +13 -3
  95. package/dist/jobs/store.js.map +1 -1
  96. package/dist/jobs/supervisor.d.ts +151 -0
  97. package/dist/jobs/supervisor.d.ts.map +1 -0
  98. package/dist/jobs/supervisor.js +679 -0
  99. package/dist/jobs/supervisor.js.map +1 -0
  100. package/dist/jobs/types.d.ts +8 -1
  101. package/dist/jobs/types.d.ts.map +1 -1
  102. package/dist/jobs.d.ts +15 -218
  103. package/dist/jobs.d.ts.map +1 -1
  104. package/dist/jobs.js +14 -1317
  105. package/dist/jobs.js.map +1 -1
  106. package/dist/leaderboard.d.ts.map +1 -1
  107. package/dist/leaderboard.js +44 -28
  108. package/dist/leaderboard.js.map +1 -1
  109. package/dist/mcp/config-hot-reload.d.ts +14 -3
  110. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  111. package/dist/mcp/config-hot-reload.js +27 -6
  112. package/dist/mcp/config-hot-reload.js.map +1 -1
  113. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  114. package/dist/mcp/dispatcher-factory.js +15 -4
  115. package/dist/mcp/dispatcher-factory.js.map +1 -1
  116. package/dist/mcp/resources.d.ts.map +1 -1
  117. package/dist/mcp/resources.js +3 -2
  118. package/dist/mcp/resources.js.map +1 -1
  119. package/dist/mcp/tool-schemas.d.ts +1 -1
  120. package/dist/mcp/tool-schemas.d.ts.map +1 -1
  121. package/dist/mcp/tool-schemas.js +6 -3
  122. package/dist/mcp/tool-schemas.js.map +1 -1
  123. package/dist/mcp/tools.d.ts +20 -6
  124. package/dist/mcp/tools.d.ts.map +1 -1
  125. package/dist/mcp/tools.js +92 -14
  126. package/dist/mcp/tools.js.map +1 -1
  127. package/dist/mcp-clients.d.ts +9 -0
  128. package/dist/mcp-clients.d.ts.map +1 -1
  129. package/dist/mcp-clients.js +4 -0
  130. package/dist/mcp-clients.js.map +1 -1
  131. package/dist/observability/spans.d.ts.map +1 -1
  132. package/dist/observability/spans.js +10 -2
  133. package/dist/observability/spans.js.map +1 -1
  134. package/dist/quota.d.ts.map +1 -1
  135. package/dist/quota.js +16 -2
  136. package/dist/quota.js.map +1 -1
  137. package/dist/redaction.d.ts +90 -0
  138. package/dist/redaction.d.ts.map +1 -0
  139. package/dist/redaction.js +221 -0
  140. package/dist/redaction.js.map +1 -0
  141. package/dist/route-policy.d.ts +2 -1
  142. package/dist/route-policy.d.ts.map +1 -1
  143. package/dist/route-policy.js +54 -0
  144. package/dist/route-policy.js.map +1 -1
  145. package/dist/router.d.ts +12 -23
  146. package/dist/router.d.ts.map +1 -1
  147. package/dist/router.js +86 -223
  148. package/dist/router.js.map +1 -1
  149. package/dist/safety.d.ts.map +1 -1
  150. package/dist/safety.js +7 -1
  151. package/dist/safety.js.map +1 -1
  152. package/dist/state-dir.d.ts +10 -0
  153. package/dist/state-dir.d.ts.map +1 -1
  154. package/dist/state-dir.js +27 -1
  155. package/dist/state-dir.js.map +1 -1
  156. package/dist/status.d.ts +14 -3
  157. package/dist/status.d.ts.map +1 -1
  158. package/dist/status.js +36 -3
  159. package/dist/status.js.map +1 -1
  160. package/dist/types.d.ts +24 -1
  161. package/dist/types.d.ts.map +1 -1
  162. package/dist/workspace-lock.d.ts +20 -0
  163. package/dist/workspace-lock.d.ts.map +1 -1
  164. package/dist/workspace-lock.js +63 -0
  165. package/dist/workspace-lock.js.map +1 -1
  166. package/dist/workspace-resolve.d.ts +0 -9
  167. package/dist/workspace-resolve.d.ts.map +1 -1
  168. package/dist/workspace-resolve.js +115 -183
  169. package/dist/workspace-resolve.js.map +1 -1
  170. package/dist/workspaces.d.ts +15 -0
  171. package/dist/workspaces.d.ts.map +1 -1
  172. package/dist/workspaces.js +330 -90
  173. package/dist/workspaces.js.map +1 -1
  174. package/package.json +5 -4
package/CHANGELOG.md CHANGED
@@ -4,7 +4,1078 @@ Notable changes per release. Format follows [Keep a Changelog](https://keepachan
4
4
  this project uses [semantic versioning](https://semver.org/spec/v2.0.0.html) and is
5
5
  pre-1.0, so minor versions can carry behaviour changes.
6
6
 
7
- ## [Unreleased]
7
+ ## [0.10.0] — 2026-09-06
8
+
9
+ ### Added
10
+
11
+ - **`doctor` says whether jobs will actually run detached.** A missing
12
+ `dist/job-runner.js` is not an error — it is the signal to run jobs
13
+ in-process, which is right for an unbuilt checkout and wrong everywhere
14
+ else, because the concurrency cap is enforced by the supervisor pool. So the
15
+ bound that exists because of a measured out-of-memory crash silently stopped
16
+ applying, with one line on stderr at dispatch time and nothing checking it.
17
+ This check FAILS rather than warns, so `doctor` now exits 1 in an unbuilt
18
+ checkout where it previously exited 0.
19
+
20
+ - **Locks left by a process that is gone are swept before each job starts.**
21
+ Such a lock was always reclaimable, but only when something contended for
22
+ that same directory — and nothing contends for a path you dispatched against
23
+ once and moved on from, so its file stayed indefinitely. An unreadable lock
24
+ is given the same grace the acquire path gives it, rather than being deleted
25
+ on sight: `wx` creation leaves a real empty-file window, and losing that race
26
+ would delete a live holder's lock.
27
+
28
+ - **Empty supervisor spawn logs are cleaned up.** Keeping a crash log is
29
+ deliberate — a supervisor that died is the one that left a stale heartbeat,
30
+ and its output is the only explanation. An empty one explains nothing, and
31
+ the liveness check reads that directory on every drain. Measured before this:
32
+ 129 files over three weeks, six bytes each.
33
+
34
+ ### Fixed
35
+
36
+ - **`usage` with no routes explained itself.** It printed a bare header, which
37
+ reads as a broken command — the one thing it does not mean. It now says
38
+ there are no routes and names both ways to get one.
39
+
40
+ - **`--json` is honoured when a command fails, not only when it succeeds.** A
41
+ bad `--config` made `doctor --json` print a sentence, so anything parsing the
42
+ output got a parse error instead of the reason. Four shapes bypassed the
43
+ envelope entirely — an unknown command, `auth` with no subcommand, `dispatch`
44
+ with no prompt, and the `--json=true` spelling — and the first printed the
45
+ help block to stdout, so `harness-dispatch frobnicate --json | jq` was fed
46
+ usage text. Errors stay on stderr; without `--json` the plain line is
47
+ unchanged.
48
+
49
+ - **README:** the quick start told a new user to run `doctor --live` as step
50
+ three of installing without saying it contacts providers and spends quota.
51
+ The `configure` section was one 180-word block covering four separate
52
+ concerns; it is split under headings, and `configure` is now described as
53
+ optional where someone deciding whether to run it will see it.
54
+
55
+ ### Fixed
56
+
57
+ - The "already applied" answer is given from one place. Both branches that
58
+ can reach it built the message separately and had to say the same thing by
59
+ hand — and it is the message a caller reads to decide whether their work
60
+ landed, so the two drifting apart is the exact confusion the second branch
61
+ was added to fix.
62
+
63
+ - Five deeply nested blocks were flattened into named helpers: a rule
64
+ dispatch table written as a switch inside a loop, the stdout line
65
+ reassembly, two JSON-shape readers, an SSE frame reader, and the
66
+ steal-a-stale-lock path with its two nested try/catch blocks. Each is
67
+ behaviour-preserving and each removes a level that had nothing to do with
68
+ the logic underneath it.
69
+
70
+ - Housekeeping the audit listed and nothing had got to: CI built the whole
71
+ project twice on every leg of every run (the explicit step plus the one
72
+ inside `npm test`); `doctor` ran its state-directory probe twice, creating
73
+ and deleting a file each time, to fill two fields from the same answer; an
74
+ alias renamed a function to itself; and 28 blank lines left behind by
75
+ earlier extractions are gone.
76
+
77
+ - Four assertions that could not fail for the reason their test claimed.
78
+ `job_status` on an unknown job asserted only that *something* threw, which
79
+ a schema rejection also satisfies — so "a well-formed id for a job that is
80
+ gone" was untested. The traversal and near-miss-action refusals had the
81
+ same hole: both passed on "No such job", which every id in that file
82
+ produces, leaving the thing under test unchecked. And a parser test
83
+ asserted only that a normal OpenAI body did not throw, which is equally
84
+ true of a parser that dropped the request on the floor; it now checks the
85
+ request survived.
86
+
87
+ - The endpoint dispatcher's two request paths share one prologue. The URL,
88
+ headers, timeout timer, abort wiring and fetch-error mapping were written
89
+ out twice and kept in step by hand — which is not a theoretical risk here:
90
+ the credential leak fixed in the entry above existed in both copies, and
91
+ the file's own header records the same shape happening before. What stays
92
+ a parameter is the one thing that genuinely differs, the `stream` flag and
93
+ its Accept header, now pinned by tests asserting what each path puts on
94
+ the wire. Collapsing that too is what "just drain the stream" would do,
95
+ and it would change every buffered endpoint request against a third-party
96
+ gateway.
97
+
98
+ - **SECURITY, structural: configured credentials are removed where text leaves
99
+ the process, instead of at each place a message is built.** Six consecutive
100
+ reviews found the same defect — a credential reaching a caller, a log file or
101
+ an agent's context — and six fixes closed the path that reviewer had found.
102
+ Each fix was correct and each was incomplete: four of the six were "the fix
103
+ landed in one branch and the sibling beside it kept leaking", twice inside a
104
+ single file, and one round shipped a comment asserting every branch was
105
+ covered while one was not.
106
+
107
+ Sites were the wrong unit. There is no bounded list of places a string can be
108
+ built, and ordinary feature work adds more without their author having any
109
+ reason to think about credentials. The set of ways text LEAVES is bounded:
110
+ JSON for a tool result or an HTTP response, the dispatch log, job files on
111
+ disk, and the terminal. Those are now the places redaction happens, and the
112
+ secrets are derived from the loaded config rather than named per call site —
113
+ including the `api_keys:` block, whose entries are keyed by route name and so
114
+ matched no credential-looking key name, and a credential embedded in a
115
+ `base_url` path, which host redaction preserves by design.
116
+
117
+ Also fixed with it: `configure --print` emitted `base_url` in full while
118
+ redacting the api_key two lines below and printing a note implying the whole
119
+ preview was sanitised.
120
+
121
+ The guarantee is pinned two ways, because one of them does not reach far
122
+ enough and saying otherwise was itself an overclaim. Four sinks are proven
123
+ behaviourally: a credential of every shape a config can hold is planted, the
124
+ sink is driven for real, and removing its redaction fails that sink's case
125
+ and no other. The remaining eight have no reachable input that carries a
126
+ credential today — something upstream already removed it — so a behavioural
127
+ case for them could not fail, and inventing one would be a test passing for
128
+ the wrong reason. They are covered instead by an inventory that fails if the
129
+ redaction call disappears, which is the real risk for a guard.
130
+
131
+ An earlier version of this entry claimed all of them were proven
132
+ behaviourally. A verification pass sabotaged all nine call sites one at a
133
+ time and found five shipped green.
134
+
135
+ The first version of that test did not do this. It called the redactor on the
136
+ rendered output before asserting, so it proved the redactor works on a string
137
+ — never in doubt — and could not fail for a sink that forgot to call it. An
138
+ acceptance pass established that by reading, and found an unredacted
139
+ server-sent-event path shipping under a green suite in the same breath. The
140
+ streamed and buffered forms of one request behaved differently: the same
141
+ credential was removed from the buffered response and returned verbatim in
142
+ the stream.
143
+
144
+ - **The redaction registry no longer treats every `${VAR}` as a credential.**
145
+ It was keyed off the record of resolved environment references, which covers
146
+ every `${VAR}` in the file — and `${VAR}` is documented as legal in any
147
+ string value. So `model: ${MY_MODEL}` made that model name a process-wide
148
+ redaction target, and `status` reported `model=<redacted>`. A harness answer
149
+ mentioning it would have been mangled the same way, silently, which is worse
150
+ than the disclosure being guarded against: wrong work product delivered as if
151
+ it were right. Collection now reads the fields that hold credentials.
152
+
153
+ For the same reason, a long segment in a `base_url` path is no longer assumed
154
+ to be a secret — that guess made an Azure deployment name
155
+ (`.../deployments/gpt-4-turbo-preview`) a redaction target. A path segment
156
+ cannot be told from a credential by inspection, so the config now says so and
157
+ names the route, and the user moves it to `api_key:` where it is removable by
158
+ value.
159
+
160
+ - **SECURITY: a CLI harness quoting its own api key had it passed through to
161
+ the caller and into `logs/dispatches.jsonl`.** A CLI route is handed its
162
+ credential in an environment variable, and a harness reporting an auth
163
+ failure can print it back; nothing removed it. Reproduced end to end with a
164
+ stub harness writing `auth error: rejected key <key>` to stderr — it arrived
165
+ verbatim in the terminal and in the log.
166
+
167
+ Reaching it needs `api_key:` on a CLI route, which reclassifies the route as
168
+ metered and is then blocked by billing policy until the user explicitly opts
169
+ in — so it is narrower than the endpoint leaks beside it, and it is the same
170
+ disclosure once opted in.
171
+
172
+ The scrub wraps the dispatcher's whole event stream rather than each result
173
+ site. That is the point of it: this class has now been found five times, and
174
+ four were a fix landing in one branch while the sibling beside it kept
175
+ leaking. `#runStream` has five result sites plus the chunk events, and a
176
+ sixth added later would silently miss a per-site scrub. Chunks are scrubbed
177
+ too, since they become `partialOutput` and `stdout.log` on disk. A route with
178
+ no key — every subscription CLI — is untouched and pays nothing.
179
+
180
+ - **SECURITY: an endpoint that echoed your API key back in its error body
181
+ had it passed straight through to the caller and into
182
+ `logs/dispatches.jsonl`.** Both request paths build `HTTP <status>:
183
+ <message>` from the response body and neither scrubbed it, while the
184
+ network-error paths beside them always had. So a key in the base URL's
185
+ query string — a documented way to configure an endpoint, and how Gemini's
186
+ is written — came back verbatim whenever the endpoint quoted the request
187
+ URL in its own 4xx body. Found by writing the test the audit said was
188
+ missing: scrubbing had been proven at the unit and never once from the
189
+ dispatcher, which is the only place it can actually leak.
190
+
191
+ It took three attempts to close, and the first two both shipped a claim
192
+ wider than their code. The `HTTP <status>` pair was fixed first and
193
+ declared the class closed; the two `describeUnusableBody` paths beside it
194
+ — a 200 carrying an HTML error page, which is what a gateway returns —
195
+ still quoted 300 raw characters of the body back. Fixing those came with
196
+ an assertion that every branch was covered; the mid-stream SSE `error`
197
+ event was not, and that is the likeliest shape of all of them, because a
198
+ 200 with `text/event-stream` carrying an error object is how these
199
+ endpoints report an auth failure once the stream is open, and jobs only
200
+ ever stream.
201
+
202
+ Separately, the scrubber only ever removed credential-bearing parts of the
203
+ base URL. Every endpoint in this project's own config authenticates with an
204
+ `Authorization` or `x-api-key` header, so the configured key was not in the
205
+ URL at all and survived on every path, including the two fixed first.
206
+
207
+ All seven branches that can return an endpoint's own text now scrub, each
208
+ is pinned by its own test, and the code comment enumerates them rather than
209
+ asserting completeness — an enumeration can be checked against the file and
210
+ a claim of completeness is what failed twice here.
211
+
212
+ - **The CLI dispatcher is now tested against a real process, not only
213
+ through a mock — and production no longer contains the mock detector.**
214
+ `stream-subprocess.ts` carried a branch in PRODUCTION that detected a
215
+ vitest mock on `runSubprocess` and synthesised one stdout chunk from a
216
+ buffered result, under a comment claiming this still exercised the
217
+ streaming path. It exercised it nowhere: every dispatcher suite mocked
218
+ `runSubprocess`, so every dispatcher suite ran the adapter. Four behaviours
219
+ had no test at all: a JSONL event split across two reads, a timeout that
220
+ really kills the child, an abort reaching a running process, and stdout
221
+ arriving as events during the run rather than one lump at the end. All four
222
+ now run against `node -e`.
223
+
224
+ The adapter has since moved into the test suite, where a suite that wants
225
+ it opts in by mocking the function the dispatchers actually call. Nothing
226
+ in `src/` now branches on being under test.
227
+
228
+ - **`doctor` names a route that has never once succeeded.** The circuit
229
+ breaker is about RECENT failure and forgets after its cooldown, so a route
230
+ that is simply dead — a host that no longer resolves, a revoked key — keeps
231
+ being selected, keeps failing, and keeps falling back, indefinitely. On the
232
+ maintainer's own machine a local endpoint sat at 8 calls and 0 successes
233
+ while being the preferred route for `review`, so every review dispatch paid
234
+ for one doomed attempt first. The counts were in `usage` all along, and
235
+ nobody reads `usage` when things merely feel slow. Advisory, never a
236
+ failure: a route needs at least five calls before it is judged, because a
237
+ laptop that was asleep is not a broken endpoint.
238
+
239
+ - Three of the six MCP tools were asserted to exist and never invoked.
240
+ `cancel_job`, `retry_job` and `workspace` had tests for the functions
241
+ underneath but nothing exercising the layer that parses a caller's
242
+ arguments and shapes the reply — where a schema change or a renamed field
243
+ breaks an orchestrator without failing anything. Now driven through the
244
+ real tool path, including the refusals that matter: a traversal attempt in
245
+ a job id, and a near-miss `action` on the one verb that is irreversible.
246
+
247
+ - The workspace lock's heartbeat guard is pinned. A holder that freezes past
248
+ the stale window is legitimately stolen from, and its heartbeat must stop
249
+ writing — otherwise it destroys the new owner's claim and, worse, convinces
250
+ its own `release()` that it still holds the lock, deleting it out from
251
+ under whoever now owns it. The guard was there and uncovered; removing it
252
+ now fails a test that names exactly that.
253
+
254
+ - **`max_concurrent_runs: 0` lifts the cap without also removing the
255
+ supervisor pool.** It used to short-circuit the slot queue and the pool
256
+ entirely, so every job got its own detached runner — measured under load at
257
+ 8 concurrent dispatches becoming 8 runner processes at ~76 MB each, which
258
+ is the per-job wrapper cost the pool exists to remove, on the memory-bound
259
+ machine the cap exists for in the first place. Uncapped now means uncapped
260
+ *jobs*, with the process count still bounded by the pool. The cap is
261
+ represented as `null` rather than `0` or `Infinity`, because both of those
262
+ were wrong somewhere: `0` read as "bypass everything", and `Infinity` broke
263
+ pool sizing, which divides by the limit and so asked for zero supervisors.
264
+
265
+ The README and the source comment went on describing the old behaviour for
266
+ a further nine commits, steering users away from the setting for a reason
267
+ that had stopped being true. Both now say what the code does.
268
+
269
+ - The contract every shipped harness owes — a missing CLI, a non-zero exit, a
270
+ model override, a timeout, id and availability — is asserted once per
271
+ harness from one table instead of being written out in four per-harness
272
+ files. There is no per-harness dispatcher class to begin with: each built-in
273
+ harness is the generic CLI dispatcher driven by a protocol block in
274
+ `config.default.yaml`, so those were never really four different questions.
275
+ They had already drifted apart, which is the argument for the table: three
276
+ of the four checked that nothing was even resolved before the failure, one
277
+ did not. A preset added to the shipped config with no row now fails a
278
+ completeness check. What stays per harness is what genuinely differs —
279
+ Codex's JSONL extraction and `--cd` handling, Cursor's workspace default and
280
+ Retry-After parsing, Antigravity's live streaming and safety-flag mapping,
281
+ Claude Code's raw-stdout fallback.
282
+
283
+ - **The job store is five modules instead of one 1,600-line file**, and two
284
+ runtime imports that existed to dodge an import cycle are gone. The cycle
285
+ was real but avoidable: `config-hot-reload.ts` imported
286
+ `setJobRetentionDays` from the `jobs.ts` barrel rather than from
287
+ `jobs/store.ts`, where it is defined. Pointing it at the definition removed
288
+ the cycle, and the second dynamic import was guarding a cycle that never
289
+ existed. `jobs.ts` is now a 32-line barrel, so every consumer keeps its
290
+ import path.
291
+
292
+ The move surfaced a defect worth naming: `resolveRunnerPath()` resolves
293
+ against its own module's location, so moving it one directory deeper made
294
+ both its candidates miss. Returning nothing is not an error there — it is
295
+ the signal to run the job **in-process**, so every dispatch quietly stopped
296
+ being detached and the concurrency cap stopped applying, with nothing
297
+ failing until the job-concurrency tests ran. Fixed, with the trap written
298
+ down at the function.
299
+
300
+ - **The tests are typechecked, and the gate enforces it.** Clearing the 125
301
+ errors that surfaced turned up two things a reader would have taken at face
302
+ value. `RetryOutcome` never declared `droppedModel` — a field `retry_job`
303
+ sets and the MCP tool description documents, invisible to any typed caller
304
+ because the object is built with a conditional spread, which TypeScript
305
+ does not excess-property check. And five router fixtures scored a route
306
+ whose billing surface was `"vendor_cli"`, a value in no union and no
307
+ config: they were measuring a shape that cannot occur. A test helper also
308
+ returned `Record<string, never>`, which made every assertion against a
309
+ config file it had just written unable to fail.
310
+
311
+ - **The test suite's `implements Dispatcher` claims are true now, and there
312
+ is a way to check them.** `tsconfig.json` sets `rootDir: src` so it can
313
+ emit, which means a test file cannot be in the same program — so the tests
314
+ were never typechecked, and four stub classes declared they implemented an
315
+ interface while omitting a required method. That is not academic: it has
316
+ now cost two runtime failures that a compiler would have caught instantly,
317
+ one of them a stub that would have crashed the moment the router stopped
318
+ preferring the buffered path. The stubs implement it, with a body that
319
+ throws — stronger than absence, because a buffered entry point that
320
+ accidentally streams now fails loudly instead of passing quietly. A new
321
+ `tsconfig.test.json` typechecks tests against src; `npm run typecheck:tests`
322
+ runs it. It reported 52 remaining pre-existing errors when it landed and was
323
+ deliberately kept out of `npm run check` until they were cleared — which
324
+ happened later in this same release, so as shipped the gate runs it and it
325
+ is clean. An acceptance pass caught this paragraph still describing the
326
+ interim state, two entries above one saying the opposite.
327
+
328
+ - **"A shipped harness preset" now has a module.** It was a protocol block
329
+ in YAML, loaded and parsed inside `config.ts`, interpreted by the generic
330
+ CLI dispatcher, read again by the dispatcher factory, and asserted by four
331
+ per-harness test files — so every change to a built-in harness touched all
332
+ of them. A co-change check over 300 commits reported exactly that: ten
333
+ files moving together across six directories, one concept with nowhere to
334
+ live. `src/harness-presets.ts` is where it lives now, and every importer
335
+ names it. `config.ts` is 184 lines smaller and no longer parses the
336
+ shipped file it also consumes.
337
+
338
+ - **The leaderboard fetch stopped introducing itself as version 0.4.** Its
339
+ `User-Agent` was a hardcoded string, still saying 0.4 at 0.9 — so the one
340
+ host this tool ever contacts had been told a version that was wrong for
341
+ five releases. It is built from `VERSION` now. The reason it survived is
342
+ worth recording: a test asserted the literal `"harness-dispatch/0.4 …"`,
343
+ so the suite was holding the stale claim in place rather than catching it.
344
+ That assertion now compares against the real version.
345
+
346
+ - A note returned to the CALLER carried a git commit hash
347
+ (`… this was per-process until 304a1b5`), which means nothing to anyone
348
+ reading a dispatch response. Removed, along with a second hash in a
349
+ comment and a reference to a line number in a file the symbol had since
350
+ moved out of.
351
+
352
+ - The HTTP surface's context-file limit is imported from the MCP surface
353
+ rather than copied. It was a second `= 64` under a comment reading
354
+ "mirrors MAX_CONTEXT_FILES in mcp/tools.ts — the two surfaces must
355
+ agree": the constant does not live in that file, and a comment cannot
356
+ make two numbers agree. The module was already importing its sibling
357
+ constant from the right place.
358
+
359
+ - `explicitOptsFromHints` is gone. It was exported for nobody: it existed
360
+ because the HTTP fanout path built dispatch options by naming fields
361
+ inline and dropped two, and that path now enforces route policy earlier
362
+ and passes hints wholesale — which is what the deleted function's own
363
+ docblock argued for.
364
+
365
+ - **The landing page no longer describes a different product.** It listed
366
+ three MCP tools where six are registered and one resource where there are
367
+ two; its setup block ran `configure` without `--yes`, which writes
368
+ nothing, and then showed `doctor --live` output using three strings the
369
+ tool has never printed; its MCP snippet omitted `--config`, the form the
370
+ tool itself warns silently falls back to shipped defaults and ignores your
371
+ config file; it claimed "the one outbound call is a public leaderboard
372
+ GET" when that fetch is opt-in and off by default; and its worked example
373
+ showed a job id the API cannot produce. The sample output is now copied
374
+ from a real run.
375
+
376
+ - `package.json`'s `homepage` pointed at a GitHub Pages URL that returns
377
+ 404 — the deploy workflow is manual-only and has never been run — so the
378
+ link npm shows on the package page was broken. It now points at the
379
+ README. The site's own spec records that the page is not live and what to
380
+ do about it.
381
+
382
+ - **An abandoned job no longer blames the wrong process.** Its error read
383
+ "The dispatch server that started this job exited before the run finished",
384
+ and a load test produced that message verbatim while the server was up and
385
+ still serving requests — one supervisor had been killed. The status file
386
+ this is derived from records a heartbeat and nothing about who was holding
387
+ it, so naming a culprit was always a guess, and it sent anyone debugging to
388
+ the wrong process. It now says what is known: the job stopped reporting,
389
+ the process running it is gone, the partial output is on disk, and
390
+ `retry_job` re-runs the same task.
391
+
392
+ - `max_concurrent_runs: 0` is documented as what it is. README said it
393
+ "removes the bound"; measured under load, it also takes the supervisor
394
+ pool out of the path, so every job gets its own runner process at ~76 MB —
395
+ the per-job cost the pool exists to remove, on the memory-bound machine
396
+ the cap exists for. Routing the unbounded case through the pool would be
397
+ better and is not done here: the pool sizes itself by dividing the limit,
398
+ so an infinite one provisions zero supervisors, and getting that right
399
+ needs its own load run rather than a guess.
400
+
401
+ - **The shipped harness defaults are read through the same field table as
402
+ everything else**, closing a silent drop that was already live. Route
403
+ settings resolve through one shared table so that a key works on `clis:`,
404
+ `endpoints:` and the legacy `services:` at once — but the parser for the
405
+ shipped defaults still hand-wrote its own list, and had drifted:
406
+ `thinking_level` on a built-in harness entry was parsed by nobody, while
407
+ the table reads that very field as the fallback for every user route of
408
+ that harness. Exactly the silent-drop shape the table exists to retire,
409
+ one layer underneath it. A row added to the table now reaches the defaults
410
+ layer too, instead of needing to be remembered in a fourth place.
411
+
412
+ - **The two workspace policies no longer keep separate copies of the guards
413
+ that protect a recursive delete**, and those guards are now tested on
414
+ Windows, where the attack they exist for was originally measured. `copy`
415
+ and `git_worktree` each had their own prune function and their own
416
+ create-and-verify sequence, ~55 and ~30 lines, identical but for a git
417
+ root. That duplication is why the symlink class took four releases to
418
+ close: each fix landed in one copy, shipped as done, and the next review
419
+ found the other still exploitable. The comment left behind said "whenever
420
+ one of these gets a guard, check the other in the same edit" — a process
421
+ rule standing in for a shared function, which now exists.
422
+
423
+ The tests that cover those guards were skipped on Windows because an
424
+ unprivileged `symlink(..., "dir")` fails there. A junction does not, and
425
+ `lstat` reports one as a symbolic link, so the code under test sees what
426
+ it sees on POSIX. Two of the six skips are gone.
427
+
428
+ Three deliberate breakages that the suite previously did NOT catch — the
429
+ sweep's name check removed, the verify-before-prune ordering reversed, and
430
+ the symlink refusal removed — now each fail their own test. The gap was
431
+ that the destructive path only runs past the 24-hour retention window and
432
+ no test aged anything, so the delete never happened and the guards were
433
+ never asked. Two new tests age a directory on purpose.
434
+
435
+ - **A patch-building path no input could reach has been removed, along with
436
+ the ~800-line test suite guarding it.** When a `copy` job had no recorded
437
+ changed-file list, the patch was built by diffing the whole original
438
+ directory against the whole workspace — a comparison that reports every
439
+ excluded directory as deleted, so ~170 lines of path normalisation and
440
+ section filtering existed to make its output safe to apply. The field it
441
+ falls back from has been recorded since 2026-07-13 and an isolated
442
+ workspace is pruned after a day, so no run reaching this code could still
443
+ exist; its own comment still described the copy as living inside the
444
+ project, which stopped being true in 0.7.0. Such a job is now refused with
445
+ the path to its workspace, matching what the `git_worktree` branch beside
446
+ it already did. Two tests that had been passing only because their fixture
447
+ omitted the field now run against the current path, and one of them turns
448
+ out to pin something real: discard refuses to destroy unapplied work, a
449
+ guard the old fixture never triggered.
450
+
451
+ - **Dead imports and locals can no longer accumulate unnoticed.** The
452
+ compiler was not asked about them, and 38 had built up across twelve
453
+ files: three whole import declarations nothing read, a function nobody
454
+ called, a parameter threaded through a signature after the code that used
455
+ it was fixed, and two catch blocks computing a message they discarded.
456
+ Individually harmless; in bulk they are what makes a reader unable to tell
457
+ what is load-bearing. All 38 removed, and `noUnusedLocals` /
458
+ `noUnusedParameters` are now on, so the next one fails the build rather
459
+ than joining the pile.
460
+
461
+ - **The router had two implementations of every dispatch path, and its own
462
+ header said otherwise.** `route()`/`routeTo()` and `stream()`/`streamTo()`
463
+ were separate selection-and-fallback loops, with a second copy of the
464
+ workspace-policy wrapper beside them — about 250 lines that had to stay in
465
+ step and did not. The file's header claimed the buffered methods were
466
+ "reimplemented on top of the streaming primitives", which is what made the
467
+ drift invisible: the timeout rule ended up written four times, and only the
468
+ `route()` copy was covered by a test, while background jobs and every HTTP
469
+ request run on the streaming pair. The buffered methods now drain the same
470
+ loop. One thing is still chosen per entry point, and only one: whether the
471
+ dispatcher is asked through `dispatch()` or `stream()` — an endpoint route
472
+ keeps its non-streaming request, which sets `stream: false` on the wire, so
473
+ no live traffic changes shape. Behaviour is otherwise unchanged; 123 lines
474
+ are gone.
475
+
476
+ - **An empty `HARNESS_DISPATCH_STATE_DIR` sent every state path to the
477
+ current directory.** The lookup used `??`, which treats an empty string as
478
+ a real value — and an empty value is what a launcher or shell produces
479
+ when it forwards a variable that is not set. The state root became `""`,
480
+ so config, jobs, breaker state, quota counters and logs all resolved
481
+ against wherever the process happened to start: the cwd-dependent bug this
482
+ module's own comment says was fixed. A relative value is now anchored too,
483
+ so a job runner spawned with a different working directory cannot read a
484
+ different state root than the server that spawned it.
485
+
486
+ - **`doctor` no longer reports a Codex that is installed but not logged in
487
+ as logged in** when its CLI prints usage text and exits 0, which is what a
488
+ build without the `login status` subcommand does. Exit code and output
489
+ must now agree. Also, the login probe's synchronous spawn-failure path
490
+ bypassed its own settled guard.
491
+
492
+ - `doctor`'s `config` line now distinguishes a file that defines no routes
493
+ from one that defines routes AND asks for detection with `detect: true`.
494
+ It reported the former for both, which reads as though the file were being
495
+ ignored.
496
+
497
+ - **The plugin's `/setup` command no longer walks users into a config that
498
+ removes every CLI harness.** It told them to write `disabled:`,
499
+ `overrides:` and `endpoints:` together — and a config carrying
500
+ `endpoints:` is authoritative, so detection switches off, the two tuning
501
+ keys apply to nothing, and Claude Code, Codex, Cursor and Antigravity all
502
+ vanish from the route table. That is not hypothetical: the maintainer's
503
+ own config had this shape, and no dispatch on that machine could reach a
504
+ CLI harness for four days after 0.9.0 shipped the authoritative rule. The
505
+ command now states the rule up front, points at `configure --yes` first,
506
+ offers two valid shapes (`detect: true` plus `endpoints:`, or a fully
507
+ explicit `clis:`), and ends by reading the two `doctor` lines that report
508
+ the mistake. Also corrected in the plugin: the "not yet published" note
509
+ (0.9.0 is on npm), the config-resolution order (it omitted `./config.yaml`
510
+ and the state-dir variable), the claim that `hints.model` is unvalidated,
511
+ and the command list, which omitted `/setup`.
512
+ - **Two rules that a user depends on were passing while broken.** An audit
513
+ broke fifteen guards on purpose to see which the suite would catch; these
514
+ are the two that got through. First, "a per-call `timeoutMs` beats the
515
+ service's configured default" is written out four times in `router.ts`,
516
+ once per entry point, and only the `route()` copy was tested — while
517
+ background jobs and every HTTP request go through `stream()`/`streamTo()`,
518
+ so the copies that carry real traffic were unpinned. Second, the HTTP
519
+ bearer check was only ever tested with the header MISSING: a server that
520
+ accepted any `Bearer` value at all passed every test in its own file, and
521
+ token rotation on a running server was never exercised. Both now have
522
+ table-driven tests covering every entry point and every authenticated
523
+ route, including a wrong token of the same length, an empty one, and one
524
+ without the scheme. No behaviour changed; the tests are what changed.
525
+
526
+ - README corrected in nine places found by an audit against the code: the
527
+ Configure section described a verification step and an interactive
528
+ choice of harnesses, model priority and safety profile that do not exist;
529
+ Claude Code billing was still described as date-aware when the code
530
+ classifies it as plan usage unconditionally; the CLI list omitted the
531
+ public `dispatch` command while calling `route` the hidden one (it is the
532
+ alias); the endpoint-mode example used the legacy `services:` format
533
+ without saying so, which silently drops `clis:` routes when pasted into a
534
+ modern config; two different default timeouts were stated (60 minutes is
535
+ right for every MCP and HTTP dispatch); `job_status` with no id was said
536
+ to return every job (it returns the twenty newest); `configure`'s target
537
+ omitted that a `./config.yaml` still wins; `usage listModels` was said to
538
+ fetch live when a declared list is returned as-is; and a duplicated
539
+ endpoint line.
540
+
541
+ - **`configure` no longer regenerates away a comment you put above its
542
+ header, and no longer counts a stripped final newline as an edit.** The
543
+ unedited check accepted any comment lines above the fingerprint, so a
544
+ `# note to self` at the top of the file was overwritten without a word;
545
+ meanwhile an editor that drops the trailing newline made the file count
546
+ as edited and forced `--force`. Only the exact header may sit above the
547
+ fingerprint now, and trailing whitespace is ignored when fingerprinting.
548
+ (Twenty-fifth acceptance pass, finding 4.)
549
+
550
+ - **`doctor` names a harness on PATH that an authoritative config leaves
551
+ out.** A config listing its own routes is authoritative, so a harness
552
+ installed afterwards is simply absent — and `routes` said "1 ready
553
+ route(s)" with a second CLI installed and no hint; the PATH hint only
554
+ appeared at zero routes. It now adds "Installed but not in this config:
555
+ claude — add `detect: true` to <file> to merge them, or a clis: entry".
556
+ (Finding 5.)
557
+
558
+ - **`--config` at the boundary.** A directory produced a raw `EISDIR` that
559
+ named no path; it now says which path is a directory. `--config=` (empty)
560
+ silently auto-detected and reported the routes as loaded "from" the
561
+ current directory; it is now the same usage error as a missing value. An
562
+ empty or routes-free config file was reported as the source of routes
563
+ that detection had found; doctor's `config` line now says they were
564
+ auto-detected and that the file defines no routes of its own. (Finding 3;
565
+ its remaining half — a load failure printing text under `--json` — is fixed
566
+ further down this same release.)
567
+
568
+ ## [0.9.0] — 2026-09-02
569
+
570
+ ### Changed (breaking)
571
+
572
+ - **A config file that defines routes is now authoritative about them.**
573
+ Auto-detection used to run unconditionally, so a file with `clis:` or
574
+ `endpoints:` entries ADDED to the installed harnesses rather than replacing
575
+ them, and only `disabled:` — naming every route, including ones you might
576
+ not know existed — subtracted.
577
+
578
+ That default caught three acceptance passes in a row despite explicit
579
+ warnings, and caught this project's own test suite: one test dispatched to
580
+ the real Claude Code CLI on every `npm test` and every CI run, under a
581
+ comment asserting it could not reach a route. It also decayed — adding
582
+ support for a new harness would auto-add it to every existing config, so a
583
+ `disabled:` list written today quietly stopped isolating tomorrow.
584
+
585
+ The legacy `services:` format has always behaved this way, so the two shapes
586
+ now agree rather than this being a new rule.
587
+
588
+ **What changes for you.** A config that has a `clis:` or `endpoints:` key
589
+ gets exactly the routes listed under it — including `clis: []`, which means
590
+ no CLI routes. Add `detect: true` to keep the old behaviour. A config
591
+ mentioning NEITHER key — one carrying only `overrides:`, `disabled:` or
592
+ settings — still auto-detects, because a file cannot be authoritative about
593
+ routes it does not describe; it now says so in a warning. `detect: false`
594
+ turns detection off outright.
595
+
596
+ Note that `disabled:` and `overrides:` tune AUTO-DETECTION, so they do
597
+ nothing in a config that lists its own routes — remove the entry instead.
598
+ The config the README tells you to copy said the opposite of this until it
599
+ was corrected in the same release; check yours if you wrote it from that.
600
+
601
+ ### Fixed
602
+
603
+ - **Re-running `configure` after installing a harness no longer needs
604
+ `--force`.** The natural first-run order — install this tool, configure,
605
+ find out a harness is needed, install one, configure again — was refused
606
+ at the last step: "already exists ... --force". And had the first run found
607
+ anything, the re-run would have loaded that file as authoritative and not
608
+ detected the new harness at all. `configure` now stamps what it writes
609
+ with a fingerprint; a re-run that finds its own unedited output regenerates
610
+ it from a fresh detection, and says so. A file that has been edited, or
611
+ that configure did not write, is still loaded (so its settings migrate)
612
+ and still refused without `--force` — and since such a file lists its own
613
+ routes, `--force` regenerates it from the file, not from a detection. It
614
+ used to print "Detected N harness routes" there anyway; an acceptance pass
615
+ measured that line with a second harness on PATH that never appeared. It
616
+ now says detection did not run and how to merge (`detect: true`). The
617
+ non-interactive hint at the end of `configure --yes` also told the user to
618
+ pass `--yes`, which they just had; it now names `connect`. Seen on the
619
+ cold-install walk.
620
+
621
+ - **`connect` now registers with a Claude Code that has never been opened.**
622
+ A client counted as installed only if its config file existed, and Claude
623
+ Code writes `~/.claude.json` on its first interactive launch — so a user who
624
+ installed Claude Code and this tool together was told "No MCP clients found
625
+ on this machine" with `claude` on PATH, handed JSON to paste by hand, and
626
+ `claude mcp list` stayed empty. A client whose command is on PATH now counts
627
+ as installed; when its file is missing, `connect` creates it holding only
628
+ our entry (0600), which Claude Code accepts as a user-scope registration.
629
+ Doctor's `mcp-clients` line names the installed-but-unregistered client
630
+ instead of a generic "not registered". Seen on the cold-install walk.
631
+
632
+ - **`configure` writes its config where every later command can find it.**
633
+ It wrote `./config.yaml` into whatever directory it was run from, and
634
+ lookup stopped at the current directory, so a config written from `~` was
635
+ invisible to `doctor` or `dispatch` run inside a project — "0 configured
636
+ route(s)", no hint why — while the MCP client, handed the absolute path,
637
+ saw it fine. The default target is now the state directory
638
+ (`~/.harness-dispatch/config.yaml`, or under `HARNESS_DISPATCH_STATE_DIR`),
639
+ lookup falls back to that file after `./config.yaml` (a per-project file
640
+ still wins), `connect` looks in the same place, and doctor's `config` line
641
+ names the file it loaded. `--config` and `HARNESS_DISPATCH_CONFIG` are
642
+ unchanged. Seen on the cold-install walk, where a run from `/` produced
643
+ `/config.yaml`.
644
+
645
+ - **`doctor` now notices a Codex that is installed but not logged in.** A
646
+ ready route meant "the CLI is on PATH", so a never-logged-in Codex passed
647
+ routes, billing and safety, and the first dispatch failed with a raw OpenAI
648
+ `401 Unauthorized ... Missing bearer` that never mentioned `codex login`.
649
+ A new `harness-login` check asks `codex login status` and fails with the
650
+ command to run when it answers "Not logged in". The CLI is asked rather
651
+ than its credential file read, because Codex accepts a ChatGPT login or an
652
+ API key and honours its own home directory, and only its own answer is
653
+ right in every case. Any answer other than a definite "Not logged in" —
654
+ an older Codex without the subcommand, a spawn failure — is reported as
655
+ undetermined and does not fail the install. Codex only: the other
656
+ harnesses have no equivalent subcommand this tool has verified.
657
+
658
+ - **The installed command did nothing on Linux and macOS.** `npm install -g`
659
+ puts `harness-dispatch` on PATH as a symlink to `dist/bin.js`, and node
660
+ hands that unresolved link path to the program, so a run-if-entrypoint guard
661
+ that only looked for a filename ending in `bin.js` decided it was being
662
+ imported, ran nothing, and exited 0. Every documented command — `configure`,
663
+ `doctor`, `connect`, `dispatch`, even `--help` — printed nothing and reported
664
+ success on every non-Windows `npm install -g` or `npx` since the first
665
+ release. Windows was unaffected only because npm's `.cmd` shim passes the
666
+ real file path. The guard now also accepts an invoked path that resolves to
667
+ this file. Found by walking the README's three install steps in a clean
668
+ container, which nothing before had done: every prior walk ran `node
669
+ dist/bin.js` on this Windows machine.
670
+
671
+ - **SECURITY: workspace paths are now built and verified rather than checked
672
+ and trusted.** Four consecutive releases patched a guard that inspected a
673
+ path STRING and then let every later write re-resolve it, and each patch was
674
+ found incomplete by the next review: it followed the link it was checking,
675
+ then covered one of the two isolation policies, then only the last path
676
+ segment, then stopped one directory short of the one the release notes named
677
+ — and was silently inert altogether if the workspaces setting had a trailing
678
+ slash or a `..` in it. Validating once also left the rest of the dispatch
679
+ trusting the result, so swapping a directory for a link mid-copy redirected
680
+ it.
681
+
682
+ Every directory from the anchor down is now created by this tool with a
683
+ non-recursive `mkdir`, which cannot traverse a link it did not make, and the
684
+ verified path is returned and used instead of being re-derived. Destructive
685
+ operations re-verify immediately before acting. The symlink refusal now
686
+ applies on Windows too, where junctions need no privileges and were being
687
+ followed.
688
+
689
+ Verified as two separate users on Linux across every shape reported: a link
690
+ at the workspace root, at the base, at the base's parent, and with the
691
+ setting spelled with a trailing slash or `..` — all refused, with nothing
692
+ written into the attacker's directory. Legitimate use is unaffected,
693
+ including a symlinked temp directory, which is normal on macOS.
694
+
695
+ - **A delegated "run the tests" task no longer blocks its own route.** The
696
+ rate-limit phrase check ran against the whole transcript with no filter, so
697
+ this project's own test output — which names rate limits in test titles —
698
+ flagged as a real limiter, tripping the breaker for 300 seconds and
699
+ recording a rate limit that never happened. It is now checked per line and
700
+ past the same filter the numeric check uses.
701
+
702
+ - **Real limiter messages that were being missed now register**, including
703
+ Anthropic's `rate_limit_error` and OpenAI's "You exceeded your current
704
+ quota" wording. A missed limit is the worse direction: the router keeps
705
+ hammering a route that has already said stop.
706
+
707
+ - A metered route that declares it cannot bill you is no longer told to allow
708
+ paid usage. The previous release put that advice in a branch such a route
709
+ never reaches, so it worked for local routes only.
710
+
711
+ - The claims checker now also scans MCP resource descriptions, every
712
+ route-refusal message, the rendered status body and the CLI's own help text
713
+ — surfaces a user or agent reads that it could not see.
714
+
715
+ - **SECURITY: the workspace symlink guard covered one of the two isolation
716
+ policies.** The previous release applied the `lstat` refusal, the
717
+ guard-before-prune ordering and the sweep's name check to `copy` only.
718
+ `git_worktree` kept all three defects, so the identical attack still
719
+ destroyed data — reproduced as two real users: `copy` refused, `git_worktree`
720
+ deleted the victim's directory and raised the guard's error afterwards. The
721
+ entry claiming the class was closed was true for one policy and false for the
722
+ other. The test shipped with it passed `policy: "copy"` only, so the suite
723
+ stayed green over a live vulnerability; it now runs against both.
724
+
725
+ - **The same guard only checked the last path segment.** `<tmp>/harness-dispatch`
726
+ and `<tmp>/harness-dispatch/workspaces` are fixed names under a
727
+ world-writable directory and were never examined, so a link planted one or
728
+ two levels up redirected the whole copy into an attacker's tree. Every
729
+ segment from the workspaces base down is now checked for links and ownership.
730
+
731
+ - **A rate-limit regression from the previous release.** The filter added to
732
+ stop false positives discarded any line containing "should", which threw away
733
+ real limiter messages — a missed 429 means the router keeps hammering an
734
+ exhausted route. Two other patterns in that filter could never match at all.
735
+
736
+ - **A free local route is no longer told it can bill you, with a fix that does
737
+ nothing.** A route with a declared kind and `paid_usage_possible: false` is
738
+ blocked when its `billing_confidence` is `unknown` — deliberate — but was
739
+ shown "route can incur paid usage" beside `paid=no` and advised to set a
740
+ field it already had. It now names the real cause and a remedy that works.
741
+
742
+ - The `usage` tool description and README told agents that `service` and
743
+ `models` are unvalidated. Both throw on an unknown route id. The same wrong
744
+ sentence was corrected in the plugin skill one release earlier and missed
745
+ here — `scripts/check-claims.mjs` could not see tool descriptions at all, and
746
+ now does.
747
+
748
+ - `scripts/fetch_benchmarks.py` no longer replaces good benchmark data with its
749
+ bundled fallback when the network fails. It swallowed every exception,
750
+ returned an empty set, wrote it out and exited 0 — turning a transient outage
751
+ into a permanent downgrade of the file that ships in the package.
752
+
753
+ - An env-var reference embedded in a larger value (`base_url:
754
+ https://${HOST}/v1`) is now stripped from spawned CLI environments. The
755
+ sanitizer anchored to whole-string matches, so a key inside a longer string
756
+ stayed visible — the exact leak it was written to close, one string shape
757
+ over.
758
+
759
+ - `OPERATIONS.md` described streamed fanout wrongly (its arms DO create jobs,
760
+ and are not aborted on disconnect), `safety.ts` claimed every shipped harness
761
+ defines all three safety profiles (`cursor_cli` declares one),
762
+ `ux-walkthrough.md` called `discard` the only destructive operation (the
763
+ retention sweep is the one that deleted files), and two module headers said
764
+ three tools where six are registered.
765
+
766
+ - **SECURITY: a `copy` dispatch could delete files outside its workspace.**
767
+ The ownership guard used `stat`, which follows symlinks, so it compared the
768
+ uid of whatever a link POINTED AT rather than the link's own — and on a
769
+ shared machine the workspace path is predictable, so another local user can
770
+ plant one. Worse, the retention sweep that deletes aged workspaces ran
771
+ BEFORE the guard and had no ownership, marker or name check of its own.
772
+ Reproduced end to end in a container: a victim's directory chmod'd, the
773
+ project copied into it, and their files removed. All three now fixed —
774
+ `lstat` with symlinks refused outright, the guard runs before anything
775
+ destructive, and the sweep only deletes directories matching the name shape
776
+ this tool generates. The previous release's entry claiming a foreign-owned
777
+ root was "refused outright" was false against exactly this attack.
778
+
779
+ - **Non-ASCII output was being corrupted.** Every stdout chunk was decoded on
780
+ its own, so a character split across two reads became replacement
781
+ characters — accented text, CJK, emoji, box-drawing. This is the path every
782
+ job uses, so it reached partial output, saved logs and delivered results,
783
+ and was invisible to anyone working in English.
784
+
785
+ - **An aborted streaming request now stops the run.** Nothing connected the
786
+ client disconnect to the dispatch, so an abandoned stream ran to completion,
787
+ spending quota — and being the one path with no job record, it could not be
788
+ cancelled either. On a CLI route that meant an agent still editing files for
789
+ a caller that had gone.
790
+
791
+ - **HTTP fanout with no eligible route now refuses** instead of answering 200
792
+ with an empty list. CI and cron read 200 as success; the MCP surface already
793
+ refused the identical request by name.
794
+
795
+ - A configured `timeout_ms` now covers reading an endpoint's response body,
796
+ not just its headers — the same defect as the leaderboard timeout fixed last
797
+ release, in the path the router uses for its primary route.
798
+
799
+ - Endpoint credentials can no longer leak through streaming error text: undici
800
+ embeds the request URL, and this branch returned it unscrubbed while its
801
+ sibling scrubbed it deliberately.
802
+
803
+ - A rate-limit false positive no longer trips the breaker on text ABOUT a 429
804
+ — a line number, or a test assertion the delegate ran. One flag blocks a
805
+ route for 300 seconds and records a limit that never happened.
806
+
807
+ - Config hot-reload now notices a config whose timestamp moves BACKWARDS
808
+ (restored from a backup, `cp -p`, extracted from an archive), and documents
809
+ the two cases where it does nothing at all.
810
+
811
+ - The MCP snippet `configure` prints is now the entry `connect` actually
812
+ writes. Pasting the documented snippet then running `connect` was answered
813
+ "has an entry we did not write" — the tool called its own output
814
+ hand-edited and refused to update or remove it.
815
+
816
+ - The chained-context omission notice named the wrong jobs when a job id
817
+ repeated, listing ones whose output was directly above it.
818
+
819
+ - **An existing `copy` workspace directory is now actually secured.** The
820
+ previous release set 0700 with `mkdir`'s `mode` option, which applies only to
821
+ directories it CREATES — so everyone who had used `copy` before kept a
822
+ world-readable root holding a full copy of their source, and the entry
823
+ claiming otherwise was wrong. Measured on Linux: 0755 before, 0755 after. An
824
+ explicit chmod fixes them. A root owned by ANOTHER user is now refused
825
+ outright rather than copied into: the path is deterministic inside a shared
826
+ temp directory, so on the multi-user machine this guard exists for, someone
827
+ else can create it first.
828
+
829
+ - **A half-dead leaderboard endpoint no longer hangs every dispatch.** The 8s
830
+ timeout was cancelled as soon as response headers arrived, leaving the body
831
+ read unbounded — measured still pending after 15s. Quality scores are awaited
832
+ per routing candidate, so this stalled routing indefinitely. Off by default,
833
+ which is why it was not worse.
834
+
835
+ - **The HTTP server no longer leaks an MCP server per rejected request.** A
836
+ request carrying an unknown session id is answered 400 without initialising,
837
+ so nothing ever closed the server built for it in advance — five such
838
+ requests left five alive until shutdown. Sessions are also expired after 30
839
+ minutes idle: nothing removed them before except an explicit HTTP DELETE,
840
+ which the standard client never sends, so a clean shutdown left its session
841
+ resident forever.
842
+
843
+ - **`retry_job` on an abandoned job no longer races the original.** Its guard
844
+ read the derived status while the claim path reads the raw status file, so a
845
+ supervisor could pick up the original while the retry ran — the exact outcome
846
+ the command's own error text says it prevents. The original is now marked
847
+ cancelled.
848
+
849
+ - **Chained context says what it left out.** Jobs past the character budget
850
+ were dropped with no header and no note, contradicting the contract stated in
851
+ the same file. The docblock also claimed oldest entries truncate first, which
852
+ was never true — the last ones go.
853
+
854
+ - The `copy` size refusal named the parent of your project rather than the
855
+ workspace holding the work, and the `force` flag's description still promised
856
+ an uncommitted-changes refusal that only exists inside a git repository.
857
+
858
+ - **`configure` no longer deletes `detect: false`.** That key is the only
859
+ setting that isolates a machine from its installed paid CLIs, and it had no
860
+ field on the internal config object, so regenerating a file silently dropped
861
+ it. Measured: `detect: false` plus `max_concurrent_runs: 2` came back as the
862
+ latter alone, and reloading it with all four harness CLIs present produced
863
+ four routes on real subscriptions — under a "Wrote" message. A bare
864
+ `detect: false` regenerated as an empty document, where even the
865
+ "this config defines no routes" warning is suppressed, because its trigger
866
+ requires a non-empty file. `detect` now round-trips, and is written only when
867
+ the file stated it — carrying the resolved value would stamp `detect: true`
868
+ into every config that merely omitted it.
869
+
870
+ - **`connect --remove` now removes the entry.** Without `--clients` it printed
871
+ "our entry is here — will be removed", exited 0, and left the file
872
+ byte-identical. The chooser filtered out plans whose entry already matches —
873
+ correct when registering, and exactly backwards when removing, where a
874
+ matching entry is the one being removed. `--clients` bypassed that function
875
+ and always worked; the broken form is the one documented in README and
876
+ OPERATIONS.md. A hand-edited entry is still refused without `--force`, and
877
+ now exits non-zero rather than reporting success for work it did not do.
878
+ This command had no test coverage at all; it does now.
879
+
880
+ - **The test suite can no longer reach a real route.** Test setup already
881
+ sandboxed the log, state and jobs directories — where the suite WRITES — but
882
+ not config, which is what it DISCOVERS. A test that loaded config without
883
+ naming a file or stubbing detection picked up whatever the developer's
884
+ machine offers. Measured while closing this: on the maintainer's machine
885
+ that is the repo's own `config.yaml`, yielding four API routes with real
886
+ keys; on a machine with the CLIs installed it is the harness fleet, on real
887
+ subscriptions. One boundary test really did dispatch to Claude Code on every
888
+ CI run once. Setup now pins `HARNESS_DISPATCH_CONFIG` at a `detect: false`
889
+ sandbox, so an un-stubbed load gets an empty route table; a test wanting
890
+ routes passes its own path, which still wins.
891
+
892
+ - **An orphaned job can be cancelled again — when it is still able to run.**
893
+ There are two kinds: one written to disk when the server exits before a
894
+ queued job starts (genuinely terminal), and one DERIVED from a stale
895
+ heartbeat while the file still says `queued`. The second is not inert — a
896
+ supervisor reclaims it once the dead owner's claim ages out — and `cancel`
897
+ answered "had already finished; nothing to cancel" about work that could
898
+ still start, leaving no way to stop it. It now settles the status itself,
899
+ since an orphaned job has no runner to notice a marker. The written kind
900
+ still reports nothing to cancel.
901
+
902
+ - **A `copy` workspace of many files can no longer build an unbounded patch.**
903
+ The per-file diff was already bounded; the concatenation of those files was
904
+ not, so files each under the limit still summed past it. Both limits now
905
+ explain themselves — the worktree path used to surface the cap as
906
+ `stdout maxBuffer length exceeded`, which says nothing about patches or
907
+ about the work still being safe on disk.
908
+
909
+ - **Isolated workspaces are created 0700**, like the state and job
910
+ directories. A `copy` workspace holds a full copy of your source and the
911
+ default base is in the shared temp directory, so on a multi-user POSIX
912
+ machine it was readable by everyone. No effect on Windows.
913
+
914
+ - **Chained context says which project it came from.** `contextJobs` inlines
915
+ any job from the machine-wide store with no working-directory scoping, so a
916
+ job from one project could be pulled into a dispatch for another with
917
+ nothing indicating it. Disclosed rather than blocked: chaining across
918
+ projects is legitimate and the caller passes the id explicitly — what was
919
+ missing is that nobody could see it happen.
920
+
921
+ - **Dead supervisor heartbeats are swept.** A killed supervisor's file stopped
922
+ being counted but stayed forever, and the liveness check reads every file on
923
+ every drain. The sweep is limited to heartbeats: the crash log beside them
924
+ exists to explain the very supervisor that died.
925
+
926
+ - `workspace apply`'s description no longer claims a refusal it cannot always
927
+ make: the uncommitted-changes check needs a git repository. Outside one,
928
+ only the per-file check applies — a file the patch touches is still
929
+ refused, unrelated edits cannot be seen.
930
+
931
+ - A `timeout_ms` or token-cap warning no longer claims routing reads the
932
+ field. Routing does not; the range branch was fixed for this and the
933
+ not-a-number branch beside it was not.
934
+
935
+ - **Usage counters are no longer silently lost when two dispatches finish at
936
+ once.** `withFileLock` runs its critical section unlocked after a 2s
937
+ timeout — deliberate, and still right for the circuit breaker, which has
938
+ nothing to fall back on. It was wrong for the quota counters, which keep a
939
+ pending delta and clear it only on success: an unserialised write landed,
940
+ the delta was cleared as though serialised, and the process actually holding
941
+ the lock then overwrote the file with a value computed before that write
942
+ existed. Reproduced against the built artifact: five recorded calls gone,
943
+ no error. The counters now defer instead, and the delta lands on the next
944
+ result. A busy moment is no longer reported as "counters not reaching
945
+ disk" — they are delayed, not lost, and saying otherwise makes a working
946
+ system look broken.
947
+
948
+ - **A route knocked out by a 429 no longer reports `tripped: true,
949
+ failures: 0`.** `trip()` set the cooldown without counting the failure, so
950
+ `usage` — the surface an orchestrator is told to consult before delegating
951
+ — showed a contradiction that reads as a bookkeeping bug rather than a real
952
+ trip.
953
+
954
+ - **Discarding a workspace whose directory is already gone now clears the
955
+ registration git is still holding.** The early return skipped the block that
956
+ removes a worktree through git, so a workspace pruned by retention or
957
+ deleted by hand left `.git/worktrees/<name>` in the user's repo
958
+ permanently — the exact outcome `discardWorkspace`'s own documentation says
959
+ it exists to prevent. Reproduced: `git worktree list` still showing the path
960
+ as `prunable` after discard reported success.
961
+
962
+ - **An orphaned or cancelled job now hands back its partial work through the
963
+ tool an orchestrator actually calls.** The progress was on disk and
964
+ `getAsyncJob` read it, but `job_status` answered `output: ""`: orphaned and
965
+ cancelled count as terminal, and the poll response attached `partialOutput`
966
+ only on the NOT-terminal branch. So a commit titled "an orphaned job hands
967
+ back its progress" was correct in the module it edited and had no effect at
968
+ the surface a caller touches — verified at the function, not at the tool.
969
+ The crash path (a `failed` status with no result) was losing its output the
970
+ same way. The HTTP surface already salvaged this; MCP now matches it, and
971
+ the answer says the output is PARTIAL so salvage is not read as a result.
972
+
973
+ - **A gitignored file the agent wrote is no longer reported as applied while
974
+ being left behind.** Under `git_worktree`, the patch came from `git add -A
975
+ -N`, which obeys `.gitignore`, while the changed-file list came from a
976
+ filesystem fingerprint, which does not. A job that wrote a `.env` or any
977
+ ignored file got `applied: true` naming it, with the file absent from the
978
+ patch and from the project. It compounded: the already-applied guard needs
979
+ every recorded change present, so it never fired, and the next apply refused
980
+ with "changed since dispatch" — blaming the caller for the first apply's own
981
+ writes, the misleading refusal an earlier fix had removed. The paths already
982
+ recorded as changed are now force-added, which also makes `git_worktree`
983
+ agree with `copy`, whose per-file patch always carried them.
984
+
985
+ - **`clis: []` now isolates a config, which is what the previous release said
986
+ it did.** Authoritativeness keyed off a NON-EMPTY list, so the most explicit
987
+ way to write "no CLI routes" still loaded every harness on the machine —
988
+ the exact failure the change was written for, described in its own comment
989
+ in the past tense while remaining true. Presence of `clis:`/`endpoints:` is
990
+ now what makes a config authoritative, empty or not. A config that mentions
991
+ neither still auto-detects, unchanged.
992
+
993
+ - **An infinite or NaN route field is no longer accepted, and the warning
994
+ about it is no longer false.** The new range check covered negative values
995
+ but not `.inf`, `.nan`, or `1e999` (which YAML types as a string, and
996
+ `Number()` overflows to `Infinity`). Two paths were wrong: `1e999` produced
997
+ no warning at all, and `tier: -.inf, weight: .inf` loaded as
998
+ `-Infinity`/`Infinity` — ahead of every tier, above every score —
999
+ underneath a warning reading "IGNORED, and the built-in default applies
1000
+ instead". A message asserting the opposite of what happened is worse than
1001
+ the silence it replaced. Warnings also now name the value the operator
1002
+ actually wrote (`JSON.stringify(Infinity)` is `null`, so they reported
1003
+ `tier is null` for a file saying `-.inf`), and explain the right mechanism
1004
+ per field rather than describing routing for `timeout_ms`.
1005
+
1006
+ - **`configure` no longer generates a config that fails its own `doctor`.**
1007
+ It carried `disabled:` forward alongside the `clis:` it generates; in an
1008
+ authoritative config that control does nothing, so `doctor` reported it had
1009
+ no effect and exited 1. The disabled route is already absent from the
1010
+ generated list, so the name was saying nothing. It is still emitted for a
1011
+ generated config that lists no routes, where it does the work.
1012
+
1013
+ - **A `clis:` or `endpoints:` written as a mapping instead of a list now
1014
+ says so.** The entries vanished, the config counted as defining no routes,
1015
+ detection ran, and someone trying to name their own routes silently got
1016
+ every installed paid harness instead — under a warning claiming their
1017
+ config defined no routes, which contradicted the file in front of them.
1018
+
1019
+ - Legacy `services:` configs now get the same top-level key warnings as the
1020
+ modern shape. That path returned before the check ran, so `policy: copy`
1021
+ warned twice in one format and not at all in the other.
1022
+
1023
+ - **A negative `tier`, `weight` or `cli_capability` no longer hands a route
1024
+ every dispatch.** These were type-checked but never range-checked, and
1025
+ routing multiplies `weight` and `cli_capability` into the score while
1026
+ ordering `tier` ascending — so a negative pair does not demote a route, it
1027
+ promotes it past every legitimate one. An acceptance pass measured
1028
+ `tier: -5, weight: -100, cli_capability: -3` scoring 299.8 against a normal
1029
+ route's 0.88, from a tier sorting ahead of them all, with no warning
1030
+ anywhere. Out-of-range values are now reported and ignored, so the built-in
1031
+ default applies — the same outcome an unreadable value already got. Upper
1032
+ bounds were deliberately not added: `cli_capability: 1.1` ships in this
1033
+ repo's own default config as real tuning, and the defect is sign, not
1034
+ magnitude.
1035
+
1036
+ - Top-level `policy:` and `workspace_policy:` now say they have no effect.
1037
+ Both were allow-listed and read nowhere — and both ARE valid per-route keys,
1038
+ which is what makes the top-level spelling easy to write: it looks like a
1039
+ global default for the per-route setting, and there is no such default. An
1040
+ isolation control that silently does nothing is the failure the config
1041
+ validator exists to prevent. The per-route keys are unaffected.
1042
+
1043
+ - An `execute` task can no longer be routed to an HTTP endpoint. An endpoint
1044
+ has no agent loop, no file access and no shell — PRODUCT.md states this as
1045
+ design rather than gap — but an undeclared capability defaults to 1.0 and no
1046
+ endpoint example declares any, so endpoints scored PERFECT for execute. An
1047
+ acceptance pass measured a `--task-type execute` dispatch routed to an
1048
+ endpoint, returning prose with exit 0: execution reported as succeeded when
1049
+ none happened. It surfaced exactly when the CLI routes were busy or tripped,
1050
+ which is the case a caller is least able to check.
1051
+
1052
+ Refused outright rather than scored low, on both counts deliberately: a score
1053
+ of 0 still leaves a route selectable when it is the only candidate — the
1054
+ failing case itself — and a declared capability must not override it, the
1055
+ same rule as the safety-flag check. Endpoints remain full members of the mix
1056
+ for plan, review and second opinions.
1057
+
1058
+ - An orphaned job now hands back the progress it saved. When a supervisor dies
1059
+ there is no result, but its output is on disk in `stdout.partial.log` — and
1060
+ the orphan branch returned above the code that reads it, so the response was
1061
+ empty while the trail sat there. Chaining had the same gap, reporting "no
1062
+ result available" for a job whose partial output was recoverable; it now
1063
+ carries that output, labelled INCOMPLETE. PRODUCT.md names losing this trail
1064
+ as the defining failure: "a wasted attempt with no trail".
1065
+
1066
+ - HTTP fanout arms are now job-backed, like the MCP surface has always been.
1067
+ They called `routeTo` directly, so an arm's work existed only inside the
1068
+ request — no job directory, no manifest, no partial log. Killing the client
1069
+ or the server mid-fanout lost every arm's output with nothing on disk to
1070
+ salvage, which is the failure PRODUCT.md names as defining, on one of two
1071
+ surfaces.
1072
+
1073
+ The response shape is unchanged: arms are awaited and the same rows are
1074
+ returned, so an OpenAI-compatible client sees exactly what it saw before.
1075
+ Each row additionally carries the arm's `jobId`, which is what makes salvage
1076
+ possible. This was briefly recorded here as deferred "because it changes what
1077
+ a client receives" — that was wrong, and re-reading the MCP path showed why:
1078
+ durability and the response contract are independent.
8
1079
 
9
1080
  ## [0.8.0] — 2026-08-31
10
1081
 
@@ -1444,7 +2515,8 @@ the MCP surface to three tools: `dispatch`, `job_status`, `usage`.
1444
2515
  Known issues in this release, fixed in 0.5.0: `configure` writes resolved API keys into
1445
2516
  its output, and `configure --yes --force` can delete user-added harnesses.
1446
2517
 
1447
- [Unreleased]: https://github.com/fstubner/harness-dispatch/compare/v0.8.0...HEAD
2518
+ [Unreleased]: https://github.com/fstubner/harness-dispatch/compare/v0.9.0...HEAD
2519
+ [0.9.0]: https://github.com/fstubner/harness-dispatch/compare/v0.8.0...v0.9.0
1448
2520
  [0.8.0]: https://github.com/fstubner/harness-dispatch/compare/v0.7.9...v0.8.0
1449
2521
  [0.7.9]: https://github.com/fstubner/harness-dispatch/compare/v0.7.8...v0.7.9
1450
2522
  [0.7.8]: https://github.com/fstubner/harness-dispatch/compare/v0.7.7...v0.7.8