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.
- package/CHANGELOG.md +560 -1
- package/README.md +74 -43
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +166 -29
- package/dist/bin.js.map +1 -1
- package/dist/config/coercions.d.ts +5 -0
- package/dist/config/coercions.d.ts.map +1 -1
- package/dist/config/coercions.js +8 -0
- package/dist/config/coercions.js.map +1 -1
- package/dist/config/protocol.js.map +1 -1
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js.map +1 -1
- package/dist/config.d.ts +11 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +111 -148
- package/dist/config.js.map +1 -1
- package/dist/configure-yaml.d.ts.map +1 -1
- package/dist/configure-yaml.js +57 -13
- package/dist/configure-yaml.js.map +1 -1
- package/dist/dispatch-log.d.ts.map +1 -1
- package/dist/dispatch-log.js +3 -1
- package/dist/dispatch-log.js.map +1 -1
- package/dist/dispatchers/generic-cli.d.ts.map +1 -1
- package/dist/dispatchers/generic-cli.js +189 -97
- package/dist/dispatchers/generic-cli.js.map +1 -1
- package/dist/dispatchers/openai-compatible.d.ts +0 -5
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
- package/dist/dispatchers/openai-compatible.js +129 -82
- package/dist/dispatchers/openai-compatible.js.map +1 -1
- package/dist/dispatchers/shared/harness-login.d.ts.map +1 -1
- package/dist/dispatchers/shared/harness-login.js +7 -2
- package/dist/dispatchers/shared/harness-login.js.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.js +7 -60
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +31 -19
- package/dist/file-lock.js.map +1 -1
- package/dist/harness-presets.d.ts +51 -0
- package/dist/harness-presets.d.ts.map +1 -0
- package/dist/harness-presets.js +154 -0
- package/dist/harness-presets.js.map +1 -0
- package/dist/http/parse.d.ts +8 -1
- package/dist/http/parse.d.ts.map +1 -1
- package/dist/http/parse.js +10 -3
- package/dist/http/parse.js.map +1 -1
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +9 -2
- package/dist/http/server.js.map +1 -1
- package/dist/job-runner.js +6 -0
- package/dist/job-runner.js.map +1 -1
- package/dist/jobs/lifecycle.d.ts +100 -0
- package/dist/jobs/lifecycle.d.ts.map +1 -0
- package/dist/jobs/lifecycle.js +263 -0
- package/dist/jobs/lifecycle.js.map +1 -0
- package/dist/jobs/read.d.ts +17 -0
- package/dist/jobs/read.d.ts.map +1 -0
- package/dist/jobs/read.js +103 -0
- package/dist/jobs/read.js.map +1 -0
- package/dist/jobs/run.d.ts +45 -0
- package/dist/jobs/run.d.ts.map +1 -0
- package/dist/jobs/run.js +351 -0
- package/dist/jobs/run.js.map +1 -0
- package/dist/jobs/start.d.ts +9 -0
- package/dist/jobs/start.d.ts.map +1 -0
- package/dist/jobs/start.js +126 -0
- package/dist/jobs/start.js.map +1 -0
- package/dist/jobs/store.d.ts.map +1 -1
- package/dist/jobs/store.js +13 -3
- package/dist/jobs/store.js.map +1 -1
- package/dist/jobs/supervisor.d.ts +151 -0
- package/dist/jobs/supervisor.d.ts.map +1 -0
- package/dist/jobs/supervisor.js +679 -0
- package/dist/jobs/supervisor.js.map +1 -0
- package/dist/jobs.d.ts +15 -233
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +14 -1409
- package/dist/jobs.js.map +1 -1
- package/dist/leaderboard.d.ts.map +1 -1
- package/dist/leaderboard.js +5 -1
- package/dist/leaderboard.js.map +1 -1
- package/dist/mcp/config-hot-reload.d.ts.map +1 -1
- package/dist/mcp/config-hot-reload.js +4 -1
- package/dist/mcp/config-hot-reload.js.map +1 -1
- package/dist/mcp/dispatcher-factory.js +1 -1
- package/dist/mcp/dispatcher-factory.js.map +1 -1
- package/dist/mcp/resources.d.ts.map +1 -1
- package/dist/mcp/resources.js +3 -2
- package/dist/mcp/resources.js.map +1 -1
- package/dist/mcp/tools.d.ts +9 -2
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +45 -6
- package/dist/mcp/tools.js.map +1 -1
- package/dist/observability/spans.d.ts.map +1 -1
- package/dist/observability/spans.js +10 -2
- package/dist/observability/spans.js.map +1 -1
- package/dist/redaction.d.ts +90 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +221 -0
- package/dist/redaction.js.map +1 -0
- package/dist/router.d.ts +12 -23
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +83 -224
- package/dist/router.js.map +1 -1
- package/dist/state-dir.d.ts.map +1 -1
- package/dist/state-dir.js +15 -1
- package/dist/state-dir.js.map +1 -1
- package/dist/status.d.ts +14 -3
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +36 -3
- package/dist/status.js.map +1 -1
- package/dist/workspace-lock.d.ts +20 -0
- package/dist/workspace-lock.d.ts.map +1 -1
- package/dist/workspace-lock.js +63 -0
- package/dist/workspace-lock.js.map +1 -1
- package/dist/workspace-resolve.d.ts +0 -9
- package/dist/workspace-resolve.d.ts.map +1 -1
- package/dist/workspace-resolve.js +38 -183
- package/dist/workspace-resolve.js.map +1 -1
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +63 -139
- package/dist/workspaces.js.map +1 -1
- 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
|
-
## [
|
|
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
|
|