feat: release AgencyZero on UI 3.2 and WorkTable 1.10 beta - #211
Conversation
Every Settings switch lies about its setting on the 0.3 engine. `checked` is an
HTML boolean attribute, so the `checked="false"` that a controlled component
writes reads as checked, and the property never reached the input's live state
at all. A setting stored `false` comes back reporting on after a restart, and
only agrees with itself once someone clicks it.
Measured on a build against the fixed engine, same disposable profile either
way:
stored blitzDeepProfilingEnabled: false
0.3: switch ... "visible,selected"
0.4: switch ... visible
`tests/ps-qa/06-toggles.ron` could not see it. `ValueChanges` is satisfied by the
renderer flipping the input natively, whether or not the application hears about
it, and every toggle check presses first and asserts a delta, which is exactly
what a wrong initial value survives. A check comparing a control against its
persisted setting without pressing it is what would have caught this.
The full native suite is 310/310 on the fixed engine.
Pinned at 0.4.3 rather than 0.4: 0.4.2 is a newer engine without the fixes.
The release workflow declines to publish when the committed version equals the live one, so the renderer fix would merge and reach no user. Every Settings switch on 0.8.50 reports the wrong state for a setting that is off, until someone clicks it.
`^0.4.3` and `^0.3.3` are version bumps wearing carets. `^0.4` and `^0.3` are the convention here; the lockfile decides which patch is actually used.
Two of the ten breaking changes reach this frontend.
Value-change callbacks now report the value, so `Switch` and `Checkbox`
take `onChange={(checked) => ...}` rather than an `Event`. The native
event is `onNativeChange`. The cleanup row keeps that one: its box is
rendered permanently checked, unchecking it is the keep action, and a
keep that fails has to put the tick back by writing to the element,
which only the native handler hands over.
`Input` moved its default size from `md` to `sm` so an unsized field
lines up with an unsized button. None of the eleven fields here set a
height of their own, so each names `md` to keep the row it was built
for. Which of them would rather be shorter is a look decision, not a
migration one.
The other eight do not apply: no `className`, none of the renamed
components, no `isDisabled`/`isOpen` on a library component (those
spellings are this app's own wrappers), no `color`, no query
accessors and no `PasswordField`. The Layouts application compiler was
already configured.
Typecheck, both contract checks and 394 tests pass against 4.0.0.
4.0.0 was published in error and has been unpublished from the registry, so a `^4.0.0` range now resolves to nothing at all. The value-change contract these conversions target ships as 3.1.0. The source is unchanged: it was written against this API either way. Only the number the range asks for is different.
`solid-js` and `@solidjs/web` were both `"next"`, and `bun.lock` is gitignored here, so nothing recorded what that resolved to. Today it is 2.0.0-rc.4 for every other repository in the fleet and 2.0.0-rc.6 here, decided by whichever day someone last installed. On a library still in release candidates that is a build whose behaviour changes without a commit. Pinned to 2.0.0-rc.4, which is what the rest of the fleet and the published `@pathscale/ui` peer set are built against. Typecheck, both contract checks and 394 tests pass on 3.1.0 with this pin.
Register the relaunch handler through blitz-control-protocol instead of the Tauri runtime re-export. Discover the runtime-owned descriptor by the launched process id so the local QA harness follows the same control surface.
Adds Agent::Grok across detection, models, pricing, quota, settings and the run loop, plus the frontend surfaces that name a provider. Grok specifics the other providers do not need: * Sessions are keyed by process cwd, like Claude's. `grok_session_cwd` reads `~/.grok/sessions/<urlencoded-cwd>/<id>` so resume and compact run where the session actually lives, instead of answering JSON-RPC -32603 and surfacing it as a parse error. * Prices double at 200k for the whole prompt, so a turn is steered at 180k and again at 190k, then compacted after the turn ends rather than mid-tool. * ACP does not execute `<tool_call>` markup written into assistant text. When that leaks the turn ends having done nothing, so the run resumes with a correction naming native tools. * Auto still emits `session/request_permission` for writes outside the workspace, so the approval channel opens and `auto_allows` answers. Also records a foreign namespace Prompt Syntax span rather than letting it render raw. A `<ps @Antml:invoke>` from a model blending grammars is a tool call it believed it made: the span stays inert, per PS 13.2, but every one is now logged with its verb and arguments, written to the transcript, and handed back so the call can be reissued natively.
Two latent gaps found reviewing the Grok work. `user_local_bin_is_searchable_without_overriding_shell_order` passed by accident: its input PATH already contained `/opt/homebrew/bin`, so the appended copy was deduplicated away and the indices lined up whatever order the candidates were pushed in. Start from a PATH holding none of them, so the order is actually read. `price_for` is first match wins on substring, so `grok-4.6` must precede `grok-4.5` or the shorter key shadows the longer one and bills the wrong cache read rate (0.50 against 0.30). Nothing enforced that. Check the whole table, not just the pair that prompted it.
Every table in the committed archive was v2. The app converts a v2 store on load, so each QA run migrated the fixture again before it could open it, and a restore never handed the harness the format the build actually uses. Converted through `wt-migrate migrate-v2-store`: 17 tables, 27314 rows, all verified. A restored copy now boots straight into the store with no conversion step in the log.
Added: Grok as a project agent, plus two dependency PRsFour commits on top of the UI 3.2 / WorkTable 1.10 work. Depends on (merge these first)
|
The catalogue is four agents now, ordered claude, codex, grok, copilot, and the prompt list carries grok-4.6 and grok-4.5. Also corrects a label assertion that predates this work: the pill reads "Codex" because that is what `agentLabel` has, not "OpenAI". CI never reached these before. The Rust build failed first on a worktable requirement, so the frontend suite did not run.
Merge order and remaining blockerConsolidated to one PR per repo. RustAgentAbstraction#36 was closed and cherry-picked onto #35 (same Grok branch), so history stays linear with no merge commits. Order
Version bumpsAll present: WorkTable The Rust failure is two path overrides, not this workBoth point outside the repo, so CI cannot load the manifest. They are local debugging overrides that predate this work (master has Fixed since the last comment
Frontend is 402/402 under vitest. Locally |
`spawn_resume_after_compact` ran on every successful compaction, so an owner pressing Compact was answered with a turn they did not ask for. Compaction interrupts in-flight work only when the app starts it. Grok is compacted before the 200k price cliff, mid-task and unasked, and leaving that summarised and idle strands the work that triggered it. An owner choosing the moment is the opposite: the next instruction is theirs to give. So the resume follows the trigger, not the agent, and an owner-driven compaction of a Grok project stays silent too. The `compact_project` command keeps its signature and hardcodes `Owner`, which leaves no resume parameter on the manual path and nothing the frontend can set. Only `compact_project_with`, which is not registered for IPC, can ask for `Automatic`.
0.4.21 carries the Grok ACP transport this branch drives, and it is on crates.io now, so the path override goes back to a version requirement and the sibling checkout leaves both workflows. Nothing outside the workspace is taken by path any more. `cargo fmt` stays named per workspace member. `--all` would pass today, but it reaches into any path dependency, so a local `[patch]` or a temporary override would start reporting another repository's formatting as this job's failure.
Production code in wt-migrate and agency-tools already ran on Nagoya. What kept Tokio in their graphs was the test harness: 10 `#[tokio::test]` in wt-migrate and 7 in agency-tools' integration test, each pulling a whole multi-thread runtime in as a dev-dependency. Each becomes an ordinary `#[test]` around `nagoya::block_on`, which is the runtime the code under test already uses, so the test now exercises the same executor as production rather than a second one. `cargo tree -i tokio` is empty for all four leaf crates now: az-core, az-mcp-proxy, wt-migrate and agency-tools. Verified past the unit tests: a release build boots the real app on the QA profile, opens the store through this migration layer in 28ms, and the ps-qa behaviour sweep is unchanged at 311/312. The one failure is `settings-agent-recheck-completes`, which probes installed agent CLIs and fails the same way before this change in a throwaway HOME.
The run loop carried its own state in channels. Cancellation was a `watch::Sender<bool>` plus receivers; three failure signals were `mpsc::unbounded_channel::<()>()`, queues whose payload is nothing. A `()` queue allocates a node, takes a lock and wakes a task to carry one bit that a single atomic already holds. It is also consuming, and that was a real defect rather than only waste: `injection_failure` is awaited from two loops, `recv()` hands the one `()` to whichever polled first, and the other waited forever for a failure that had happened. `Cancel` and `Latch` keep the flag and the wake as one object. The flag answers without waiting; the future registers a waker with Nagoya's `Notify` and is woken by the setter, so nothing polls and no wakeup happens that is not the event. Both are level-triggered, so every observer sees it, and setting before anyone waits is still observed. Removes 13 watch sites and 2 unbounded queues from the run loop, with one fewer allocation and one fewer task wake per signal. Verified past the unit tests: the ps-qa sweep holds at 311/312 with the same environmental agent-probe failure as before, and the session-reset group, which drives stop and restart, passes 3/3.
Two of the run loop's queues were unbounded for work that is already capped by the code feeding them. Liveness pings are gated by `ping_outstanding` and `MAX_UNANSWERED_LIVENESS_PINGS`, so at most four exist at once. Context steers fire behind one-shot flags, so a turn sends at most three. Unbounded said "any depth is fine" about both, and hid the real cap from anyone reading the declaration. Both are now `channel(N)` at that cap, and both senders use `try_send`. That part is not a detail: the bounded sender's `send` is a future that parks when full, and awaiting it on the event loop is exactly the stall these workers exist to prevent. `try_send` refuses instead, which is the right answer when the queue is full, because a fifth unanswered ping or a repeated steer says nothing the first four did not. The injection queue stays unbounded on purpose. It carries what the owner types mid-turn, which has no arithmetic cap. The workers themselves stay. `control.send` waits on the provider, and awaiting it from the loop would stop `run.recv` and manufacture the deadlock the watchdog exists to detect. Sweep holds at 311/312 with the same environmental probe failure.
Six reads ran on `tokio::task::spawn_blocking`, for a measured reason recorded at `list_item_rows`: as plain async fns they landed on Tauri's async workers beside the slow network commands, and `list_items` went from 10.8ms to 52.5ms because a store read started queueing behind a quota call that has been seen to take five seconds. Moving the work off those workers was right. Whose threads it moved to was not. Tokio's blocking pool is process-wide and implicit: az does not size it, cannot name its threads, cannot tell its own work from a dependency's on a stack trace, and has nothing to shut down at exit. It is the ambient runtime in another costume, which is the shape this port exists to remove. So az owns one. `runtime::Pool` is a `nagoya::Runtime` sized to the machine with `az-` named threads, held on `AppState` and stopped from the drain every exit path already shares. `Tuning::spread`, not the `locality` default: nothing submitted here ever suspends, so there are no wakes to keep warm, and the only decision left is which worker takes a job handed in from outside. Not `throughput` either, whose eight-job injector batch would let one worker claim a transcript scan and seven store reads behind it. The stop is explicit because nagoya's `Runtime` detaches its threads on drop rather than stopping them, deliberately. It runs only after the store drains: a failed drain leaves the app up with quit blocked, and a pool stopped there would take every read in the window with it. Also fixes three agent-io checks that could not have passed on a fresh profile. They click controls inside the Agent I/O section, which is collapsed until something expands it, and `reveal_before_capture` only scrolls. `prepare`/`prepare_unless` is the idiom 04-items.ron uses. Sweep: 294 pass, 1 fail. The failure is settings-agent-recheck-completes, which probes installed agent CLIs and cannot authenticate Codex under a throwaway HOME.
…and fs The mid-turn message path copied its payload at every hop. `InjectedMessage::Owner` carried `body: String` and `original_body: String`, the run loop did `clone_from(message_id)` before forwarding, and a review injection allocated four times before it reached the queue. That is a lot of copying to move text between two tasks in one process. The payload is `Arc<str>` now. It is allocated once where the text enters the system and handed forward; the forwards are refcount bumps. The enum's shape is unchanged, so the ordered worker, the ordering guarantee and the deadlock it exists to prevent are all untouched: this is a payload change, not a control-flow one. Worth recording what the trace found, because it decides the next step. There is no multi-producer anywhere on this path. The inner channel is sent from two lines that are both in the same run loop and it is never cloned. Every send on the outer one takes the `ActiveRuns` mutex, looks the run up, sends, and drops the lock, so the mutex is already the synchronisation and the channel's multi-producer machinery does nothing. Also takes `tokio::time` and `tokio::fs` out: - `timeout` and `sleep` are nagoya's, same shape. - The legacy recovery sweep is `std::fs`. It runs once at boot over a directory no build since 0.1.131 writes to, and `tokio::fs` is a thread pool behind an async facade: that bought an executor dependency and no concurrency, since the recovery it feeds is sequential anyway. `tokio::time::Instant` stays for now. It only feeds `sleep_until` inside the three `select!` blocks, and nagoya's `sleep_until` takes nanoseconds rather than an `Instant`, so those move together with the select work. 48 production `tokio::` references in this file, down to 38.
`cancel`, `injection_failure` and `ping_failed` were three `select!` arms in the main loop and two more in the approval wait. Each is an `AtomicBool` plus a `Notify`: it knows exactly when it changed and can wake whoever is parked on it. Putting three of those in a poll set asks the loop to re-poll all three on every provider event, to be told almost always that nothing happened. `Signals` gives them one queue. The loop parks once and reads the flags to learn which fired, which is three `Acquire` loads. They stay level triggered, so two arriving together are both acted on rather than one being consumed, and that is also what lets them share a wake at all. This fixes a real divergence the duplication had already caused. The approval wait listened for `cancel` and `injection_failure` but not `ping_failed`, so a liveness ping that could not be delivered went unobserved for as long as a question stood, because the loop that watches for it is not running while an approval is pending. Both waits now come off the same object and cannot drift apart. The approval wait takes `stopped`, which is cancellation or a failed injection, rather than all three. A failed ping means the run is unmonitored, not over, and the owner is still being asked a question. `changed` takes what the caller has already handled, which is the honest way to say "I know about the ping, do not wake me for it again": a `Latch` never clears, because clearing it would lie to every other reader, so a handled non-terminal fact would otherwise make its arm ready on every iteration and spin the loop. Outer select 7 arms to 4, inner 5 to 3. The remaining arms are the two real inputs and two timers.
The ping had a bounded channel of `()` and a task that read from it and called `control.send`. The channel carried no data, so the only thing it achieved was moving the await off the run loop, and its capacity restated a bound the loop already enforces: `should_ping_again` refuses a fifth ping while four are unanswered. `ProxyControl` owns its client and run id and borrows nothing from `ProxyRun`, so the loop can hand the send straight to az's pool. It happens somewhere else, which is all the worker did, and the loop returns to `run.recv` without awaiting it. The queue, the task, the `drop` that closed it and the join at teardown all go. `Pool::spawn` is new and deliberately separate from `Pool::run`: `run` takes a closure and waits for an answer, this takes a future and does not. A panic inside it is lost rather than propagated, which is why the ping reports its own failure through a latch the loop reads. The "the ping worker is gone" branch goes too. It could only be reached by `try_send` failing on a closed channel, and there is no longer a channel to close; clippy found it unreachable. 37 production `tokio::` references left in this file, from 39.
Same shape as the ping, with even less around it. There are three steers for the life of a turn - the 180k checkpoint, the 190k stop-now and the post-compact resume - and each is behind its own one-shot flag, so the queue's capacity of three restated the flags and the worker only moved the await off this loop. Nothing reads the result, and the worker discarded it too: a steer that does not land is not a reason to stop a run the owner is still watching. The attempt counter is an atomic now rather than a local in the worker. It only labels the idempotency key, and the two send sites are in the same loop, so nothing contends for it. 35 production `tokio::` references left in this file, from 37.
A pure combinator that polls two futures and touches no runtime. Nagoya ships none, deliberately, and `futures` is already in this file for `try_join_all`, so it comes from there.
Two of the loop's arms were timers, and neither was a clock. `idle_deadline` was recomputed as `Instant::now() + window` at four places, every one of them on a provider event. That is not a point in time, it is "how long since the last event" - a property of the receive. Saying it as a peer is what forced the `Instant`: a duration cannot be raced by a `select!` arm, only a deadline can. It is `nagoya::timeout(window, run.recv())` now, and the window is a `Duration` again. `timeout` polls the inner future first on each wake, so an event landing as the window closes is delivered rather than discarded. `cleanup_deadline` was never a wait at all. Only the stateless task manager has one, so the arm wrapped `pending()` to be never-ready for every other run: a branch whose entire job was to never fire, re-polled on every event. It is a start time and a limit now, checked once per iteration. They share one wait, so the two ways that can go wrong are handled. The wait is the smaller of the window and the remaining bound, or a silent run would sit past its cleanup limit until some event happened to arrive. And a wait that ended because the bound arrived returns to the top rather than taking the wedged-run path, so a cleanup timeout is not reported as silence. The wedged-run logic moved from the arm to a `Wake::Idle` branch unchanged. Outer select: 7 arms to 4. 25 production `tokio::` references in this file, from 35 at the start of this pass and 48 at the start of the port.
The run that died logged three lines, and the first two are the same
bug:
WARN could not record session-run measurement: need 17776, but 12716 allowed
ERROR the run failed: `claude` rejected an argument [...17KB of control_response...]
ERROR could not persist the failed turn: need 16512, but 15260 allowed
`claude` answers a rejected argument with its entire `control_response`,
every skill description and model entry included, and that string went
straight into two rows: the measurement's `status` and the failed turn's
`stop`. A WorkTable row must fit one 16356-byte page, so both writes
were refused, the turn was never persisted, and the run ended with no
record of itself.
The file already had the cap and the reason written down.
`MAX_PERSISTED_BLOB` and `truncate_to_bytes` exist because an oversized
insert has twice corrupted this store on this machine rather than
merely failing; `body` gets `body_head` plus a spill to `message_chunk`.
`status` and `stop` share the same page and had nothing.
Both are capped now, and in the two places a caller cannot forget:
`RunMeasurement::finish` caps its `status`, `persist_terminal_agent_chunk`
caps its `stop` (which covers `finalize_agent_chunk`, its only caller).
The failure path also caps `error_text` at the binding, after
`claude_rejected_resume` and `is_cybersecurity_refusal` have matched:
both use `contains`, so they read the uncapped text while what is stored
and shown is the head, which is where a provider puts the sentence and
not the dump.
Three tokio uses in this file were timers and wakes with no tokio in them. `ActiveRuns.released` is a `Notify` that only ever broadcasts. Both waiters built the future, pinned it, called `enable()` to register before reading the map, then awaited: the dance tokio requires so a release landing between the check and the await is not lost. Nagoya's `notified()` snapshots the broadcast generation at construction, so building it before the check buys the same guarantee, and the pin and the `enable()` go away. The construction order is the whole thing and it is commented as such. The approval wait's deadline was `tokio::time::Instant::now() + APPROVAL_TIMEOUT` raced by `sleep_until`. It stays absolute, because servicing an injected message while the question stands must not extend it, and it is nanoseconds on nagoya's clock now. Also drops a comment block that was pasted twice over the `stopped()` arm, keeping the version that says why `stopped` is the right set. 25 production `tokio::` references in this file, from 48 at the start of the port. What is left is tokio for a reason: Tauri's executor (`yield_now`), the `gh` CLI's process and io, `select!`, and the channels, which nagoya deliberately does not ship.
Switching interface language and back, with a query still in the search
box, hid sections that matched it. The Codex import picker was the one
that showed up: ps-qa's `select` group runs right after the `language`
group, navigated correctly, logged "revealed deferred content for
Choose a session from Codex CLI / IDE in 8 step(s)", and then could not
find the control it had just revealed.
The search corpus is keyed by language (`${locale}:${id}`) and learned
once per section, so changing language retracts every section's index at
once. Two things then went wrong, and both are in this commit.
The effect branch for "no corpus yet" cleared `searchRetained`. That
branch has two causes: a section that was never indexed, and a section
whose index was just retracted by a locale change. For the second, the
section was matching an instant earlier, and clearing retention dropped
it. Retention is left alone now; the indexer re-populates the key.
`visible()` read `hits().size > 0`, which is empty while re-indexing
because no row has reported yet, not because nothing matches. A section
mid-reindex under a live query now stays visible; the rows report an
instant later and the ordinary predicate takes over. Rows match against
their own live DOM text, so this admits nothing a settled section would
have hidden.
Found while proving the five ps-qa `select` failures were not the
tokio/nagoya port: only the first check actually ran, and the harness
log showed it reaching the control before the search hid it.
The run loop accepts an injected message in two places: the main wait, and the nested wait that runs while an approval question stands, because "the moment the user hits enter" is the delivery contract and a dialog on screen is exactly when someone types "deny that and do X instead". The body was copy-pasted between them: take the incomplete directive tail, flush the continued chunk, restore the tail, record the directive turn id, queue delivery. Five steps, twice, and they had already drifted in the one way that matters. The main loop reads the unfinished-directive flag as `last_was_text`, so the next delta finishes the broken line instead of gaining a paragraph break. The nested copy has no `last_was_text` and folded it into `preserve_text_adjacency` instead. That is a real difference, so it is the return value now, and the five shared steps are `accept_injection`. The cursor those steps walk (the chunk body, when it started, the row it continued, the directive turn id) travels as a `StreamedChunk` rather than four more parameters. Clippy asked, and it was right: nine arguments was the extraction admitting it had not finished. Not touched, deliberately. The ordered delivery worker stays: it awaits a provider receipt per message, and two messages typed in sequence must arrive in sequence, which per-message spawns cannot promise. It stays on `tokio::spawn` too, because the teardown joins it to let it report rejected messages before the run slot is released, and nagoya propagates a panic to the awaiter where tokio hands back an `Err` - moving that join would unwind `drive_run` past its own teardown.
The previous commit fixed the hide, and proved nothing. This makes the fix falsifiable. `visible()` moves to `searchVisibility.ts` as a predicate over the five things the section knows, and the component calls it. That indirection buys one thing, which is the only reason for it: the decision can be tested. `SettingsTab` cannot be mounted under vitest - the suite runs on `node` with no DOM, and `vitest.config.ts` already records that mounting it halts the reactive system mid-boot - so a rendering test was never the option here. Six cases, and the one that matters asserts a section stays visible while it re-indexes after a language change: still mounted, still matching, but `hits` momentarily 0 because no row has reported against the new locale key yet. Reverting the predicate to its old form fails that case and only that case, with "expected false to be true", which is the bug as ps-qa saw it: the harness revealed the Codex import picker and then could not find it. The neighbouring cases hold the fix to a window rather than an exemption. An unmounted section without a corpus stays hidden, and a mounted one hides again as soon as re-indexing settles on no match.
`ee517fa` capped the field and asserted the cap on a string, which proves a string got shorter, not that the row goes in. This drives `persist_terminal_agent_chunk` against a real WorkTable store with the error that caused it: a sentence followed by kilobytes of `control_response`. Removing the cap fails it with "need 21904, but 15260 allowed" - the same refusal shape the live log recorded at 20:25:58 UTC as "could not persist the failed turn: need 16512, but 15260 allowed". With the cap the insert succeeds and the row reads back. The row carries a body as well as the oversized stop, because a page is 16356 bytes and the columns share it: an error capped to exactly the page would still fail beside anything else. The assertions are that the turn persists and the head is what survives, which is the property that was broken - the owner watched an answer arrive and it was never written.
Agents keep missing this and it costs hours. The descriptor's `address` is a live unix socket speaking MCP: the semantic tree, real input, pixel capture, quit. Anything ps-qa does is available directly, plus what it has no subcommand for. Written down because the alternative is what I just did - read source, infer which layer returned empty, and guess, while the running app sat there able to answer. The framing is the only part that is not obvious and it fails misleadingly: 4-byte big-endian length, then a 1-byte tag, then JSON. Newline-delimited JSON answers "frame size too big", which reads like a protocol mismatch rather than a missing header. The snippet in this section was run verbatim against a live instance. Also says what is not there, because that was the next hour: no JS eval. Tree and input only, so "what did this Tauri command return" is an app-log or unit-test question, and element queries need the separate debug driver.
…s an executor `discover_chat_imports` was dispatched sixteen times in one session and answered seven. The last eight never returned, so the webview promise behind them stayed pending, and Settings kept the empty state it had rendered first: "No sessions discovered", while discovery itself was fine and found both fixture sessions when called directly. ps-qa's `select` group had been failing on that since the pool was introduced, and `list_items` (6 sent, 4 returned) and `list_messages` (6/4) were losing calls the same way without anything visible to show for it. The cause is the handoff. Five commands handed a synchronous store read to az's nagoya pool and awaited the result, but the caller is a `#[tauri::command]` parked on Tauri's tokio executor while the work finishes on a nagoya worker: the waker is registered with one executor and woken from the other. A wake lost there is not a slow command, it is a command that never returns. The root fix is not a better handoff, it is no handoff. These are synchronous functions - a WorkTable select, a mutex read, a bounded directory walk - and a synchronous `#[tauri::command]` runs on the invoke thread rather than the async runtime. That is what the measurement at `list_item_rows` actually asked for: it says a cheap read must stay off the async workers, where `list_quota` averages over a second, and a synchronous command is never on them. `spawn_blocking` and then the pool were two ways of shipping the work elsewhere to achieve what not being async achieves for free. So `list_items`, `list_messages`, `list_running_tasks` and `discover_chat_imports` are plain `fn` now, `import_chat_session` and the analytics backfill call `chat_import::load` directly, and `Pool::run` is deleted rather than fixed: with no callers it is the await this module exists without, and leaving it invites the next one. `backfill_imported_usage` loses its now-unused pool parameter. The pool keeps `spawn`, which returns nothing, for the liveness ping and cliff steers - work whose answer nobody reads. Sweep: 311 passed, 1 failed, from a 307/5 baseline that had stood all day. All five `select` failures are gone and discovery is 16 sent, 16 returned. The remaining failure is `pillmenu-effort-shadow-does-not-stack`, which passes and fails across runs on identical code.
The port stalled on a fear that was specific to `JoinHandle` and got generalised to every nagoya primitive: a handle awaited from a Tauri task registers with one executor and is completed by the other, and a lost wake is a command that never returns. Timers are not that shape. nagoya's `sleep` arms a slot in a process-global heap that a lazily started `nagoya-timer` thread drains, and firing calls `wake()` on whatever waker the slot holds, which is the awaiting task's own. There is no nagoya runtime in the path at all, so a tokio task awaiting one is woken exactly as a nagoya task would be. So all sixteen `tokio::time` sites move: `sleep` and `timeout` are drop-in, including as backon's sleeper, which takes any `Fn(Duration) -> Future`. `timeout_at` has no nagoya counterpart, and the two sites that used it share one budget across a sequence of awaits rather than granting each a fresh one. That is held as an absolute deadline on nagoya's clock and converted back to what is left of it at each call, by the two helpers this adds to `runtime`, where the rest of the executor policy already lives. `update` and `experimental` now name no tokio at all.
Two defects that share a shape: a rule written against one spelling of something that has two. The foreign-namespace guard tested for "was not executed:", which is the singular opening of its own correction. Two or more leaked spans open with "were", so the guard failed to recognise the text it had just sent, resumed, and corrected the correction, for as long as the model kept quoting the spans back. The invariant sentence is now a constant both the prompt and the guard name, so no future edit to the wording can separate them again. The existing test passed throughout because it only ever built one span; it now runs both counts, and fails against the old guard. The page budget had the same problem one layer down. `body` was allowed 12000 bytes and `stop` 8000, each correct on its own and jointly 4000 past the 16356-byte page. That is one streamed reply over 12K followed by one large provider error, which is what the previous commit was written about. The budget is now enforced on the row, at `persist_message_body`, which every message insert already goes through, rather than as independent per-column caps that cannot see each other. `body` gives up its space first because it is the one that loses nothing: the tail it sheds is written to `message_chunk` either way. That means the spill can no longer start at a fixed 12000, since `full_body` reassembles by concatenating the stored head with the chunks, and any other split point loses or repeats the bytes between. `store_body` now takes the head length that was actually written. `stop` keeps a floor, and its head, which is the part that names the failure.
Two things the review found, both about work landing where it was deliberately moved away from. `Cancelled::poll` re-read the stop flag after the first wake, because a `notify_waiters` this future was not the target of still wakes it, and only the flag says whether the run stopped. The branch that re-registers after such a wake then returned `Ready` on the next notification without applying that rule at all. `Signals::around` puts the run's two failure latches on the same queue as its cancel, so the wake arriving there is routinely a sibling's, and treating it as a stop ends a run the owner is still watching. The test asserts the invariant rather than reproducing the race, and says so: the wrong branch needs a notification between the re-registration and its poll, both inside one call, and nagoya's `Notified` snapshots the broadcast generation at construction, so one thread cannot open that window. `reorder_items` called `list_items`, which had just become synchronous for the reason its own doc records: a scan of every item in a project took 10.8ms to 52.5ms when it ran on the async workers. `reorder_items` is async, so calling it inline put the scan back exactly there. It now reads the rows it moved, by id. That is what the caller consumes anyway, which the store's `reorderItems` shows by upserting each returned row and keeping nothing else.
… it sits The durable marker recorded how far the promotion got and never which tree ended up at the live path. Every ambiguity here came from that one gap. A rolled-back promotion leaves v2 at the store, the backup beside it and no stage. A promotion that reached disk before its final marker leaves v3 at the store, the backup beside it and no stage. Those are the same shape from outside, and the "already complete" fast path read it as the second: it wrote `complete` and deleted the backup, which in the first case is the only remaining copy of the owner's data. Reachable without any exotic failure: `rename(stage, store)` returning ENOSPC on the directory entry, or EXDEV when the profile directory is a mount point. A sentinel now rides inside the staged tree, written before the rename that publishes it, so the rename carries it atomically: if it is at the store, the promotion happened. The fast path and the `complete` arm both check it before deleting anything, and refuse with the reason rather than guessing. The rollback discarded its own failures with `let _`, so a rollback that did not happen was reported as one that did, and the owner was told their data was retained at a path by code with no idea whether it was. Both outcomes are now distinct errors, and the one that needs a human says which directory to move where. The marker also rewinds to `validated`, because the source is no longer preserved elsewhere once it has been moved back. Finally, no marker did not mean nothing happened. The marker is published by create-temp-then-rename, so a run that died after the source was preserved but before that rename landed left the store absent and the backup holding everything. Resume read that as "nothing to do", and the migrator then refused for as long as the backup existed: an unbootable profile with no path out the app could take by itself. The backup is the durable fact, so it is now trusted over the missing marker and the interrupted promotion is finished. Stores with no migration in flight are untouched, which the fourth test pins: the recovery is reached only with a backup present and no live store.
CI was red on every panel check, and none of them was a panel. The v2
reader failed to compile, its staging step failed with it, and the three
checks that need the binary reported the fallout.
error: Unexpected token `update_in_place`;
expected one of `update`, `delete`, `in_place`
The reader includes the GUI's schema directly, to guarantee it reads rows
through the same layout that wrote them. But it is pinned to
`worktable_dsl 1.0.0-beta.18.1` on purpose, because that is the DSL whose
reader understands v2 pages, while the GUI has moved to 1.10. Adopting
1.10's `update_in_place` earlier in this branch therefore handed a
five-versions-older parser a token that did not exist when it was written.
Sharing the columns is the requirement; sharing the queries was incidental,
and it is what coupled the two DSLs. The reader calls `name_snake_case`,
`version`, `load` and `select_all`, and issues no query at all. A build
script now copies the schema with the `queries` blocks stripped before the
macro ever parses them, which also holds for whatever 1.11 adds next. A
`#[cfg]` on the block does not work: the macro parses its own body and
rejects attributes there.
Verified rather than assumed: all seventeen tables' `columns` blocks are
byte-identical between source and generated copy, so the on-disk layout the
reader depends on is unchanged.
The crate is excluded from the workspace, so `cargo fmt -p ...` cannot
reach it and every workspace-wide gate skipped it in silence. That is how a
crate that does not compile reached master's CI. It now has a formatting
check beside the build that already covered it, and its own target
directory is ignored rather than committed.
The sentinel guard was applied to the `complete` phase as well, and that
refused to boot against every store already migrated: their marker says
`complete` and their tree, correctly, contains no sentinel, because it did
not exist when they were promoted. The app panicked in its setup hook.
could not recover the interrupted WorkTable v3 migration:
migration state says complete, but <store> is not the promoted v3 store
Found by running the QA sweep, which is the only thing that caught it. The
whole local gate was green: the unit tests construct their own fixtures and
so only ever build stores that have the sentinel, and CI does not open a
real profile either.
`complete` needs no corroboration. It is written only after the v3 tree is
live, which makes the phase itself the proof, and it is the one phase that
is unambiguous alone. The ambiguity the sentinel exists for is in
`source-preserved`, where the phase cannot tell a promotion from a
rollback, and that check stays exactly where it was.
The `source-preserved` refusal now also says what to do about it, since a
store interrupted there before the sentinel existed cannot be resolved by
the app and needs a person to say which tree is the wanted one.
`items-scroll-expands` failed on a fixture, not on pagination. It scrolled over a row whose title is also the name of a project tab; `scroll` sends its wheel events to the first node matching the name, the tab strip sorts ahead of the item list, and a tab bar does not scroll. The list never moved, the page stayed at twelve, and it read as the next page failing to mount. Pagination was working the whole time. Scrolling the same list over a row the tab strip does not shadow takes it from 12 to all 23 items, which is what this now asserts. Two other anchors look right and are not: a row further down the list is not mounted before the scroll, so it matches nothing, and the row's status button is a 30px control that takes the wheel itself. The chosen row is full width, inside the scroller, and painted on the first page. The sweep is 312 passed, 0 failed, on a cold app. It has never been clean before: the last run was 311 with this failure, and the run before that also carried `pillmenu-effort-shadow-does-not-stack`, which did not recur here.
CI failed `item-issue-editor-opens` where it passes locally, and the
message says why:
no painted node matching "textbox:GitHub issue URL" has a box
(1 in the tree: bounds=Some([1037.0, 936.0, 27.0, 20.125]))
Twenty-seven pixels wide. The node is there, painted and focusable, and it
is not a URL editor by then.
The row is the field, a Cancel, and a button reading "Link a GitHub issue".
That label sets its own width and does not give any of it back, so the only
child that can absorb the panel's 332 pixels is the input, and `min-w-0`
said it could absorb all of it. Locally the row fits and the field keeps a
usable width; the CI runner's window is narrower, so the same layout
resolves with the field squeezed to nothing.
A floor on the field and `flex-wrap` on the row. The buttons move to a
second line when the width is not there, which is the outcome this row
should have had: the field is the reason it exists.
Sweep is 312 passed, 0 failed, cold.
AgencyZero now takes the released UI 3.2 line and is ready for WorkTable 1.10 beta's v3 page format without dropping existing stable or experimental profile data.
The GUI resolves
@pathscale/ui3.2.3 with the Solid 2.0 RC packages on one compatible line. Native behavior fixes cover retained PillMenu paint, immediate project-item reveal, Settings search reset, transparent variant windows, and the ps-qa control bindings. The app version is 0.8.61.Storage and runtime changes:
^1.10.0-beta1;wt-migrate/v2-readersidecar reads the immutable compatible v2 registry pair, exports neutral archives for all 17 tables, and stays outside the beta runtime graph;validatedmarker permits crash recovery to promote it;Copied-profile end-to-end evidence, with no live profile changed:
03000000, removed its temporary v2 directory, recorded phasecomplete, and skipped cleanly on the second launch;validatedandsource-preservedphases recover through the same promotion state machine.Focused validation at current storage-migration head
8a20e95, patching WorkTable from the exact PR #106 headc631e0b:cargo test -p wt-migrate: 18 passed;cargo clippy -p wt-migrate --all-targets -- -D warnings: passed;cargo fmt --all -- --checkandgit diff --check: passed;Earlier cutover validation at
f4535d6, using the same PR #106 implementation line:cargo check -p az-gui --no-default-features --features webview-runtime: passed;cargo clippy -p wt-migrate -p az-gui --no-default-features --features az-gui/webview-runtime --all-targets -- -D warnings: passed;cargo test -p agency-tools -p wt-migrate: passed, including 29 agency-tools tests and 18 wt-migrate tests;cargo checkandcargo clippy --all-targets -- -D warnings: passed.The previously recorded UI candidate evidence remains 41 frontend files and 394/394 tests, with production build, TypeScript, lint, and UI ownership checks passing. Desktop and GPU QA were not rerun for this storage/runtime pass.
Registry-only CI is expected to remain red until
worktable 1.10.0-beta1is published. The consumer gate above used the review-ready WorkTable PR head rather than an application workaround.The 0.8.61 update adopts WorkTable's final typed mutation surface: declared writes pass the row key, a generated
TableColumnsselector, and the typed value; complete rows usereplace. All 22 active GUI mutation callsites were migrated without changing schema grammar.cargo check -p az-gui --all-targetspasses against WorkTable checkpoint72b018f, using the already-built frontend and local sidecar placeholders only for Tauri's build-time file checks. No JavaScript runtime ran. Registry order is WorkTable #106 and publication, then this PR.