Skip to content

feat: release AgencyZero on UI 3.2 and WorkTable 1.10 beta - #211

Merged
pathscale merged 55 commits into
masterfrom
feat/ui-3-migration
Sep 18, 2026
Merged

pathscale merged 55 commits into
masterfrom
feat/ui-3-migration

Conversation

@pathscale

@pathscale pathscale commented Sep 7, 2026

Copy link
Copy Markdown
Owner

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/ui 3.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:

  • every current WorkTable consumer resolves the workspace caret ^1.10.0-beta1;
  • shared stable and experimental bootstrap detects v2 before opening any application table;
  • an internal wt-migrate/v2-reader sidecar reads the immutable compatible v2 registry pair, exports neutral archives for all 17 tables, and stays outside the beta runtime graph;
  • migration builds a separate v3 stage, checks every table count and full-row SHA-256 digest, strict-loads the v3 tables, closes each table so the stage is durably saved, then promotes it;
  • after close and cold validation, every staged data/index file and nested directory is synced bottom-up before the durable validated marker permits crash recovery to promote it;
  • failure leaves the v2 source at the live path, including interrupted export/import and failed promotion recovery;
  • successful durable promotion deletes the displaced v2 directory. A later launch sees the completed v3 state and skips conversion;
  • headless storage work uses Nagoya's default locality. The GUI retains direct Tokio only at Tauri and AgencyProxy boundaries whose APIs expose concrete Tokio tasks, sockets, processes, signals, timers, channels, and async I/O types;
  • the lifecycle ownership work from refactor: take the control surface from the protocol, not the runtime #212 and its descriptor-by-PID QA fix are folded here;
  • repository policy now prohibits adding Node.js, npm, npx, pnpm, Yarn, or Deno tooling. The existing frontend remains on Bun.

Copied-profile end-to-end evidence, with no live profile changed:

  • stable: 17 tables, 63,868 rows, with every count and full-row digest matching after strict cold reopen;
  • experimental: 17 tables, 59,212 rows, with every count and full-row digest matching after strict cold reopen;
  • a stable copy at a dotted custom path promoted to header 03000000, removed its temporary v2 directory, recorded phase complete, and skipped cleanly on the second launch;
  • a forced reader failure left the experimental v2 copy byte-identical and created no displaced backup;
  • interrupted validated and source-preserved phases recover through the same promotion state machine.

Focused validation at current storage-migration head 8a20e95, patching WorkTable from the exact PR #106 head c631e0b:

  • cargo test -p wt-migrate: 18 passed;
  • cargo clippy -p wt-migrate --all-targets -- -D warnings: passed;
  • cargo fmt --all -- --check and git diff --check: passed;
  • no Cargo or Bun lockfile is committed.

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;
  • internal v2 reader cargo check and cargo 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-beta1 is 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 TableColumns selector, and the typed value; complete rows use replace. All 22 active GUI mutation callsites were migrated without changing schema grammar. cargo check -p az-gui --all-targets passes against WorkTable checkpoint 72b018f, 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.

meh added 7 commits September 6, 2026 18:07
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.
@pathscale pathscale changed the title feat(gui): migrate to @pathscale/ui 3.1.0, and pin Solid feat(gui): release AgencyZero on UI 3.2 Sep 12, 2026
@pathscale pathscale changed the title feat(gui): release AgencyZero on UI 3.2 feat: release AgencyZero on UI 3.2 and WorkTable 1.9 beta Sep 13, 2026
@pathscale pathscale changed the title feat: release AgencyZero on UI 3.2 and WorkTable 1.9 beta feat: release AgencyZero on UI 3.2 and WorkTable 1.10 beta Sep 13, 2026
meh added 3 commits September 15, 2026 02:23
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.
@pathscale

Copy link
Copy Markdown
Owner Author

Added: Grok as a project agent, plus two dependency PRs

Four commits on top of the UI 3.2 / WorkTable 1.10 work.

Depends on (merge these first)

PR Why this branch needs it
pathscale/RustAgentAbstraction#36 --no-plan (ACP has no TUI, so plan mode hangs the turn) and RateLimit::used_percent. AZ will not build without it.
pathscale/ps-blitz#99 Hoisted paint child duplicated on incremental resolve. Fixes the last ps-qa failure on this branch.

