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