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.
- package/CHANGELOG.md +1074 -2
- package/README.md +92 -36
- package/config.default.yaml +19 -3
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/billing.d.ts +24 -0
- package/dist/billing.d.ts.map +1 -1
- package/dist/billing.js +24 -0
- package/dist/billing.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +299 -48
- package/dist/bin.js.map +1 -1
- package/dist/circuit-breaker.d.ts +9 -1
- package/dist/circuit-breaker.d.ts.map +1 -1
- package/dist/circuit-breaker.js +15 -1
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/client-register.d.ts +11 -1
- package/dist/client-register.d.ts.map +1 -1
- package/dist/client-register.js +15 -6
- package/dist/client-register.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 +12 -0
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +133 -7
- package/dist/config/validation.js.map +1 -1
- package/dist/config.d.ts +15 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +199 -152
- package/dist/config.js.map +1 -1
- package/dist/configure-yaml.d.ts +2 -0
- package/dist/configure-yaml.d.ts.map +1 -1
- package/dist/configure-yaml.js +114 -4
- 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 +281 -112
- 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 +150 -83
- package/dist/dispatchers/openai-compatible.js.map +1 -1
- package/dist/dispatchers/shared/harness-login.d.ts +25 -0
- package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
- package/dist/dispatchers/shared/harness-login.js +73 -0
- package/dist/dispatchers/shared/harness-login.js.map +1 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.js +71 -58
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
- package/dist/file-lock.d.ts +29 -1
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +53 -20
- 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 +2 -0
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +188 -16
- 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/context.d.ts +0 -7
- package/dist/jobs/context.d.ts.map +1 -1
- package/dist/jobs/context.js +95 -7
- package/dist/jobs/context.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/types.d.ts +8 -1
- package/dist/jobs/types.d.ts.map +1 -1
- package/dist/jobs.d.ts +15 -218
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +14 -1317
- package/dist/jobs.js.map +1 -1
- package/dist/leaderboard.d.ts.map +1 -1
- package/dist/leaderboard.js +44 -28
- package/dist/leaderboard.js.map +1 -1
- package/dist/mcp/config-hot-reload.d.ts +14 -3
- package/dist/mcp/config-hot-reload.d.ts.map +1 -1
- package/dist/mcp/config-hot-reload.js +27 -6
- package/dist/mcp/config-hot-reload.js.map +1 -1
- package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
- package/dist/mcp/dispatcher-factory.js +15 -4
- 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/tool-schemas.d.ts +1 -1
- package/dist/mcp/tool-schemas.d.ts.map +1 -1
- package/dist/mcp/tool-schemas.js +6 -3
- package/dist/mcp/tool-schemas.js.map +1 -1
- package/dist/mcp/tools.d.ts +20 -6
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +92 -14
- package/dist/mcp/tools.js.map +1 -1
- package/dist/mcp-clients.d.ts +9 -0
- package/dist/mcp-clients.d.ts.map +1 -1
- package/dist/mcp-clients.js +4 -0
- package/dist/mcp-clients.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/quota.d.ts.map +1 -1
- package/dist/quota.js +16 -2
- package/dist/quota.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/route-policy.d.ts +2 -1
- package/dist/route-policy.d.ts.map +1 -1
- package/dist/route-policy.js +54 -0
- package/dist/route-policy.js.map +1 -1
- package/dist/router.d.ts +12 -23
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +86 -223
- package/dist/router.js.map +1 -1
- package/dist/safety.d.ts.map +1 -1
- package/dist/safety.js +7 -1
- package/dist/safety.js.map +1 -1
- package/dist/state-dir.d.ts +10 -0
- package/dist/state-dir.d.ts.map +1 -1
- package/dist/state-dir.js +27 -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/types.d.ts +24 -1
- package/dist/types.d.ts.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 +115 -183
- package/dist/workspace-resolve.js.map +1 -1
- package/dist/workspaces.d.ts +15 -0
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +330 -90
- package/dist/workspaces.js.map +1 -1
- 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
|
-
## [
|
|
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.
|
|
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
|