release: integrate the 15 open PRs (render, fusion, triage batch) - #405
Merged
Conversation
… a session swap The rail's blue fill stops being drawn after a new session, and the rows it used to own keep that fill under the composer. Reproduced over a PTY at 120x34, rendering the escape stream into a pyte grid and counting the cells carrying a non-default background, before and after ctrl+g n: painted rows before the swap after the swap incremental: true 33 1 incremental: false 33 33 Ink's incremental writer skips any line whose rendered string matches the last frame's and anchors that diff by counting rows up from the bottom of the previous block. A session swap replaces the transcript, the rail and the meta bar in one commit; the anchor does not survive it, and the lines the renderer believed were already correct are no longer where it left them. instance.clear() on the session change was tried first, so the frame after the swap would be written against an empty cache. The callback was instrumented to prove it fires; the screen is still wrong. The desync is not something the app can resync from above, and the option is Ink's own experimental one, so it goes off here. The elapsed-time tick stays at 1000 ms — that part of #379 was three of every four wake-ups producing the identical string, and it is the larger share of the repaints a running turn asked for. What comes back is the blink on terminals without synchronized output, which is what v0.5.6 and everything before it did. Both assertions move with the option: the unit test on buildInkRenderOptions and the end-to-end one that reads the options off the intercepted render() call.
…work Fusion's split has been advice since the mode shipped, and a capable cloud model handed a catalog of forty tools reads the twelve files itself and never fans anything out. A QA run showed exactly that: nine steps, twelve os.fs.read calls, zero delegations, every completion on the cloud model. So the first mutation of a fusion turn is now refused until the turn has delegated once. Read, plan, split, delegate — then the gate opens, because two things genuinely belong to the orchestrator afterwards: integration, which is a write, and anything a worker handed up because it needed approval, which workers cannot request. A refusal rather than a hidden descriptor: removing tools mid-turn rewrites the stable prefix and drops the session's KV cache, while a refusal costs one tool result and reads as an instruction. Same trade plan mode makes, and the same one fusion.delegate already makes for a worker that calls it. Read-only tools, the fan-out itself and the terminal verbs are never gated — planning is reading, and vetoing reply would veto the turn's exit. The guidance is rewritten around the loop it now enforces: plan in the open with the parts sized, one task per part in one call, briefs that stand alone, read every reply against its brief, merge yourself, and send rework back out instead of quietly absorbing it. Same prefix budget as before (1344 of 1400 bytes). A worker's own turn is never gated: the flag is per turn and skips ephemeral ones, so the hands the mode exists to free stay free.
A first live run on a cheap auto-routed model read the files, replied 'no edits were needed', and changed nothing. Stopping after the plan is the cheapest way to satisfy 'plan in the open', so the line now says the delegation belongs to the same turn.
The loop built the per-turn fusion context and handed it to the step executor, which builds the batch context and forwards `isPlanMode` — and nothing else. The gate was live, unit-tested and never consulted: a driven run in fusion mode wrote all four files itself with zero refusals and zero workers. Forwarded the three fields the same way `isPlanMode` travels, together so a context can never carry the flag without the ledger behind it.
Remote operators could read and steer a session from a chat but never change what it ran on, so a bot on someone else's machine was pinned to whatever model that machine was last told to use. /model reports the active provider and every configured one; /model <provider> [model-id] switches. It writes the same state the TUI's LLM pane owns — the active text provider and its defaultChatModel in the user config, plus the per-session llm stamp — so there is no second, channel-local store to drift.
Reported on Discord: mercury-2.5 misforms tool calls without OpenAI's strict function schemas, and there was no way to ask for them. "strict" was already a legal supportsTools level, parsed out of config and carried through the model resolver, but nothing consumed it — setting it did nothing at all, and extraBody merges at the top level of the request body so it cannot reach a per-tool function.strict either. Strict schemas are far more restrictive than the descriptors this repo carries, so the conversion is per tool and refuses per tool: a mixed tools array is legal, while a whole-array flag would 400 every request the moment one tool does not fit.
The per-session NDJSON cap was enforced from the wrong end. On reaching `tracing.trace.maxBytesPerSession` (10 MB by default) the sink appended one `trace_truncated` marker, set `overflown`, and dropped every later event for that session — permanently, and across restarts, because `resolveState` re-derived `overflown` from the file size. A long session therefore kept a pristine record of its opening minutes and nothing at all about its end, which is where "what went wrong" lives. The cap is now honoured by dropping the OLDEST events instead of refusing new ones. When an append would cross it the sink rewrites the file keeping its tail: whole leading lines go until the file is at half the cap, a `trace_truncated` marker is left at the seam carrying `droppedEvents` / `droppedBytes`, and writing continues. A leading `session_started` row is preserved so `trace list` and `trace replay` still find the header. There is still exactly one file per session, so `trace show/export`, the debug bundle, the issue report and the eval harness need no changes. The rewrite goes through a temp file in the same directory and an atomic rename, so the trace is never missing or half-written; a trim that cannot be completed degrades to the old stop-writing behaviour and never throws into the caller. Halving on each trim bounds the cost to an amortised two bytes rewritten per byte traced, and a file already at the target is never rewritten — a single event bigger than the cap is dropped on its own instead of triggering a rewrite per event.
… fan-out Nobody should be picking the worker count from a list. It is two decisions and neither belongs to the operator: how many workers this machine can serve at once, which the machine knows, and how many a given job is worth splitting into, which the orchestrator knows once it has read the job. The machine half: `localModels.managed.parallel` takes `"auto"` and defaults to it (config v63). llama.cpp divides `--ctx-size` between `--parallel` slots, and a slot smaller than a worker's own prompt cannot serve one — so the count is how many times MIN_SLOT_CONTEXT (16k, the same figure as MIN_AUTO_CONTEXT) fits in the context the daemon is launched with, capped at 8, and 1 on a CPU-only launch where concurrent slots share the same cores. That context is already sized from VRAM by context-size.ts, so a bigger machine widens the pool with no new probe and nothing to configure. Resolved in daemon-lifecycle, where the effective context is known; both launch sites pass the config value through untouched. Migration: a pre-v63 file's `parallel: 2` is the schema's old default, not a choice anyone made, so it reads as `"auto"`. Any other number is someone's deliberate pin and is kept — as is a 2 written at v63 or later. The model half: a `fusion.delegate` call that names no `maxWorkers` now gets the machine's capacity instead of `llm.runMode.fusion.workers`. The pool notice was reworded to match — it names the slot count as where the number comes from rather than as a knob to go and raise. The composer's count rows are gone; its label reads "up to N workers", because N is the ceiling and not a promise about this turn. The worker MODEL rows stay: which model runs the workers is a real choice. `/runmode workers N` survives as the pin.
`loadImageFile` derived an image's MIME type from `extname()` alone, so whatever the uploader called the file decided what we told the provider it was. Every attachment that reaches the agent through a chat channel carries a client-chosen name: `attachmentBasename` in the channel inbox returns a sanitised filename unchanged whenever it already has an extension, and never looks at the content. A PNG screenshot sent from Telegram lands on disk as `photo.jpg`, `describeImageViaOpenAi` builds `data:image/jpeg;base64,<PNG bytes>`, and the provider answers with an opaque 400 that names neither the file nor the mismatch. Sniff the magic number instead: a small pure `sniffImageType` module (PNG, JPEG, GIF87a/89a, WebP) reading at most a 12-byte prefix, with the extension kept as the fallback. Bytes win when they identify a supported format; unrecognised bytes are "no opinion", not "not an image", so anything that describes fine today keeps describing fine. A supported image with no extension at all now loads instead of being rejected unread, and `UnsupportedImageFormatError` says both signals failed rather than blaming the extension. A disagreement is logged at warn on the load seam so the mismatch is visible without a repro. The channel inbox is deliberately untouched — one seam per change; the sniffer is standalone so it can be reused there later.
TelegramLockfile.release() unlinked the lock file whenever it existed, with no check that the PID inside belonged to this process. On the lock-conflict path that erases the wrong file: TelegramChannel.start() releases from its catch block, so the process that LOSES the acquire() race deletes the WINNER's lock on its way down. The winner keeps polling from memory, the file is gone, and the next process acquires "successfully" — two pollers on one bot token, stopped only by Telegram's own 409 Conflict, which is precisely what the lockfile exists to prevent. DiscordLockfile.release() has had the ownership guard all along; this brings the Telegram side in line. acquire()'s stale-lock reclaim, the error message, and the Discord implementation are untouched.
An inbound Telegram or Discord message reached `runtime.runTurn` as bare text. The model saw "restart the deploy" with nothing about who said it or in which channel. That was survivable while a channel had exactly one owner; Discord's `ownerUserIds` list made it a real gap — several people now drive one bot and the agent cannot tell them apart. Add a single-line `[from]` block, built the way `buildAttachmentUserMessage` already builds `[attachments]`: display name, platform, user id, chat id, and the topic id where the surface has one. Inclusion rule (deterministic, tested): always on Discord, which is multi-author by nature; on Telegram only in a group or supergroup. A Telegram DM reaches the runtime for the single configured `ownerUserId` only, so the line would restate a constant on every turn forever. Ordering: the identity line goes above the user text and above any `[attachments]` block — envelope first, attacker-controlled payload after, so there is exactly one `[from]` line and it is the first line of the turn. The display name is attacker-chosen text, so it is flattened to one line (control characters, newlines, bidi format characters and the Unicode line/paragraph separators all become spaces), emitted inside a quoted field with `"` and `\` escaped, and capped at 64 characters before escaping. A nickname therefore cannot start a line at all and cannot forge a second `[from]` line or an `[attachments]` block. Ids get a strict character allowlist — they arrive through a structural cast, not a validated one. Nothing outside the live channel message path changes: the TUI, tasks, webhooks and fusion workers are untouched, and so is the Telegram file path, which is private-chat-only.
Some models call tools reliably only when the provider constrains decoding to the tool's schema — OpenAI's strict mode. Until now there was no way to ask for it: `strict` is a field on each tool (`tools[].function.strict`), and `tools` sits in `RESERVED_BODY_KEYS` and is re-applied on top of the `extraBody` merge, so the vendor passthrough could never reach it. The dormant `supportsTools: "strict"` catalog level did not help either — nothing has ever read it. Adds `strictTools` to the provider entry (config v63, off by default, byte-identical request body when absent) and a schema transform that rewrites each tool into the subset strict mode accepts: objects closed, every property listed in `required` with optional ones widened to nullable, and the value-range keywords the runtime validators already enforce stripped. Tools whose arguments are a free-form map cannot be expressed strictly and are sent unconstrained rather than silently losing their payload. Nullable optionals change the response too — a strict model sends `"userName": null` where it used to omit the key, and several tools branch on presence rather than value. So on a provider that opted in, the tool-call adapter is wrapped to drop top-level null arguments, leaving the parsed call identical to what those tools saw before. A conformance test walks every descriptor the agent registers through the adapter and the transform and checks every strict rule recursively, so a new tool with an unsupported keyword fails here rather than as a 400 that kills the whole request. Reported on Discord by thegreatteacher (2026-09-10) against Inception Labs' Mercury 2.5.
…ters The word guard alone missed the spaced form of the same arithmetic: G_cont = G1 * G2 * G3, omega = 2 * pi * 5, Octave's A .* B .* C and SELECT * FROM t still lost their asterisks. A * followed by whitespace cannot open emphasis under CommonMark either, so require the body to start and end with a non-space character. Switch both rules from \w to Unicode letter/number classes. \w is ASCII-only in JS, so the guards did not apply to non-Latin prose: пи*2*пи was emphasised where pi*2*pi was not, and слово_это_слово lost its underscores where snake_case_name kept them. Refuse a candidate whose body does not close the tags it opens, so an <i> can never cross a <b>/<s>/<a> emitted by an earlier inline pass. Telegram answers crossing tags with a 400 on the whole sendMessage.
Mutation testing against the new suite: `release()` could swallow the read error and unlink anyway, or unlink whenever the pid failed to parse, and every test still passed. Both are the original bug wearing a different hat -- on POSIX, unlink permission comes from the directory, so an unreadable lock held by another user's atomic-agent would be swept away and its token handed to a second poller. - the not-throwing cases now also assert the file is still there; "did not throw" alone passes for a release() that deletes all of them - an unreadable (0o000) lock file must survive release(), skipped where file modes do not bite (root, Windows) - a read-only state dir pins the other half of the contract: the unlink can fail after a successful read and release() still says nothing - a fractional pid joins the junk-contents table All six mutations now fail at least one test (revert, drop the guard, flip === to !==, swallow the read error, unlink on NaN, rethrow from the catch). Telegram suite 243 passed. Also corrects the new class docstring: a file we leave behind is only reclaimed when its PID is dead. A PID the OS recycled onto an unrelated process keeps acquire() refusing until someone deletes the file, which is the trade this guard makes and DiscordLockfile already made.
Review fixups on the `/model` command. The mid-turn refusal only covered the issuing chat's own session, but `llm.activeTextProvider` is global and `resolveActiveLlmSlice` re-reads it per inference attempt — provider, tool transport and tool-call adapter with it. A `/model` from one chat therefore landed on every other in-flight turn at its *next step*, not its next turn, which is exactly the hazard the refusal existed to prevent. It now gates on `turnController.busySessionIds()` and names how many sessions are running so the operator can wait or `/cancel`. Also: - A failed provider reload no longer leaves a torn config. The model pin has to be written before the rebuild (the rebuild reads it off disk), so the pin — and the in-memory active provider — are rolled back before answering "Could not switch"; a later bare `/model` can no longer report a model that was refused. - `stampChatSession` moved inside a `catch`. It ran after the try block, so a `sessionStore.save` failure rejected the whole command *after* the config write had succeeded: no reply at all on Telegram, an unhandled rejection on Discord's `void this.onDispatch(...)`. - `/model` registered in Telegram's `setMyCommands`, so it appears in the command menu and autocomplete like every other verb. - A one-token argument starting with a slash (`/model /vendor/m-9`) is answered with the command's shape instead of "Unknown provider .". - Trailing arguments are refused rather than silently dropped. - Tests: the API-key environment variables are saved, cleared and restored per test, so a developer or CI with a real OPENROUTER_API_KEY exported no longer fails (or vacuously passes) the "no API key" cases. - Tests: the `openai-compat` fixture gains `baseUrl`/`defaultChatModel`, which the real registry requires, and a new test builds the fixture through `ProviderRegistry.fromConfig` so the one stubbed seam (`reloadLlmProviders`) can no longer hide a config production refuses.
The keyword allowlist bounded what a node may say; nothing bounded how
many of them there were. The built-in descriptors hid it — the deepest,
`os.shell.run`, nests two levels — but a third-party MCP `inputSchema`
is not so polite, and both failure modes are the one this module exists
to prevent:
* a schema nested past a strict compiler's ceiling is rejected with
the WHOLE request, taking every other tool's definition down with
it, which is exactly the outcome the per-tool refusal buys its way
out of;
* a self-referential schema ran the recursion into a `RangeError`
that escaped `descriptorsToOpenAiTools` and killed the step. It
cannot come off the wire, since MCP schemas arrive through
`JSON.parse`, but nothing here promised that.
Five levels is the conservative reading of the published ceiling, and
refusing deeper costs that one tool its strict marking and nothing else
— it ships exactly as it does today. Built-in coverage is unchanged at
77 of 82.
Also pins two refusals that no test held. `anyOf` beside a sibling
`type` was already declined and reads as a contradiction to a strict
compiler, so passing it through would emit a node no provider can
compile; inverting that check used to be a silent mutation. And AGENTS.md
now records that the null-drop is per request rather than per tool, so a
refused tool cannot express a literal top-level `null` while the level is
set.
…tes the read Typing an image from its bytes means `loadImageFile` opens every path the agent names. The extension check used to be what stopped it: a `.log` or an extension-less device path was rejected without ever being opened. With that gone, `readFile` runs first and the two unbounded cases became reachable from `vision.describe`: - `/dev/zero` (or any character device / FIFO): measured on this branch, `readFile` climbs past 2.8 GB RSS in 15 s and keeps going. On main the same path was rejected in 11 ms. - a large regular file: a 1.5 GB `install.log` allocated the full 1.5 GB in 3.1 s only to be rejected afterwards by the same `UnsupportedImageFormatError` main raised in under a millisecond. Front the read with a `stat`: reject anything that is not a regular file, and reject a file whose on-disk size already exceeds the caller's cap. `vision.describe` passes `config.vision.maxImageBytes` down as that cap, so an over-size image is refused from its size instead of being materialised and then thrown away — which also closes the pre-existing gap where the cap was only enforced after the whole file was in memory. The post-read byte-length check stays: it is the only thing that covers a file growing between the stat and the read. Both rejections carry their own error type so the tool surfaces the message as-is rather than wrapping it in `failed to load image: …`.
Two things checked against the code rather than against the prose. `SwarmRegistry.startEnabled()` filters on `enabled && token !== null`, so an enabled swarm unit with no token is silently skipped. "every enabled swarm bot" promised more than serve delivers; it now says "that has a token", in the README and in `serve --help`. `atomic-agent --help` still described serve as an HTTP API only. That top-level line is the one people read before they ever run `serve --help`, so leaving it stale keeps the discovery gap this change set out to close. Reworded to name the channels. Also reflows the help paragraph so no line trails a two-word orphan.
Review fixups on top of the empty-completion recovery. A recovery that "spends a step" is a promise of another inference. On the last step of a leg that has produced nothing usable that promise was false: the recovery burnt the step, the `no_progress` boundary broke out of the loop before `executeStep` ran again, and the operator read "trying again (1/1)" for a try that never happened — then got "ran out of steps" instead of the model's own diagnosis, with the ModelError dropped and no `loop_failed`, so the failure never reached error reporting either. `recoveryStepAvailable` gates both recoveries on a step actually being left; the parse path had the identical hazard and gets the same guard. The empty-completion budget is now per RUN of consecutive empties rather than per turn: any completion that carried something — a step that ran, a body that failed to parse, a reply the server cut short — resets the count. A dead link still buys exactly one retry for the whole turn (nothing resets it), while a model that answered a step and then went quiet gets its own nudge instead of being denied one on the strength of an empty completion a dozen working tool calls ago. It is also what makes the terminal message's "twice in a row" true, which it was not before. Tests for the three rendering branches the previous commit shipped uncovered: the TUI reducer line, the trace recorder payload and the `trace show` formatter row. Each was mutation-checked (line replaced, payload fields set to -999, formatter case replaced) and each mutation fails the new tests. Docs corrected where they overclaimed: the rewritten message does NOT make the doubled empty distinguishable in Sentry — the scrubber never transmits a message and `pickFrames` keeps the cause's stack, which is deliberate (same defect, same cluster). The trace is where the two are told apart.
Review follow-ups on the `[from]` identity line. - `sanitizeDisplayName` truncated with `String.slice`, which counts UTF-16 units: a 64-unit boundary landing inside a surrogate pair left a lone surrogate in the prompt. That is not valid UTF-8, so it turns into U+FFFD the first time the turn is encoded for a provider or written to the session store. Cut on code points instead, which also makes the cap mean 64 characters for an astral name rather than 32. - The cap/escape ordering had no test that could tell the two orders apart — the existing one asserted `line.length < 200`, which holds either way. Pin the exact output so reversing the order fails. - Three comments claimed more than the code delivers: that the cap bounds the rendered field (escaping doubles it), that a malformed id is dropped whole (its bad characters are stripped and the rest is spliced), and that there is exactly one `[from]` line in a message (only the name is escaped — a message body or a failed attachment's filename still renders a line-anchored `[from]` below the envelope, from any account on the owner allowlist). State the real boundary, and pin the last one with a test so it is a documented contract rather than a surprise. - Cover C1 NEL (U+0085) and the C0 file/group/record separators in the injection table. No behaviour change beyond the surrogate fix.
Review follow-ups on the head-trimming sink. The cost bound the trim relies on was not enforced. `bytesWritten <= target` skips a rewrite only when the file can reach the target, and a trim cannot always get there: a preserved `session_started` plus the marker is irreducible. When that floor sits above the target the guard never fires and every single event pays a whole-file read + write + rename. Measured on a 1.4 KB header at a 2 KB cap: 54 rewrites over 59 events; at a 256-byte cap, 40 over 40. Two changes fix it — the header is only preserved while it is at most half the target (it exists to introduce a tail, not to crowd it out), and the size a trim actually produced is remembered so a file already at its floor is left alone. Same measurements after: 8 over 59, and 2 over 40 — with more of the session on disk, not less. Also: - flush the temp file before renaming it. `rename` is atomic for the directory entry, not for the data behind it, so a power cut after the rename could leave the trace pointing at an unwritten extent — losing the whole file rather than half of it. - sweep temp files a crashed trim stranded. A process killed between the write and the rename leaks up to half a cap of unredacted trace content under a name no reader lists, one per crash, forever. Only temps whose owning pid is gone are removed. - count a file's unterminated last line as a dropped event, so `droppedEvents` still adds up on a trace another process left mid-append. Tests. Every cap test ran at 600 bytes, where the target (300) is below `MARKER_BUDGET_BYTES` — the trim degenerates to "wipe everything but the header" and no surviving tail is ever cut, so the newline rounding was never executed. Cutting mid-line passed the whole suite. Added coverage at caps where a real tail survives (asserting each surviving row is byte-identical to the event that was emitted), for the rewrite bounds above, for the temp sweep, and for the marker's seq and the unterminated-line count. The `trace_truncated` marker no longer means "the trace stops here", so the eval postmortem's rendered line and two stale comments that still described the old cap behaviour are corrected.
…ections
Review fixes on the strict tool schemas.
The null drop was gated on the batch-level flag while the conversion is
per tool, so a tool whose schema was REFUSED — shipped byte-identical to
the flag-off payload — also had its top-level nulls deleted. For a
third-party MCP tool with a required ["string","null"] argument the model
sends null because the tool's own schema asked for it, and the server
received a call missing a required key. The adapter now reports the
escaped names it actually marked strict (strictToolNames) and the batch
side undoes the padding for exactly those, so "the refusals ship exactly
as they do with the flag off" is true on the way in as well as out.
The converter also refused the two standard spellings of "nullable", so
it was not idempotent and rejected nearly every pydantic/FastMCP schema:
type: ["string","null"], an anyOf with a {type:"null"} branch and a bare
{type:"null"} are now accepted and never widened twice, and the
annotations default/$schema/$comment are accepted and dropped (a strict
decode has no absent key for a default to fill; the null is deleted again
on the way in, so the server applies its own default as it does today).
$defs/$ref stays refused.
An absent additionalProperties was treated as false, inverting the JSON
Schema default: an MCP object the server left open was closed and still
marked strict, and a zero-property MCP tool was published as a
zero-argument tool. It is now refused unless the schema says false
outright, which costs the built-ins nothing — default-tool-args-schemas
spells it out on every object, and coverage is unchanged at 77 of 82
registered schemas, 76 of 82 emitted functions.
A property named __proto__ was silently dropped (out[name] = ... on an
object literal sets the prototype), leaving a function marked strict
whose schema forbade an argument the tool declares; properties are built
with Object.fromEntries now.
Tests: the per-tool gate end to end in step-executor (a converted tool's
forced null is dropped, a refused tool's is not), the three nullable
spellings, idempotence over every converted built-in schema, the dropped
annotations, __proto__, and the open-object refusal. Each was checked to
fail against the pre-fix behaviour.
AGENTS.md: corrected 77-of-82 (registry) vs 76-of-82 (payload, reply
overridden by its hand-tuned minLength schema), named the widened-enum
shape (18 properties) as the likeliest first 400 rather than the anyOf
branch (2), and documented the MCP coverage rules.
It also supersedes the note added one commit earlier that the drop is
per request "because carrying the strict-marked names from the request
builder to the parser is more plumbing than the case is worth": the
plumbing is one optional adapter method and one argument, and the case
is a live MCP tool.
…executes Fusion assumed its pairing: cloud orchestrator, local workers. That is the right default and the economics the mode was built for, but it is not the only sensible pairing. Cheap local planning driving capable cloud executors is a real use case, and so is a big local model orchestrating a small one. The kinds were baked into three places; none of them had to be. - The resolver's worker default still prefers a local provider, but only as a default — a pinned leg is honoured whatever its kind, and the fallback is now "any provider that is not the other leg" rather than "a llama-server or nothing". - setMode no longer forces the orchestrator onto a cloud provider. An explicit pin wins; without one the order is the active provider, then the first usable cloud one. It picks the second leg the same way and refuses only when there is no second provider to pick. - The pre-flight counts usable legs instead of requiring one of each: a keyed cloud row can answer, a local row with something on disk can answer, and fusion needs two of those. - `no-local-provider` becomes `no-second-provider`, and its sentence stops prescribing a kind. In the composer the two controls are now the two slots. The provider control is the orchestrator: cloud rows as before, plus `local-llama` when a model is on disk. The workers control is the second slot: the models on disk, plus every cloud provider that is not already orchestrating — fanning out to the model doing the orchestrating buys nothing and doubles the bill. Picking a local model for the workers also claims the slot for the local leg, so the pick cannot be quietly ignored by a pin left on a cloud provider. The `### fusion` machine line only states slot counts when the local leg is actually running the workers; with cloud workers there is no slot pool to describe, and the idle daemon's number would be about the wrong machine.
Strict decoding and parallel function calls do not compose. OpenAI's
own guidance is explicit: "Structured Outputs is not compatible with
parallel function calls — when a parallel function call is generated,
it may not match supplied schemas. Set `parallel_tool_calls: false`."
`buildOpenAiChatBody` defaulted `parallel_tool_calls` to `true`
(`agent.maxParallelToolCalls` is 8 out of the box), so a provider that
opted into `strictTools` was emitting `strict: true` on 80 tools and
still getting best-effort adherence — the exact symptom the flag
exists to cure. Under the flag the wire now asks for one call per
response; the executor's own batching is untouched, and a provider
without the flag keeps today's value verbatim.
Also corrects two claims in the transform's header that do not hold:
* `os.git.init` is cited as a tool an explicit null would break, but
its `optionalString` maps null to undefined before the presence
check. `memory.profile.set` is the real case — `parseSetOptions`
gates on `!== undefined` and then demands a boolean, so a null
turns a good call into a validation error. Cited that instead.
* "no schema this transform converts turns a nested optional into a
null the caller did not choose" is false: five do
(`os.fs.archive.extract.limits.*`, `fusion.delegate.tasks[].*`).
All five readers happen to treat null as their default, so nothing
is broken today; the comment now names them, and names the other
place the drop does not reach — step-executor's two content
recovery paths build a batch from the grammar parser and use the
adapter for `nameUnescape` alone.
Mutation-testing the branch found four edits that removed the feature end to end and left all 936 tests green: dropping `this.strictTools` from either `buildOpenAiChatBody` call site in `OpenAiProvider`, not wrapping the tool-call adapter, and deleting `strictTools: entry.strictTools` from all five OpenAI-shaped factories. The transform, the body builder and the config parser were each well covered; the wiring between them was not covered at all. Adds the two observable ends. `openai-provider.test.ts` asserts the posted body — streaming and not — carries `function.strict` with the rewritten schema and `parallel_tool_calls: false`, is byte-identical with the flag absent or false, and that the wrapped adapter drops a top-level null (`memory.profile.set.pinned`) while the unwrapped one keeps it. `cloud-passthroughs.test.ts` gets the `strictTools` row alongside `maxOutputTokens` and `extraBody`, which is what that file exists for. Each of the four mutations now fails at least one test.
The "in a row" semantics have three reset sites and only one of them — the success path — was pinned by a test. Deleting either of the other two, in the truncation-retry branch and in the parse-recovery branch, left `npx vitest run src/agent src/tui src/tracing` entirely green, so the claim that consecutiveness is enforced rather than assumed was two-thirds unverified. These two are also the risky half: they are the resets that can RAISE the number of wasted inferences in a turn. Two loop tests, one per site: an empty, then a completion of that shape, then a second empty that gets its own retry only because the shape in between cleared the count. Both fail against `origin/main`, and each fails when only its own `emptyRecoveries = 0` is deleted — parse-branch deletion fails only the "rejected completion" test, truncation-branch deletion only the "cut reply" one. Reaching a loop-level parse failure on `native_tools` needs bad tool-call arguments twice: plain `content` is wrapped as a `reply` by the transport fallback, and the first bad batch is consumed by the step's own one-shot repair.
…ng required nulls
Four defects found re-reviewing the branch, each reproduced before it
was fixed.
Tagged-tool providers. A `qwen-openai-compatible` link answers with
`<tool_call>` prose that we decode ourselves; no strict decoder is
involved, so nothing stops the model omitting an optional. But
`indexOfferedTools` read the strict payload's `required` literally —
and the rewrite puts EVERY property there — so `coerceArguments`
rejected every realistic tagged call for a missing parameter,
`parseSource` returned null, and the tool call collapsed into prose.
Turning the level on made tool calling strictly worse for that
operator, which is the outcome this design says it refuses. The
decoder now reads the strict spelling the way strict means it: a
listed property that admits `null` is optional. That costs one
presence check the tool's own validator makes again, and it holds on
whichever link the fallback chain serves, not just the configured one.
The null-drop was keyed per TOOL while the rewrite is per PROPERTY. An
argument that was already `required` is emitted byte-identical —
`z.string().nullable()` through the MCP SDK is `anyOf: [{string},
{null}]` listed in `required` — yet its null was still deleted because
some other property on the same tool got widened, handing that server
a call missing a required field. `strictToolNames` becomes
`strictWidenedArgs`: each rewritten function mapped to the arguments
whose optionality the rewrite actually erased. Both new tests fail
without the change.
The nesting bound did not reach the `anyOf` path, which recursed at
the caller's depth. A self-referential union still overflowed the
stack out of `descriptorsToOpenAiTools` — the exact failure the bound
was added for — and an arbitrarily deep union chain still converted.
Every emitted node is a spread of the node that came in, so an
allowlisted keyword the node's SHAPE has no rule for rode out
unconverted into a schema marked strict: `enum` on an object emitted
its raw sub-objects verbatim, and `items` on an object (or the object
keywords on an array, or any of them on a union) were the same bug
with different keys. Each shape now refuses its own strays, as
`convertScalar` always did.
Also: `modelWantsStrictTools` — the only path from the operator's
config to the wire — moves out of `bootstrap.ts` into
`model-strict-tools.ts` with direct tests, since reverting it left
every runtime test green; the nine `github-tool-args-schemas.ts`
entries join the pinned coverage sample (82 schemas, 77 convert, 76
emitted functions strict); and both directions of one inference share
a single build instead of converting all ~80 schemas twice.
Flag off is still byte-identical to the previous payload, checked
against origin/main's adapter.
The previous commit moved the emphasis word guard from `\w` to
`[\p{L}\p{N}_]` so it would apply to Cyrillic prose. CJK ideographs and
Hangul are `\p{L}` too, and Chinese, Japanese, Korean and Thai are
written without spaces between words, so in those scripts *every*
emphasis run is flanked by letters: the guard stopped being a heuristic
that tells arithmetic from emphasis and became a blanket disable of
single-`*` italics for the whole language. `这是*重点*内容` came out
with the asterisks visible where it used to render `<i>`, and where
CommonMark renders `<em>`. `**bold**` was unaffected, so the loss was
specific to italics.
Exempt those scripts from the `*` word guard. The cost on the
arithmetic side is small, because the guard only ever inspects the
character immediately beside the delimiter and real code puts an ASCII
operand there — `这是 20*log10(abs(15-1*25)) 的结果` is still literal.
`_` is deliberately not exempted: CommonMark forbids intraword `_` in
every script, CJK included.
Two smaller corrections in the same rule:
- Count `\p{M}` as a word character. A combining mark is part of the
word it attaches to, and without it the keycap `1️⃣*x*` opened
emphasis (U+20E3 is a mark, not a digit) where the bare `1*x*` did
not.
- Refuse a candidate whose delimiter sits inside a tag an earlier pass
emitted. `tagsBalanced` could not see this case: a `*` inside a URL
splits `<a href="…">` down the middle, leaving no complete tag in the
body to be unbalanced, so `*[a](http://x/*)*` was rendered as
`<i><a href="http://x/</i>">a</a>*` — an orphan `</a>` that
Telegram answers with a 400. That one reproduces on main too; the
comment claiming an emitted tag can never be crossed was wrong, and
now both the claim and the behaviour match.
Measured on the same sweeps as before: 0 ill-formed outputs over the
2,015,538 strings from `* _ space a b c` up to length 8 and over
`* _ [ ] ( ) : a` up to length 6; ill-formed count equal to main on an
alphabet containing raw `<`/`>` (3,584 either way, all from the
pre-existing `escapeNonTagText` hole); and still no input that is
emphasised here but left literal by main, so the rule remains a strict
narrowing of shipped behaviour.
…ode change Second review round on #394. Four substantive findings. 1. `/model local-llama <model-id>` was accepted, changed no inference, and permanently renamed the model everywhere with no chat-side undo. The `llama-server` factory never reads `entry.defaultChatModel` (register-built-in-providers.ts), but `resolveActiveModelName()` in bootstrap.ts reads it FIRST — ahead of `localModels.managed.modelId` — so the pin fed the report, the pricing key and every `message_sent` event while the daemon kept serving its own GGUF. The TUI cannot reach that state (`openChatModelPicker` / `ensureInlineModels` refuse non-cloud kinds); now neither can a chat. `MODEL_PIN_IGNORED_KINDS` refuses the pin and points at the Local Models tab. 2. `/model <provider>` could drop a fusion deployment out of fusion — removing `fusion.delegate` and the `### fusion` guidance from every session and invalidating every KV prefix — and answer "Now on local-llama." Leaving fusion by switching provider is by design (`resolveRunMode` derives the effective mode from `activeTextProvider`); having no way to see it is not. The bare report now carries a `Run mode:` line built from the same `describeRunMode` the TUI's chip uses, and a switch that crosses into or out of fusion says so, naming the provider that restores it. Local <-> Cloud stays silent: the reply already names the provider. 3. On the default local-first install, bare `/model` answered "provider default" for a managed daemon whose GGUF id the config knows. `displayModelOf` now falls back to `localModels.managed.modelId` for a `llama-server` entry — the same first legs, in the same order, as `resolveActiveModelName()`. Display only: the session stamp keeps using the config pin, because a stamp carrying a model on a llama-server entry would make the TUI's session restore write it into `defaultChatModel` on reopen (session-model-restore.ts) — the exact poisoning (1) prevents. 4. The API-key refusal and the report's flag were keyed on provider kind alone, so known-service presets (Groq, Nous, Anthropic — all stored as `openai-compatible` with their own `apiKeyEnvVar`) were shown as usable and switched to with the variable unset, after which every turn 401s silently. An entry that declares an `apiKeyEnvVar` has said it needs a key; a bare keyless compat entry (LM Studio, Ollama) has not, and keeps working. The refusal and the report name the variable. `apiKeyEnvVar` was missing from `AtomicAgentConfig.llm.providers[]` although `parseLlmProviders` has always carried it — added. Plus the two nits: the Discord suite gains the one-token `<provider>/<model-id>` case it was missing, so the two files are the symmetric pair they claim to be (25 tests each), and `restoreProviderDefaultChatModelInConfig` gains direct tests in the file that owns it, including the unset branch `setProviderDefaultChatModel` cannot express. Tests: 7 of the 9 new Telegram assertions fail against this branch's own src (verified by reverting model-command.ts); the two that pass either way are deliberate controls — a preset with its key set must still switch, and an ordinary Local -> Cloud switch must stay quiet.
The previous commit added `\p{M}` to the emphasis word guard so the
keycap `1️⃣*x*` would behave like the bare `1*x*`. VARIATION
SELECTOR-16 is `\p{M}` too, and it is what turns a bare symbol into an
emoji, so the guard silently stopped single-`*` and `_` italics after
every emoji spelled with one: `⚠️ *Do not* run this` came out with the
asterisks visible where `⚠*Do not*` rendered `<i>`, and where both
`origin/main` and the two earlier commits on this branch rendered
`<i>`. Over U+2000–U+2BFF that is 2,791 of 3,072 code points losing
italics the moment the selector is appended — ⚠️ ✔️ ⭐️ ❤️ ▶️ ℹ️ ❗️ ⏱️
among them.
Accept a variation selector as the character before an opening
delimiter, and only there. Before an opening delimiter the selector
belongs to whatever precedes it, and judging the run by the selector
instead of by its base is the bug. After a closing delimiter it
belongs to the delimiter itself — `*` is an emoji base, so in `*x*️⃣`
the trailing `*️⃣` is a keycap, and reading it as a closing delimiter
would emit `<i>x</i>️⃣` and delete the `*` out of an emoji. There the
mark keeps blocking.
U+20E3 COMBINING ENCLOSING KEYCAP is not a variation selector, so
`1️⃣*x*` stays literal; combining marks that really are part of a word
still block, which the two new negative pins cover (Devanagari `की*x*`
and decomposed `é*x*` — both regress if `\p{M}` is simply dropped).
Sweeps re-run: over `* _ space a b c` to length 8 (2,015,539 strings),
`* _ space a 这 点` to length 7, `* _ [ ] ( ) : space a` to length 6 and
`* _ < > space a b` to length 6, output is byte-identical to the
previous commit — the change touches nothing on ASCII marker soup — and
ill-formed counts are unchanged (0, 0, 0, and 3,584 equal to main from
the pre-existing raw-`<` hole). On an alphabet containing `⚠️ ` and
`1️⃣` it differs from the previous commit on 105 of 9,331 strings, all
of them in the looser direction and none of them emphasising anything
`origin/main` leaves literal, so the rule is still a strict narrowing
of shipped behaviour.
Also correct the residual-risk comment. It named only the user-typed
raw `<` as the remaining source of malformed output; `renderBold` and
`renderStrikethrough` run after `renderLinks` with no `tagMask` of
their own, so `**` and `~~` can still cross an emitted anchor —
`convert("[**](tg:*)**")` is `<a href="tg:*"><b></a></b>` with no `<`
typed anywhere. Pre-existing and identical on main; the comment now
says so.
Third review round on #394. 1. The "never throws" contract was still not held. The post-switch config re-read was guarded, but the *entry* `getConfig()` / `resolveLlmConfig()` was outside every guard — and that is the read most likely to fail, because `/model`'s own `setActiveTextProviderInConfig` calls `resetConfigCache()`, so every invocation goes back to disk. A hand-edited or torn config therefore rejected the whole command: no reply at all on Telegram, an unhandled rejection on Discord's bare `void this.onDispatch(...)`. Reproduced with `llm.activeTextProvider` naming an entry that is not in `llm.providers`. The guarantee is now enforced in one place — `runModelCommand` wraps the whole body — rather than promised leg by leg; the legs with something better to say still catch first. 2. The managed-model fallback in `displayModelOf` was applied to every `llama-server` entry, not the active one. Its justification is parity with `resolveActiveModelName()`, and that only holds for the active provider — `bootstrap` computes that name for `activeTextProvider` alone, while the report walks the whole list. `localModels.managed` describes one daemon, so a second local entry (a box on the LAN; `resolve-run-mode.ts` picks the worker leg with a `find`, so more than one is expected) was reported as running a GGUF it has never seen. A non-active local entry now reads "provider default", which is what the config actually says about it. The `localModels.mode` leg is deliberately left alone: gating on it would make the channel disagree with the model name in every `message_sent` event and in the cost lookup. 3. The report enumerated every configured provider with no cap and no clip. Discord splits a message at 2000 characters and Telegram at 4096, so an install with a dozen `openai-compatible` entries turned one answer into several messages — and on Telegram a chunk a second 429 drops is simply lost. Sixty providers produced a 3962-character Discord report. The enumeration is now fitted to a budget under the tighter of the two limits, the overflow is counted rather than printed, and the active entry's line is budgeted up front so the cap can never drop the provider the report is about. Unbounded names — model ids, `apiKeyEnvVar`, the typed token — are clipped; provider ids are not, because `PROVIDER_ID_RE` already caps them at 32 characters. The `Configured:` list in the unknown-provider refusal is capped the same way. Plus the nit: the unreachable `return head` in `runModeChangeNote` is gone, the fusion branch being the only shape the two guards above can leave. Tests: 5 new cases per channel file (30 each, still symmetric). All 10 fail against this branch's own src with `model-command.ts` reverted — the config-parse case fails by rejecting `handleInboundText` outright, which is the defect.
…ocal one Driving the swap on a built binary: pick `local-llama` for the orchestrator and the strip read `local-llama ⇄ qwen-3.5-4b · up to 2 workers` while the config said the workers were openrouter. Both halves of the right-hand side were describing the local daemon regardless of which leg the workers were on — the same class of lie as the chip that said fusion while the runtime was cloud. Three places, one cause each: - `resolveRunMode` filled `workerModel` from `managedModelId` unconditionally. That is the model the LOCAL daemon serves, so it now only answers for a local worker leg; a cloud leg reports its own default chat model. - The strip's worker half read the local models panel directly. It now labels from the worker leg's own provider row, the way the orchestrator half already did. - `up to N workers` is llama-server's request-slot count, which a cloud leg does not have. It reads `cloud workers` there instead of quoting a number about an idle daemon.
…andidates
Two of the three answers that interpolate a name the config schema does
not bound were left uncapped by the previous round, and both are the
*likeliest* to carry a long one, because the operator has just typed it:
- the switch confirmation ("Now on X · <model>"), and
- the llama-server pin refusal ("Pinning <model> would rename it …").
A Discord-legal 2000-character `/model openrouter <1982 m's>` answered in
2041 characters and `/model local-llama <1981 m's>` in 2352, both past
`DISCORD_MESSAGE_LIMIT`, so `DiscordApi.sendMessage` chunked each into
two. A third path had the same hole: `resolveTarget`'s no-API-key refusal
printed `apiKeyEnvVar` whole, while `providerLine` clips it. All three now
go through `clipName`, so the 48-character rule holds everywhere a name is
printed. The value written to the config is untouched — clipping is a
display concern, and a test asserts the stored pin is still the whole id.
The other half is a regression this round introduced. `joinIds`' flat
twelve-entry cap was right for the unknown-provider `Configured:` list,
which is orientation, but wrong for the ambiguous-prefix refusal, whose
entire job is "say which one": on an install with sixty `compat-*`
providers it answered "compat matches compat-provider-0 … and 48 more —
say which one", hiding the very information the sentence asks the operator
to act on, and buying nothing — the full list was 1220 characters on
Telegram and 1342 on Discord, inside both limits. Ambiguous candidates are
now fitted to a character budget instead: everything that fits is printed,
and only an install past one message's worth gets a count, together with
the one thing that shortens the list from a chat.
Five new cases per channel, symmetric across both surfaces.
OpenAI documents Structured Outputs as not compatible with parallel function calls — a parallel call generated under strict mode "may not match supplied schemas" — and says to send `parallel_tool_calls: false`. This branch marked every convertible tool `strict: true` and left the flag at its default `true`, so an operator who turned the level on still got best-effort adherence: the exact symptom the feature exists to cure. Found by the parallel work on PR #402, which fixed it at the body builder off its own provider flag. Implemented here off the emitted tools array instead, because strict is granted per tool: an adapter can ignore the option and a descriptor set can convert nothing, and neither should silently lose parallel calls for a request that is not constrained at all. `hasStrictFunctionTools` is the one predicate, read in two places that therefore cannot disagree — `buildLlmStreamParams` makes the decision, so the CompletionRequest is honest about it, and `buildOpenAiChatBody` is the floor under every other caller of the body builder. The executor's own `maxParallelToolCalls` batching is untouched: a model that emits several calls anyway is planned and run exactly as before.
76 of the 82 emitted functions carried `strict: true`. Three of the six refusals were over bounds the strict compiler ignores anyway — `reply` (`minLength: 1`), `vision.describe` (`maxItems`), `fusion.delegate` (`minItems`/`maxItems`/`minimum`) — so the refusal bought nothing: the tool came out unconstrained AND unbounded instead of constrained and unbounded, and `reply` is the one tool most worth constraining. So a value-range keyword is now stripped rather than refused over. It is safe for the reason `default-tool-args-schemas.ts` gives in its own header: these schemas guard shape, and every bound they carry is re-checked by the tool's own parser, which is what rejects a bad call today — checked one by one for the three recovered (the batch validator's non-empty `text` rule, `maxImagesPerCall`, `parseDelegateArgs`). `const` is deliberately not on the list: it pins a value the way a one-member `enum` does, so dropping it would widen what the tool accepts, and it stays a refusal. The fourth, `os.fs.archive.extract`, was refused over a nested `limits` that declares properties AND says `additionalProperties: true`. An object in that shape is now closed: the author wrote both halves, the declared keys are the whole documented contract, the model is shown nothing else, and `parseLimits` reads exactly those three keys. An ABSENT `additionalProperties` still refuses — same semantics, but it is what pydantic/FastMCP emit for every model, so refusing there keeps this rule to schemas somebody actually typed `true` into. A zero-property object refuses either way, so the open-object fallback is still never published as a zero-argument tool. Coverage measured over the real DEFAULT_TOOL_DESCRIPTORS: 80 of 82 emitted functions strict, refusing only `os.http.request` and `mcp.prompt.get`, where the map IS the payload. Both counts are now pinned — the registry pin alone would have missed `reply`, whose schema the adapter hand-writes. Nothing about the null-drop bookkeeping moves: `strictWidenedProperties` reads the original schema's `properties` and `required` and nothing else, and a bound says nothing about either. Pinned as its own test. The coverage idea came from the parallel work on PR #402.
Reverts 99faa2d. That commit was an optional coverage improvement — 76 to 80 of the 82 emitted functions — taken on the condition that it was low-risk. It was not. Stripping bounds made exactly two more built-ins convert, `fusion.delegate` and `os.fs.archive.extract`, and those two are the only built-ins whose strict schema contains a NESTED object (`tasks[]`, `limits`). `indexOfferedTools` — the fix two commits back that stops a strict payload's inflated `required` from rejecting every realistic `<tool_call>` — rewrites the TOP-LEVEL `required` and nothing else. So on a `qwen-openai-compatible` link with the level on, a `fusion.delegate` fan-out written the way a model actually writes one (no `deliverable`, no `files` on every task) failed the offered- tool check and the whole call collapsed back into prose. That is the precise failure the earlier commit exists to prevent, re-opened one level down. Teaching `indexOfferedTools` to recurse into nested objects and array items is the real fix and a reasonable change on its own. It is not this branch's change: this chain has now had a fixup introduce a new problem in four consecutive rounds, and trading a verified-sound converter for four more tools is not a trade worth making twice. Coverage returns to 77 of 82 registered schemas and 76 of 82 emitted functions, refusing `fusion.delegate`, `vision.describe`, `os.http.request`, `mcp.prompt.get`, `os.fs.archive.extract` and `reply`. `parallel_tool_calls: false` (fed385a) is untouched — it is a separate correctness port and nothing here implicates it. The premise both top-level walks rest on — no schema we convert has a nested object — is now pinned over the real emitted payload instead of being asserted in a comment, so the next attempt at this fails in the suite rather than on somebody's tagged link. `dropNullArgs`' header says whose premise it is and what a future nesting built-in owes both readers.
Drops the stripped-bounds and `canClose` paragraphs, restores the coverage and widened-enum counts to what the branch actually emits (77 of 82 registered / 76 of 82 emitted, 18 widened enums), and says plainly that nested nulls are left alone because nothing convertible nests, not because nesting is handled — the same premise the tagged- call narrowing walks on, now pinned. Adds the contract probe to "what this does not cover": `run-contract-probe.ts` builds its own body and sends one unmarked tool, so no preflight has ever put a `strict` function, a widened enum or the `parallel_tool_calls` floor in front of a real endpoint. That is where a first 400 would be cheapest to catch.
The strict-tool-schema section stated the premise the nested-null undo and the tagged-call narrowing both rest on as a universal: "no schema this converts contains a nested object". That is true of the built-ins, which is what the pin walks, and false of a third-party MCP inputSchema, which is exactly the class the allowlist was widened to accept. On a qwen-openai-compatible link such a schema converts, its nested required is inflated, indexOfferedTools narrows only the top level, and the tagged call collapses into prose - the same failure the narrowing exists to prevent, one level down. Scope both sentences to the built-ins, say which walk covers what, and name the MCP nesting case in "what this does not cover" alongside the fallback chain and the contract probe. Recursing indexOfferedTools and dropNullArgs together is the change that closes it, and is also what would let the tools refused over bounds be recovered safely. No code change.
It read `up to 2 workers`, and on a narrow terminal it collided with the steer hint — `up to 2 work⏎ steer`. The columns bought nothing: the number is the machine's capacity, not a choice anyone makes, and how many workers a turn actually spends is the orchestrator's call on that turn. Both legs are already named by the model segment (`A ⇄ B`). The worker slot itself stays: it is still one ←/→ away inside the composer popup, which is where the ←/→ walk has always included it. Removed the whole prop chain rather than passing null through it — prompt-shell, prompt-meta-bar, ComposerMetaControls and the selector.
Two v63s and two strict-tools paths met here. The slot-count change took v63 first, so #402's provider flag is renumbered v64. In the request builder the two mechanisms are one: the provider flag transforms the tool array, and the parallel_tool_calls floor is keyed to the array that actually goes on the wire — which covers both the flag and a caller that marked tools strict itself. Both test suites kept.
`download-spawn > spawns a detached copy…` failed twice in CI this week
on unrelated branches, each time with
expected { version: 1, …(17) } to match object { pid: 777, status: 'interrupted' }
The fake spawn reported pid 777 and the assertion wanted the seeded
record to read back `interrupted` — which is `readDownloadJob`
reclassifying a `running` record whose pid is gone. So the test was
asserting that pid 777 does not exist. On a laptop it does not; on a
busy CI container it does, and then the record comes back `running`.
The file already had `DEAD_PID` (2,000,000,000) for exactly this, above
the ceiling Linux hands out. The fake spawn now reports that.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The tested integration of every open PR: #390, #391, #392–#397, #398, #399–#402, #403, #404.
Merged as one branch rather than fifteen times into main, because two pairs genuinely collide and the resolution is what was tested:
USER_CONFIG_VERSION = 63. The auto slot-count (feat(fusion): the machine sizes the worker pool, the model sizes each fan-out #398) andstrictTools(feat(llm): opt-in strict function tools for OpenAI-compatible providers #402) both took v63 on their own branches. Slots keep 63; the provider flag is renumbered v64, with the note in the version chain saying so.parallel_tool_calls: falsewhen the tool array already carriesstrict: true; feat(llm): opt-in strict function tools for OpenAI-compatible providers #402 turns tools strict from a provider flag. Unioned: the flag transforms the array, and the parallel floor is keyed to the array that actually goes on the wire — so both the flag and a caller that marked tools itself are covered. Both test suites kept.What is in it
/modelin Telegram and Discord, strict tool schemas,servedocs, trace keeps its tail, image type from bytes, Telegram lock-release guard, sender identity, strict-tools provider flagparallel: "auto"), the model sizes each fan-outdownload-spawnstops betting that pid 777 is deadVerified
npx tsc --noEmitclean. Full suite on this branch: 9383 passed, 3 skipped, 1 failed —src/sidecar/send-message-concurrency.test.ts, which fails identically on v0.5.6 on the same machine and passes in CI. Every one of the fifteen PRs is green in its own CI.Driven against the built binary, not just the sources: render paint map 33/33 rows preserved across a session swap (was 33 → 1), fusion's legs swap from the composer and both halves of the route line name the models that actually run, and the app relaunches on that swapped config instead of dying in config validation.