harness-dispatch 0.9.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 (125) hide show
  1. package/CHANGELOG.md +560 -1
  2. package/README.md +74 -43
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js.map +1 -1
  5. package/dist/bin.d.ts.map +1 -1
  6. package/dist/bin.js +166 -29
  7. package/dist/bin.js.map +1 -1
  8. package/dist/config/coercions.d.ts +5 -0
  9. package/dist/config/coercions.d.ts.map +1 -1
  10. package/dist/config/coercions.js +8 -0
  11. package/dist/config/coercions.js.map +1 -1
  12. package/dist/config/protocol.js.map +1 -1
  13. package/dist/config/validation.d.ts.map +1 -1
  14. package/dist/config/validation.js.map +1 -1
  15. package/dist/config.d.ts +11 -3
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +111 -148
  18. package/dist/config.js.map +1 -1
  19. package/dist/configure-yaml.d.ts.map +1 -1
  20. package/dist/configure-yaml.js +57 -13
  21. package/dist/configure-yaml.js.map +1 -1
  22. package/dist/dispatch-log.d.ts.map +1 -1
  23. package/dist/dispatch-log.js +3 -1
  24. package/dist/dispatch-log.js.map +1 -1
  25. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  26. package/dist/dispatchers/generic-cli.js +189 -97
  27. package/dist/dispatchers/generic-cli.js.map +1 -1
  28. package/dist/dispatchers/openai-compatible.d.ts +0 -5
  29. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  30. package/dist/dispatchers/openai-compatible.js +129 -82
  31. package/dist/dispatchers/openai-compatible.js.map +1 -1
  32. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -1
  33. package/dist/dispatchers/shared/harness-login.js +7 -2
  34. package/dist/dispatchers/shared/harness-login.js.map +1 -1
  35. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  36. package/dist/dispatchers/shared/stream-subprocess.js +7 -60
  37. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  38. package/dist/file-lock.d.ts.map +1 -1
  39. package/dist/file-lock.js +31 -19
  40. package/dist/file-lock.js.map +1 -1
  41. package/dist/harness-presets.d.ts +51 -0
  42. package/dist/harness-presets.d.ts.map +1 -0
  43. package/dist/harness-presets.js +154 -0
  44. package/dist/harness-presets.js.map +1 -0
  45. package/dist/http/parse.d.ts +8 -1
  46. package/dist/http/parse.d.ts.map +1 -1
  47. package/dist/http/parse.js +10 -3
  48. package/dist/http/parse.js.map +1 -1
  49. package/dist/http/server.d.ts.map +1 -1
  50. package/dist/http/server.js +9 -2
  51. package/dist/http/server.js.map +1 -1
  52. package/dist/job-runner.js +6 -0
  53. package/dist/job-runner.js.map +1 -1
  54. package/dist/jobs/lifecycle.d.ts +100 -0
  55. package/dist/jobs/lifecycle.d.ts.map +1 -0
  56. package/dist/jobs/lifecycle.js +263 -0
  57. package/dist/jobs/lifecycle.js.map +1 -0
  58. package/dist/jobs/read.d.ts +17 -0
  59. package/dist/jobs/read.d.ts.map +1 -0
  60. package/dist/jobs/read.js +103 -0
  61. package/dist/jobs/read.js.map +1 -0
  62. package/dist/jobs/run.d.ts +45 -0
  63. package/dist/jobs/run.d.ts.map +1 -0
  64. package/dist/jobs/run.js +351 -0
  65. package/dist/jobs/run.js.map +1 -0
  66. package/dist/jobs/start.d.ts +9 -0
  67. package/dist/jobs/start.d.ts.map +1 -0
  68. package/dist/jobs/start.js +126 -0
  69. package/dist/jobs/start.js.map +1 -0
  70. package/dist/jobs/store.d.ts.map +1 -1
  71. package/dist/jobs/store.js +13 -3
  72. package/dist/jobs/store.js.map +1 -1
  73. package/dist/jobs/supervisor.d.ts +151 -0
  74. package/dist/jobs/supervisor.d.ts.map +1 -0
  75. package/dist/jobs/supervisor.js +679 -0
  76. package/dist/jobs/supervisor.js.map +1 -0
  77. package/dist/jobs.d.ts +15 -233
  78. package/dist/jobs.d.ts.map +1 -1
  79. package/dist/jobs.js +14 -1409
  80. package/dist/jobs.js.map +1 -1
  81. package/dist/leaderboard.d.ts.map +1 -1
  82. package/dist/leaderboard.js +5 -1
  83. package/dist/leaderboard.js.map +1 -1
  84. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  85. package/dist/mcp/config-hot-reload.js +4 -1
  86. package/dist/mcp/config-hot-reload.js.map +1 -1
  87. package/dist/mcp/dispatcher-factory.js +1 -1
  88. package/dist/mcp/dispatcher-factory.js.map +1 -1
  89. package/dist/mcp/resources.d.ts.map +1 -1
  90. package/dist/mcp/resources.js +3 -2
  91. package/dist/mcp/resources.js.map +1 -1
  92. package/dist/mcp/tools.d.ts +9 -2
  93. package/dist/mcp/tools.d.ts.map +1 -1
  94. package/dist/mcp/tools.js +45 -6
  95. package/dist/mcp/tools.js.map +1 -1
  96. package/dist/observability/spans.d.ts.map +1 -1
  97. package/dist/observability/spans.js +10 -2
  98. package/dist/observability/spans.js.map +1 -1
  99. package/dist/redaction.d.ts +90 -0
  100. package/dist/redaction.d.ts.map +1 -0
  101. package/dist/redaction.js +221 -0
  102. package/dist/redaction.js.map +1 -0
  103. package/dist/router.d.ts +12 -23
  104. package/dist/router.d.ts.map +1 -1
  105. package/dist/router.js +83 -224
  106. package/dist/router.js.map +1 -1
  107. package/dist/state-dir.d.ts.map +1 -1
  108. package/dist/state-dir.js +15 -1
  109. package/dist/state-dir.js.map +1 -1
  110. package/dist/status.d.ts +14 -3
  111. package/dist/status.d.ts.map +1 -1
  112. package/dist/status.js +36 -3
  113. package/dist/status.js.map +1 -1
  114. package/dist/workspace-lock.d.ts +20 -0
  115. package/dist/workspace-lock.d.ts.map +1 -1
  116. package/dist/workspace-lock.js +63 -0
  117. package/dist/workspace-lock.js.map +1 -1
  118. package/dist/workspace-resolve.d.ts +0 -9
  119. package/dist/workspace-resolve.d.ts.map +1 -1
  120. package/dist/workspace-resolve.js +38 -183
  121. package/dist/workspace-resolve.js.map +1 -1
  122. package/dist/workspaces.d.ts.map +1 -1
  123. package/dist/workspaces.js +63 -139
  124. package/dist/workspaces.js.map +1 -1
  125. package/package.json +5 -4
package/CHANGELOG.md CHANGED
@@ -4,7 +4,566 @@ 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.)
8
567
 
9
568
  ## [0.9.0] — 2026-09-02
10
569