feat(gui): drive Grok as a project agent

Agent::Grok across detection, models, pricing, quota, settings and the run loop, plus the frontend surfaces that name a provider. Grok specifics: cwd-keyed session resume, a 180k/190k steer before the 200k price cliff, recovery when <tool_call> markup leaks into assistant text, and an approval channel under Auto.

Also records foreign-namespace Prompt Syntax spans instead of letting them render raw. A <ps @antml:invoke> is a tool call the model believed it made; the span stays inert per PS 13.2, but each one is now logged with verb and arguments, written to the transcript, and handed back so the call can be reissued natively.

test(gui): cover the macOS PATH candidates and price key shadowing

Two latent gaps found while reviewing the Grok work. The PATH test passed by accident (its input already contained /opt/homebrew/bin, so dedup hid the new candidates). And price_for is first-match-wins on substring, so grok-4.6 must precede grok-4.5 or the wrong cache-read rate is billed, with nothing enforcing it.

test(qa): store the QA profile fixture in WorkTable page format v3

Every table in the committed archive was v2, so each QA run re-migrated the fixture before it could open it. Converted through wt-migrate migrate-v2-store: 17 tables, 27314 rows verified.

Testing

  • cargo test -p az-gui --bins: 325 passed, 0 failed. Clippy clean.
  • ps-qa full suite against the v3 fixture: 312/312, exit 0 (was 311/312 before the ps-blitz fix).
  • Frontend: 284 pass, 9 fail. All 9 are a pre-existing jsxDEV module-resolution error, identical on master; this branch adds 6 passing tests.

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.
@pathscale

Copy link
Copy Markdown
Owner Author

Merge order and remaining blocker

Consolidated 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

# PR CI Why it goes here
1 pathscale/WorkTable#106 (1.10.0-beta1) Formatting fails Nothing else builds on CI until this is published. Not touched by this work.
2 pathscale/RustAgentAbstraction#35 (0.4.21) green Publishes the Grok ACP transport this branch calls.
3 pathscale/ps-blitz#99 (0.4.10) green (7 pass, 3 skip) Independent. Any time after its own review.
4 this PR Frontend green, Rust blocked Needs 1 and 2 on crates.io.

Version bumps

All present: WorkTable 1.10.0-beta1 (#106), agent-abstraction 0.4.21, ps-blitz 0.4.10, az 0.8.64 (bumped in the Grok commit). ps-blitz 0.4.10 does not collide with #98, which detached blitz-net to its own version.

The Rust failure is two path overrides, not this work

worktable = { path = "../WorkTable" }
agent-abstraction = { path = "../../../RustAgentAbstraction" }

Both point outside the repo, so CI cannot load the manifest. They are local debugging overrides that predate this work (master has worktable = "^1.0.0-beta" and agent-abstraction = "0.4.19"). Once 1 and 2 publish, these go back to ^1.10.0-beta1 and 0.4.21. I have left them as paths for now because flipping them before the crates exist would only trade one red build for another. Say the word and I will push that commit.

Fixed since the last comment

  • fix(grok): satisfy clippy under -D warnings on Compact AGENTS.md, fix copy inside the prompt box #35. CI runs -D warnings, and the new ACP module had 9 errors (separators, map().unwrap_or() on a Result, bool count, JSON number casts). Compact AGENTS.md, fix copy inside the prompt box #35 is green now.
  • test(frontend): expect Grok in the catalogue and the prompt list. CI never reached the frontend suite before, because the Rust build failed first. With the build further along, 3 stale assertions in models.test.tsx surfaced: the catalogue is four agents (claude, codex, grok, copilot), the prompt list carries grok-4.6/grok-4.5, and one label assertion predates this work (agentLabel says "Codex", not "OpenAI").

Frontend is 402/402 under vitest. Locally bun test reports 9 failures from a jsxDEV resolution quirk that vitest, which CI uses, does not hit.

`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`.
meh added 27 commits September 17, 2026 06:12
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.
@pathscale
pathscale merged commit b8bf119 into master Sep 18, 2026
2 of 3 checks passed
@pathscale
pathscale deleted the feat/ui-3-migration branch September 18, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant