diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b032f2785..a7c4456e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,20 @@ jobs: - name: Build the AgencyProxy sidecar run: scripts/stage-agency-proxy-sidecar.sh - - run: cargo fmt --all --check + - name: Build the WorkTable v2 migration reader + run: scripts/stage-wt-v2-reader-sidecar.sh + + # Named per workspace member rather than `--all`, which reaches into any + # path dependency and reports another repository's formatting as this + # job's failure. Nothing is taken by path from outside the workspace + # today, but a local `[patch]` or a temporary override reintroduces that + # the moment someone adds one, and the failure reads as ours. + - run: cargo fmt --check -p az-gui -p az-core -p az-mcp-proxy -p wt-migrate -p agency-tools + # Excluded from the workspace so its pinned v2-era WorkTable resolves on + # its own, which also puts it out of reach of every `-p` above and every + # workspace-wide gate. Its compile is covered by the staging step; this is + # the formatting half of the same blind spot. + - run: cargo fmt --check --manifest-path crates/wt-migrate/v2-reader/Cargo.toml --all # Blitz is currently a macOS preview runtime. Enabling every feature on # this Linux runner asks it to implement Tauri's GTK-only runtime traits, # which is separate portability work rather than Linux CI for AgencyZero. diff --git a/.github/workflows/qa-panel.yml b/.github/workflows/qa-panel.yml index 5b98336ad..0c215d7be 100644 --- a/.github/workflows/qa-panel.yml +++ b/.github/workflows/qa-panel.yml @@ -91,6 +91,9 @@ jobs: - name: Stage the agency-proxy sidecar run: scripts/stage-agency-proxy-sidecar.sh + - name: Stage the WorkTable v2 migration reader + run: scripts/stage-wt-v2-reader-sidecar.sh + # blitz-inspector, NOT blitz-runtime: a blitz-runtime build answers every # inspector call with diagnosticsUnavailable, so the run would fail with # every check unable to see anything rather than with a real verdict. @@ -203,7 +206,17 @@ jobs: id: full if: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.group == '' && inputs.suite != 'focused') }} continue-on-error: true - timeout-minutes: 3 + # All 312 checks, one process, on a shared runner. This was 3, which + # the suite outgrew rather than regressed into: the run that tripped it + # reported `passed: 312, failed: 0` and every contrast audit clean, then + # the step timeout killed it at 3m00s. The previous green run finished + # in 2m33s, so the margin was 27s and one more group spent it. + # + # A step timeout is not the per-check budget. `QA_TIMEOUT_SCALE` still + # governs whether an individual outcome is allowed to be slow; this + # only bounds the wall clock of the whole sweep, so raising it does not + # weaken a single assertion. + timeout-minutes: 6 env: QA_GROUPS: '' QA_TIMEOUT_SCALE: 2 diff --git a/.github/workflows/release-experimental.yml b/.github/workflows/release-experimental.yml index 31867ad44..4cbf8f954 100644 --- a/.github/workflows/release-experimental.yml +++ b/.github/workflows/release-experimental.yml @@ -93,6 +93,9 @@ jobs: - name: Build the AgencyProxy sidecar run: scripts/stage-agency-proxy-sidecar.sh + - name: Build the WorkTable v2 migration reader + run: scripts/stage-wt-v2-reader-sidecar.sh + - name: Build experimental bundle env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72a4ddfc5..652d4491d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -152,6 +152,9 @@ jobs: - name: Build the AgencyProxy sidecar run: scripts/stage-agency-proxy-sidecar.sh + - name: Build the WorkTable v2 migration reader + run: scripts/stage-wt-v2-reader-sidecar.sh + # Ad-hoc signed, and nothing here decides that: `signingIdentity: "-"` in # tauri.conf.json does. Notarization needs a current Apple Developer # Program membership and ours has lapsed. diff --git a/.gitignore b/.gitignore index ee326e2e2..9961ab695 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,11 @@ # Built by apps/gui/frontend (rsbuild -> ../dist), served by Tauri. /apps/gui/dist /apps/gui/binaries/agency-proxy-* +/apps/gui/binaries/agencyzero-wt-v2-reader-* +/crates/wt-migrate/v2-reader/Cargo.lock +# Excluded from the workspace so its pinned v2-era WorkTable resolves on its +# own, which also means it builds into its own target directory. +/crates/wt-migrate/v2-reader/target node_modules /output/pdf/ /docs/agencyzero-overview.md diff --git a/AGENTS.md b/AGENTS.md index d8ac7b62b..e05957d04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,11 @@ hardens into procedure moves here; the reasoning stays in memory. Run what you build before calling it done. **If you can't run it, say so.** +- **Do not introduce Node.js, npm, npx, pnpm, Yarn or Deno.** The existing + frontend uses Bun; keep its install and script paths on Bun. New repository + tooling and harnesses are Rust binaries or plain shell around repository + binaries, not JavaScript runtime scripts. + - Compare against the base branch: a pre-existing failure is not yours, and saying so requires checking. - A suspiciously fast build was cached. Force a rebuild when the rebuild is the point. @@ -109,6 +114,10 @@ paraphrases were how the old checkbox contract created near-duplicates. Full con mock (`bun run dev`, port 3010) and is drivable by roles and labels: [`docs/ui-verification.md`](docs/ui-verification.md). Otherwise build, test, and ask the owner to look. +- **Native OS file dialogs are outside the control tree.** If a flow needs a + real folder or file pick, ask the owner to operate the panel, then inspect + the in-app result. Typed-path checks are a different control. Guide: + [`docs/debugging.md`](docs/debugging.md#native-os-file-dialogs). - **Never touch the running System instance**, its process, files or data directory. The store is single-writer. Use the Dev instance (`tauri.dev.conf.json`). **Check before you decide it is closed, and check by the right name.** The binary is diff --git a/Cargo.toml b/Cargo.toml index a1a72f11f..6e230c6b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,10 @@ members = [ "crates/wt-migrate", "crates/agency-tools", ] +exclude = ["crates/wt-migrate/v2-reader"] [workspace.package] -version = "0.8.50" +version = "0.8.64" edition = "2024" publish = false @@ -26,7 +27,7 @@ agency-proxy-protocol = "^0.1.8" # The GUI and both storage tools compile the same schema against one resolved # WorkTable package. A workspace dependency prevents their compatible ranges # from drifting into separate copies without freezing the selected patch. -worktable = "^1.0.0-beta" +worktable = "1.10.0-beta1" # Heavy deps are declared per-crate on purpose: only apps/gui pulls in the # Tauri stack, so agent/proxy builds never trigger a webview toolchain build. diff --git a/apps/gui/Cargo.toml b/apps/gui/Cargo.toml index 32c1ce0ce..c73dfcd6a 100644 --- a/apps/gui/Cargo.toml +++ b/apps/gui/Cargo.toml @@ -23,6 +23,7 @@ experimental = ["dep:agent-experimental"] # workspace both already take tauri this way, and neither needs that flag. webview-runtime = ["tauri/wry"] blitz-runtime = [ + "dep:blitz-control-protocol", "dep:blitz-dom", "dep:blitz-script", "dep:brotli", @@ -63,7 +64,7 @@ tauri-build = { version = "2", features = [] } [dependencies] # Carries the store forward when a column changes; see crates/wt-migrate. wt-migrate = { path = "../../crates/wt-migrate" } -agent-abstraction = "0.4.19" +agent-abstraction = "0.4.21" agency-proxy-client.workspace = true agency-proxy-protocol.workspace = true agent-experimental = { version = "^0.1.3", default-features = false, optional = true } @@ -80,18 +81,23 @@ az-core.workspace = true worktable.workspace = true # The `worktable!` macro emits code that names these by bare path rather than # through a re-export, so a consumer of the macro has to declare them too. -# Versions match worktable 1.0 beta's own, since a mismatch produces errors that name +# Versions match WorkTable 1.9's own, since a mismatch produces errors that name # the wrong crate. eyre = "0.6" rkyv = { version = "0.8.9", features = ["uuid-1"] } derive_more = { version = "2.0.1", features = ["from", "error", "display", "debug", "into"] } futures = "0.3" +nagoya = "^0.1" backon = { version = "1.6.0", default-features = false } libc = "0.2" dirs = "6" uuid = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } -tokio = { version = "1", features = ["process", "rt-multi-thread", "time"] } +# Tauri owns the live GUI runtime and exposes Tokio-native tasks. AgencyProxy's +# Unix sockets plus git/gh child processes also require Tokio's concrete I/O +# types, so this compatibility boundary remains direct and declares every API +# this crate calls instead of borrowing transitive features. +tokio = { version = "1", features = ["fs", "io-util", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } # Default features are off; see `webview-runtime` for why. tauri = { version = "2", default-features = false, features = ["macos-private-api", "compression"] } # `macos-private-api` must match the `tauri` dependency above. Tauri's @@ -101,7 +107,8 @@ tauri = { version = "2", default-features = false, features = ["macos-private-ap # 0.1.0 the runtime's own feature forwards to `tauri` and `tauri-runtime`, so # naming it here agrees with that rather than being the only thing holding the # two sides together. -tauri-runtime-blitz = { version = "^0.3", optional = true, features = ["macos-private-api"] } +tauri-runtime-blitz = { version = "^0.4", optional = true, features = ["macos-private-api"] } +blitz-control-protocol = { version = "^0.5", optional = true } # # The engine by version, not by branch. Same move `chuzz` made, for the same # reasons its manifest records. @@ -114,15 +121,14 @@ tauri-runtime-blitz = { version = "^0.3", optional = true, features = ["macos-pr # resolved anyrender from crates.io at 0.12.0, and the build stopped at # "failed to select a version". # -# A caret is what was meant. `cargo update` moves it, `Cargo.lock` records what -# was resolved, and anything else asking for the same range gets the same crate -# rather than a second copy of the engine - which is the failure that reads as -# `expected ScriptDocument, found ScriptDocument` with both types naming one -# file. Working against a local checkout stays possible through the `[patch]` -# entries in `.cargo/config.toml`, so the default exercises what a release -# actually resolves. -blitz-dom = { package = "ps-blitz-dom", version = "^0.3", features = ["system-fonts", "parallel-construct"], optional = true } -blitz-script = { package = "ps-blitz-script", version = "^0.3", features = ["system-fonts"], optional = true } +# A caret is what was meant. Every lockless build resolves the compatible family +# afresh, and anything else asking for the same range gets the same crate rather +# than a second copy of the engine. Two incompatible families produce the failure +# that reads as `expected ScriptDocument, found ScriptDocument` with both types +# naming one file. Local renderer work uses `.cargo/local-renderer.toml` through +# `scripts/local-renderer.sh`, so the default exercises the published graph. +blitz-dom = { package = "ps-blitz-dom", version = "^0.4", features = ["system-fonts", "parallel-construct"], optional = true } +blitz-script = { package = "ps-blitz-script", version = "^0.4", features = ["system-fonts"], optional = true } brotli = { version = "8.0.4", default-features = false, features = ["std"], optional = true } url = { version = "2.5.8", optional = true } tauri-plugin-updater = "2" diff --git a/apps/gui/build.rs b/apps/gui/build.rs index 5b8e75284..c5c9cf348 100644 --- a/apps/gui/build.rs +++ b/apps/gui/build.rs @@ -170,6 +170,10 @@ fn strip_unused_frameworks() { } fn main() { + println!( + "cargo:rustc-env=AZ_BUILD_TARGET={}", + std::env::var("TARGET").expect("Cargo supplies TARGET") + ); strip_unused_frameworks(); stamp_build(); if std::env::var_os("CARGO_FEATURE_BLITZ_RUNTIME").is_some() { diff --git a/apps/gui/frontend/package.json b/apps/gui/frontend/package.json index facffd4ba..c7d54bf21 100644 --- a/apps/gui/frontend/package.json +++ b/apps/gui/frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "private": true, "overrides": { + "@solidjs/signals": "2.0.0-rc.4", "babel-preset-solid": "^2.0.0-rc.0" }, "scripts": { @@ -23,14 +24,14 @@ }, "license": "MIT", "dependencies": { - "@pathscale/ui": "^2.11.6", - "@solidjs/web": "next", + "@pathscale/ui": "^3.2.3", + "@solidjs/web": "2.0.0-rc.4", "@tauri-apps/api": "^2.1.1", "clsx": "^2.1.1", "popmotion": "^11.0.5", "promptsyntax": "^0.1.0", - "solid-js": "next", - "solid-layouts": "^0.2.1", + "solid-js": "2.0.0-rc.4", + "solid-layouts": "^0.2.4", "tailwind-merge": "^3.6.0" }, "devDependencies": { diff --git a/apps/gui/frontend/src/api/fixtures.ts b/apps/gui/frontend/src/api/fixtures.ts index b0aaf0fd1..e2f84569b 100644 --- a/apps/gui/frontend/src/api/fixtures.ts +++ b/apps/gui/frontend/src/api/fixtures.ts @@ -418,6 +418,23 @@ export const AGENT_STATUS: AgentStatus[] = [ }, checkedAt: ago(2 * 60_000), }, + { + agent: "grok", + state: "connected", + version: "1.0.30", + minVersion: "1.0.30", + caps: ["fork", "thread id"], + capabilities: { + session: true, + fork: true, + events: true, + nativeSystem: true, + commands: true, + liveFollowUp: true, + approvals: true, + }, + checkedAt: ago(2 * 60_000), + }, ]; /** @@ -488,6 +505,23 @@ export const MODEL_CATALOGUE: AgentModels[] = [ pinned("gpt-5.4-mini", "GPT-5.4-Mini", "Small, fast, cost-efficient model.", TO_XHIGH), ], }, + { + agent: "grok", + source: "cli", + checked: "2026-09-13", + against: "grok 1.0.30", + discovered: false, + models: [ + pinned( + "grok-4.6", + "Grok 4.6", + "Default Grok Build model", + ["minimal", "low", "medium", "high", "xhigh"], + true, + ), + pinned("grok-4.5", "Grok 4.5", "", ["minimal", "low", "medium", "high", "xhigh"]), + ], + }, { agent: "copilot", source: "picker", @@ -591,6 +625,7 @@ export const SETTINGS: GlobalSettings = { default: "gpt-5.6-sol", }, copilot: { enabled: ["auto"], default: "auto" }, + grok: { enabled: ["grok-4.6", "grok-4.5"], default: "grok-4.6" }, }, defaultPermission: "read_only", defaultEffort: "high", diff --git a/apps/gui/frontend/src/api/mock.ts b/apps/gui/frontend/src/api/mock.ts index 8bc6f9e0b..d82848e63 100644 --- a/apps/gui/frontend/src/api/mock.ts +++ b/apps/gui/frontend/src/api/mock.ts @@ -92,7 +92,7 @@ function deepMerge(target: T, patch: DeepPartial): T { return out; } -const MODERATOR_AGENTS = ["claude", "codex", "copilot"] as const; +const MODERATOR_AGENTS = ["claude", "codex", "copilot", "grok"] as const; /** Mirror Rust's backwards-compatible moderator model normalization. */ function normalizeModeratorModel(settings: GlobalSettings): string { @@ -900,7 +900,7 @@ export function createMockApi(): AgencyZeroApi { */ listQuota: () => settle({ - agents: (["claude", "codex", "copilot"] as const).map((agent) => ({ + agents: (["claude", "codex", "copilot", "grok"] as const).map((agent) => ({ agent, supported: false, windows: [], diff --git a/apps/gui/frontend/src/components/PillMenu.tsx b/apps/gui/frontend/src/components/PillMenu.tsx index ad2ffa396..bcc105305 100644 --- a/apps/gui/frontend/src/components/PillMenu.tsx +++ b/apps/gui/frontend/src/components/PillMenu.tsx @@ -72,7 +72,11 @@ export function PillMenu(props: PillMenuProps): JSX.Element {(option) => ( diff --git a/apps/gui/frontend/src/features/analytics/AnalyticsTab.tsx b/apps/gui/frontend/src/features/analytics/AnalyticsTab.tsx index 6eb2ca33d..6728059a8 100644 --- a/apps/gui/frontend/src/features/analytics/AnalyticsTab.tsx +++ b/apps/gui/frontend/src/features/analytics/AnalyticsTab.tsx @@ -4,10 +4,12 @@ import { createMemo, createSignal, For, onSettled, Show } from "solid-js"; import { Button } from "~/components/Button"; import { Icon } from "~/components/Icon"; import { duration } from "~/lib/format"; +import { AGENT_LABELS } from "~/lib/labels"; import { whileMounted } from "~/lib/live"; import { tx } from "~/stores/i18n"; import { useWorkspace } from "~/stores/workspace"; import type { + Agent, UsageAgentValue, UsageAnalytics, UsageDay, @@ -237,11 +239,7 @@ function AgentValue(props: { agents: UsageAgentValue[] }): JSX.Element {
- {agent.agent === "codex" - ? "Codex" - : agent.agent === "claude" - ? "Claude" - : agent.agent} + {AGENT_LABELS[agent.agent as Agent] ?? agent.agent} {agent.costPerCompletedItem === null @@ -283,7 +281,9 @@ function SessionBreakdown(props: { sessions: UsageSession[] }): JSX.Element {
{session.projectName} - {session.agent} + + {AGENT_LABELS[session.agent as Agent] ?? session.agent} + {dollars(session.costUsd)} diff --git a/apps/gui/frontend/src/features/home/HomeTab.tsx b/apps/gui/frontend/src/features/home/HomeTab.tsx index ba5295219..36531002e 100644 --- a/apps/gui/frontend/src/features/home/HomeTab.tsx +++ b/apps/gui/frontend/src/features/home/HomeTab.tsx @@ -131,7 +131,17 @@ export function HomeTab(): JSX.Element {
+ {/* + `md` spelled out because 3.0 moved the `Input` default from + `md` to `sm` so an unsized field lines up with an unsized + Button. Nothing here sets a height, so the field's own + min-height is what makes this row 2.5rem tall; taking the new + default would silently shorten every field in the app by 4px. + Which of these want the shorter control is a look decision, + not a migration one. + */}
- {state.running[project.id]?.length + {state.running[project.id]?.length || project.id in state.runStatus ? tx("running now") : relativeTime(project.lastActivityAt)} @@ -357,7 +367,19 @@ export function CleanupRowActions(props: { checked state={busy() !== null ? "disabled" : undefined} aria-label={tx("Delete {name}", { name: props.item.title })} - onChange={(event) => { + /* + * `onNativeChange` rather than `onChange`, which now reports only the + * boolean. + * + * The box is rendered permanently checked and unchecking it *is* the + * keep action, so a keep that fails has to put the tick back. Nothing + * in the store changed, so no re-render will do it: the restore is a + * write to the input element itself, and the native event is the only + * handler that still hands one over. A `ref` would not help either, + * because `Checkbox` sets its own on the input after spreading the + * caller's props. + */ + onNativeChange={(event) => { if (event.currentTarget.checked) return; const checkbox = event.currentTarget; void run("keep", props.onKeep).catch(() => { @@ -656,6 +678,7 @@ function TaskManagerComposer(): JSX.Element { when={tall()} fallback={ setDraft(event.currentTarget.value)} @@ -999,6 +1022,7 @@ function GroupItemRow(props: { fallback={ ; const SECURITY: GuidedPermission[] = ["read_only", "ask", "auto"]; diff --git a/apps/gui/frontend/src/features/project/Composer.tsx b/apps/gui/frontend/src/features/project/Composer.tsx index 5d42b676d..a92d7bdb0 100644 --- a/apps/gui/frontend/src/features/project/Composer.tsx +++ b/apps/gui/frontend/src/features/project/Composer.tsx @@ -376,6 +376,13 @@ export function Composer(props: ComposerProps): JSX.Element { const compactPressure = () => { const tokens = props.contextTokens ?? 0; const window = props.contextWindow ?? 0; + // Grok 4.6/4.5 double prices at 200k, far below 80% of the 500k window. + if (props.agent === "grok") { + if (tokens >= 200_000) return "red" as const; + if (tokens >= 180_000) return "orange" as const; + if (tokens >= 150_000) return "yellow" as const; + return null; + } const share = window > 0 ? tokens / window : null; if (share !== null) { if (share >= 0.9) return "red" as const; @@ -906,7 +913,16 @@ export function Composer(props: ComposerProps): JSX.Element { lastLength = length; const height = Math.max(floor, Math.min(field.scrollHeight || floor, ceiling)); // Most keystrokes land inside the current line and change no height at all. - if (height !== lastHeight) { + // + // The `auto` reset above is the exception, and skipping the write after it + // is what made deleting text jump the box. `auto` is a real style write: it + // drops the explicit height, so the field is left sized by its own content + // and no longer clamped to `ceiling`. When a deletion removed a character + // without removing a line, the measured height matched `lastHeight`, this + // branch was skipped, and the field stayed on `auto` until some later + // keystroke happened to change the number. Always restore an explicit + // height once it has been cleared. + if (height !== lastHeight || mayHaveShrunk) { field.style.height = `${height}px`; lastHeight = height; } @@ -1234,7 +1250,14 @@ export function Composer(props: ComposerProps): JSX.Element { air above it, the controls only need to clear the edge. Even spacing made the row look adrift in the box rather than seated at its foot. */ - class={`flex flex-col gap-2.5 bg-az-inset ${ + /* + `overflow-hidden`: the inner radius is the ring's outer radius less + its 1px padding, so a child that reaches the padding box, such as + the first attachment chip, is drawn over the corner the ring + rounded. Without clipping here that chip squares off the top-left + while the untouched right corner stays round. + */ + class={`flex flex-col gap-2.5 overflow-hidden bg-az-inset ${ props.size === "lg" ? "rounded-[18px] p-[18px] pb-2.5" : "rounded-2xl p-[15px] pb-2" }`} > @@ -1314,7 +1337,7 @@ export function Composer(props: ComposerProps): JSX.Element { event.preventDefault(); void submit(); }} - class={`az-scroll block max-h-full min-h-0 w-full min-w-0 resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-0 bg-transparent p-0 text-base-content leading-[1.45] shadow-none [overflow-wrap:anywhere] placeholder:text-az-faint focus:bg-transparent focus:shadow-none focus:outline-none ${ + class={`az-scroll block max-h-full min-h-0 w-full min-w-0 resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-0 bg-transparent p-0 text-base-content leading-[1.45] shadow-none placeholder:text-az-faint focus:bg-transparent focus:shadow-none focus:outline-none ${ props.size === "lg" ? "text-ui-lead" : "text-ui-control-lg" }`} /> diff --git a/apps/gui/frontend/src/features/project/MessageBody.tsx b/apps/gui/frontend/src/features/project/MessageBody.tsx index a2ccc82ec..903914673 100644 --- a/apps/gui/frontend/src/features/project/MessageBody.tsx +++ b/apps/gui/frontend/src/features/project/MessageBody.tsx @@ -431,11 +431,15 @@ export function MessageBody(props: { id: string; body: string; class?: string }) long identifier, a url, a wall of one repeated letter) has no break opportunity without the second. Between them, message text stayed at its natural width and drew straight past the edge of its own bubble. + + `break-words` is `overflow-wrap: break-word`, which splits a word only + when that word alone cannot fit. This used to say `anywhere` as well. + The two differ in one way that matters here: `anywhere` also counts the + break opportunity when the renderer measures min-content width, so it + splits ordinary words mid-line and ends a line on "No" with "w" below. + `break-word` still rescues the long identifier without shredding prose. */ -
+
{(block, blockIndex) => block.kind === "code" ? ( diff --git a/apps/gui/frontend/src/features/project/ProjectPanel.tsx b/apps/gui/frontend/src/features/project/ProjectPanel.tsx index b9b71c060..cb3c6f13d 100644 --- a/apps/gui/frontend/src/features/project/ProjectPanel.tsx +++ b/apps/gui/frontend/src/features/project/ProjectPanel.tsx @@ -16,6 +16,7 @@ import { nextStatus, statusLabel, statusSuffix } from "~/lib/labels"; import { whileMounted } from "~/lib/live"; import { describeError, log } from "~/lib/log"; import { record as recordPerf } from "~/lib/perf"; +import { LIVE_TURN_ID } from "~/lib/running"; import { tx } from "~/stores/i18n"; import { prefs, setPrefs, togglePanelSection } from "~/stores/prefs"; import { useNow, useWorkspace } from "~/stores/workspace"; @@ -62,7 +63,7 @@ export function itemPage(items: readonly T[], limit: number): T[] { * you switch tabs. */ export function ProjectPanel(props: { project: Project; agent: Agent }): JSX.Element { - const { state, actions, itemsFor, openItemCount } = useWorkspace(); + const { state, actions, itemsFor, openItemCount, runningFor } = useWorkspace(); // One typed load for the whole panel. Leaf controls render this shared // snapshot and perform mutations; none owns a mount-time backend request. @@ -104,7 +105,7 @@ export function ProjectPanel(props: { project: Project; agent: Agent }): JSX.Ele ); }); - const running = () => state.running[props.project.id] ?? []; + const running = () => runningFor(props.project.id); // Named for what it holds, not `log`: the module-level logger is also in // scope here and the shadow made `log.info` resolve to this accessor. const taskLog = () => state.taskLog[props.project.id] ?? []; @@ -334,7 +335,7 @@ function IoPersistToggle(props: { projectId: string }): JSX.Element { id={`project-${props.projectId}-agent-io-persist`} checked={enabled()} state={!isLive("setIoPersist") ? "disabled" : undefined} - onChange={(event) => void toggle(event.currentTarget.checked)} + onChange={(checked) => void toggle(checked)} title={tx( "Keep this project's raw exchange in the database, so it survives a restart. Off by default: a long run writes thousands of rows.", )} @@ -525,7 +526,8 @@ function SettingsSection(props: { project: Project; agent: Agent }): JSX.Element const [path, setPath] = createSignal(""); const moderatorDefault = () => state.settings?.moderator.enabled ?? true; - const isRunning = () => (state.running[props.project.id] ?? []).length > 0; + const isRunning = () => + props.project.id in state.runStatus || (state.running[props.project.id] ?? []).length > 0; /** The native panel, then straight into the list: no second confirmation. */ async function pick(): Promise { @@ -624,7 +626,11 @@ function SettingsSection(props: { project: Project; agent: Agent }): JSX.Element > + {/* `md` spelled out: 3.0 moved the `Input` default to `sm`, and + these fields set no height of their own, so the implicit `md` + is what their rows are currently built on. */} - void actions.setProjectModerator(props.project.id, event.currentTarget.checked) - } + onChange={(checked) => void actions.setProjectModerator(props.project.id, checked)} />
@@ -1016,6 +1020,7 @@ function ResumeSession(props: {
(state.running[props.projectId] ?? []).length > 0; + const isRunning = () => + props.projectId in state.runStatus || (state.running[props.projectId] ?? []).length > 0; /** * Prefer an unanswered question, but keep the newest dismissed one reachable. @@ -1414,6 +1421,7 @@ function ItemList(props: { projectId: string; items: ProjectItem[] }): JSX.Eleme
{(draft) => ( -
+ /* `flex-wrap`, because this row carries a button labelled + "Link a GitHub issue" whose width does not depend on the + panel's. Without it the only flexible child is the input, + and the row balances by taking the field down to 27px: + still painted, still focusable, no longer a URL editor. + Wrapping moves the buttons to a second line instead. */ +
{ issueField = element; @@ -1891,7 +1907,7 @@ function ItemList(props: { projectId: string; items: ProjectItem[] }): JSX.Eleme if (event.key === "Enter") void saveIssue(); if (event.key === "Escape") setIssueDraft(null); }} - class="min-w-0 flex-1 rounded-lg border border-primary/28 bg-base-300 px-2.5 py-1.5 font-mono text-az-body text-ui-detail outline-none placeholder:text-az-faint focus:border-primary/60" + class="min-w-[7rem] flex-1 rounded-lg border border-primary/28 bg-base-300 px-2.5 py-1.5 font-mono text-az-body text-ui-detail outline-none placeholder:text-az-faint focus:border-primary/60" />
@@ -1053,7 +1072,12 @@ export function SettingsTab(): JSX.Element { icon="sparkles" value={current().taskManager.agent} options={state.agents - .filter((status) => status.agent === "claude" || status.agent === "codex") + .filter( + (status) => + status.agent === "claude" || + status.agent === "codex" || + status.agent === "grok", + ) .map((status) => ({ value: status.agent, label: AGENT_LABELS[status.agent], @@ -1377,9 +1401,9 @@ export function SettingsTab(): JSX.Element { checked={current().theme.glassEnabled !== false} flavor="accent" class="shrink-0" - onChange={(event) => + onChange={(checked) => actions.saveSettings({ - theme: { glassEnabled: event.currentTarget.checked }, + theme: { glassEnabled: checked }, }) } /> @@ -2591,6 +2615,7 @@ function TaskManagerDirs(props: { taskManager: TaskManagerSettings }): JSX.Eleme )} settingsQuery().trim() === "" || titleMatches() || hits().size > 0; + // The predicate lives in `searchVisibility.ts` so it can be tested: this + // component cannot be mounted under vitest (see `vitest.config.ts`), and the + // re-index case it exists for is the one that broke. + const visible = () => + sectionIsVisible({ + query: settingsQuery().trim(), + titleMatches: titleMatches(), + hits: hits().size, + mounted: mounted(), + indexedCorpus: indexedCorpus(), + }); const report = (label: string, hit: boolean): void => { setHits((prev) => { const next = new Set(prev); @@ -3807,7 +3850,9 @@ function SettingToggle(props: { disabled={props.disabled} flavor="accent" class="shrink-0" - onChange={(event) => props.onChange(event.currentTarget.checked)} + // `Switch` reports the new checked state directly, which is already this + // component's own contract, so there is nothing left to unwrap. + onChange={(checked) => props.onChange(checked)} /> ); } @@ -3880,10 +3925,10 @@ function HoldRow(props: { /** * One agent's catalogue, with its provenance stated rather than implied. * - * The provenance line is not decoration: two of the three lists were not - * obtained from the installed binary, and a picker that presents a documented - * list and an interrogated one identically invites the user to trust both - * equally. + * The provenance line is not decoration: Claude and Copilot lists were not + * obtained from the installed binary. Codex and Grok can be asked. A picker + * that presents a documented list and an interrogated one identically invites + * the user to trust both equally. */ function AgentModelList(props: { catalogue: AgentModels; selection: ModelSelection }): JSX.Element { const agent = () => props.catalogue.agent; @@ -3957,9 +4002,7 @@ function ModelRow(props: { checked={props.isEnabled} state={props.isLastEnabled ? "disabled" : undefined} aria-label={tx("Offer {name}", { name: props.model.name })} - onChange={(event) => - void actions.toggleModel(props.agent, props.model.id, event.currentTarget.checked) - } + onChange={(checked) => void actions.toggleModel(props.agent, props.model.id, checked)} />
diff --git a/apps/gui/frontend/src/features/settings/searchVisibility.test.ts b/apps/gui/frontend/src/features/settings/searchVisibility.test.ts new file mode 100644 index 000000000..9bb92af8a --- /dev/null +++ b/apps/gui/frontend/src/features/settings/searchVisibility.test.ts @@ -0,0 +1,78 @@ +/* + * @vitest-environment node + * + * The decision under test is a predicate over four booleans-worth of state, so + * it is tested as one. The component it came from cannot be mounted here: + * `vitest.config.ts` records that mounting `SettingsTab` halts the reactive + * system mid-boot, which is why this logic lives in its own module. + */ +import { describe, expect, it } from "vitest"; +import { type SectionSearchState, sectionIsVisible } from "./searchVisibility"; + +/** A settled section that answers the query through one of its rows. */ +const matching: SectionSearchState = { + query: "codex", + titleMatches: false, + hits: 1, + mounted: true, + indexedCorpus: "choose a session from codex cli / ide", +}; + +describe("a settings section under a live search", () => { + it("shows itself when a row has answered the query", () => { + expect(sectionIsVisible(matching)).toBe(true); + }); + + it("hides when it is settled and nothing in it matches", () => { + expect(sectionIsVisible({ ...matching, hits: 0 })).toBe(false); + }); + + it("shows everything when the search box is empty", () => { + expect(sectionIsVisible({ ...matching, query: "", hits: 0 })).toBe(true); + }); + + /* + * The regression. Changing interface language retracts every section's + * corpus at once, because it is keyed by locale. The section is still + * mounted and still matches, but no row has reported against the new key + * yet, so `hits` is momentarily 0. Reading that as "nothing matches" is what + * made the Codex import picker unreachable after a Chinese round trip: ps-qa + * revealed the control and then could not find it. + */ + it("stays visible while it is re-indexing after a language change", () => { + const reindexing: SectionSearchState = { + ...matching, + hits: 0, + indexedCorpus: undefined, + }; + expect(sectionIsVisible(reindexing)).toBe(true); + }); + + /* + * The same emptiness must not keep an unmounted section on screen: with no + * tree built there is nothing to re-index and nothing to show. + */ + it("does not show an unmounted section merely for lacking a corpus", () => { + const unmounted: SectionSearchState = { + ...matching, + hits: 0, + mounted: false, + indexedCorpus: undefined, + }; + expect(sectionIsVisible(unmounted)).toBe(false); + }); + + /* + * Once the rows have reported against the new language and none of them + * answer, the ordinary predicate takes over and the section hides. The + * re-index allowance is a window, not a permanent exemption. + */ + it("hides again once re-indexing settles on no match", () => { + const settled: SectionSearchState = { + ...matching, + hits: 0, + indexedCorpus: "unrelated words", + }; + expect(sectionIsVisible(settled)).toBe(false); + }); +}); diff --git a/apps/gui/frontend/src/features/settings/searchVisibility.ts b/apps/gui/frontend/src/features/settings/searchVisibility.ts new file mode 100644 index 000000000..b2eccb84a --- /dev/null +++ b/apps/gui/frontend/src/features/settings/searchVisibility.ts @@ -0,0 +1,50 @@ +/** + * Whether a settings section shows itself, and whether it keeps the retention + * it earned, while the search corpus is being rebuilt. + * + * The corpus is keyed by interface language (`${locale}:${id}`) and learned + * once per section, so changing language retracts every section's index at the + * same instant. That is the case these two answers exist for, and getting it + * wrong is not theoretical: with a query still in the search box, a Chinese + * round trip made the Codex import picker unreachable. ps-qa navigated to it, + * logged that it had revealed the control, and then could not find it. + * + * Pure, and separate from `SettingsTab.tsx`, because the component cannot be + * mounted under test: the suite runs on `node` with no DOM, and + * `vitest.config.ts` records that mounting `SettingsTab` halts the reactive + * system mid-boot. The decision is a property of these four inputs, so it is + * testable as one. + */ + +/** What the section knows when it decides whether to show itself. */ +export type SectionSearchState = { + /** The trimmed contents of the settings search box. */ + query: string; + /** Whether the section's own title or hint answers the query. */ + titleMatches: boolean; + /** How many of the section's rows have reported a match so far. */ + hits: number; + /** Whether the section's control tree is built. */ + mounted: boolean; + /** + * The section's indexed words for the *current* language, or `undefined` + * when it has not been indexed yet under that key. + */ + indexedCorpus: string | undefined; +}; + +/** + * Whether the section is on screen. + * + * `hits === 0` has two meanings and only one of them is "nothing matches". The + * other is "no row has reported yet", which is the state every section passes + * through while it re-indexes after a language change. Hiding on that is what + * made a matching section vanish, so a mounted section with no corpus for the + * current language stays visible; it settles an instant later when its rows + * report, and the ordinary predicate takes over. + */ +export function sectionIsVisible(state: SectionSearchState): boolean { + if (state.query === "") return true; + if (state.titleMatches || state.hits > 0) return true; + return state.mounted && state.indexedCorpus === undefined; +} diff --git a/apps/gui/frontend/src/features/shell/CloseConfirm.tsx b/apps/gui/frontend/src/features/shell/CloseConfirm.tsx index 83b0b7888..a99152987 100644 --- a/apps/gui/frontend/src/features/shell/CloseConfirm.tsx +++ b/apps/gui/frontend/src/features/shell/CloseConfirm.tsx @@ -23,13 +23,17 @@ export type CloseConfirmProps = { * tell you what you are about to lose trains you to dismiss it. */ export function CloseConfirm(props: CloseConfirmProps): JSX.Element { - const { state } = useWorkspace(); + const { state, runningFor } = useWorkspace(); // Optional chaining because a purged project can leave one record with a key - // another lacks, so a value can be absent under an existing key. - const runningCount = createMemo(() => - Object.values(state.running).reduce((total, tasks) => total + (tasks?.length ?? 0), 0), - ); + // another lacks, so a value can be absent under an existing key. A live turn + // with no in-flight tool still counts: quitting would kill that run. + const runningCount = createMemo(() => { + const ids = new Set([...Object.keys(state.running), ...Object.keys(state.runStatus)]); + let total = 0; + for (const id of ids) total += runningFor(id).length; + return total; + }); const heldCount = createMemo( () => diff --git a/apps/gui/frontend/src/i18n/ui/en.ts b/apps/gui/frontend/src/i18n/ui/en.ts index 61f1669e1..c07756c26 100644 --- a/apps/gui/frontend/src/i18n/ui/en.ts +++ b/apps/gui/frontend/src/i18n/ui/en.ts @@ -99,8 +99,8 @@ const en = { Models: "Models", "what each picker offers": "what each picker offers", "Re-read from the CLIs": "Re-read from the CLIs", - "only Codex can enumerate; the other two stay on the compiled list": - "only Codex can enumerate; the other two stay on the compiled list", + "Codex and Grok can enumerate; Claude and Copilot stay on the compiled list": + "Codex and Grok can enumerate; Claude and Copilot stay on the compiled list", "Task Manager": "Task Manager", "the Home conversation that keeps the lists in order": "the Home conversation that keeps the lists in order", diff --git a/apps/gui/frontend/src/i18n/ui/zh.ts b/apps/gui/frontend/src/i18n/ui/zh.ts index de8e4951c..f901b8af0 100644 --- a/apps/gui/frontend/src/i18n/ui/zh.ts +++ b/apps/gui/frontend/src/i18n/ui/zh.ts @@ -97,8 +97,8 @@ const zh = { Models: "模型", "what each picker offers": "各选择器提供的选项", "Re-read from the CLIs": "从 CLI 重新读取", - "only Codex can enumerate; the other two stay on the compiled list": - "只有 Codex 可动态枚举;另外两个使用编译时列表", + "Codex and Grok can enumerate; Claude and Copilot stay on the compiled list": + "Codex 和 Grok 可动态枚举;Claude 和 Copilot 使用编译时列表", "Task Manager": "任务管理器", "the Home conversation that keeps the lists in order": "负责整理列表的主页对话", "Task manager agent": "任务管理器智能体", diff --git a/apps/gui/frontend/src/lib/labels.ts b/apps/gui/frontend/src/lib/labels.ts index 179effef9..82a0649f3 100644 --- a/apps/gui/frontend/src/lib/labels.ts +++ b/apps/gui/frontend/src/lib/labels.ts @@ -44,6 +44,7 @@ export const AGENT_LABELS: Record = { claude: "Claude", codex: "Codex", copilot: "Copilot", + grok: "Grok", }; export const AGENT_STATE_LABELS: Record = { diff --git a/apps/gui/frontend/src/lib/running.test.ts b/apps/gui/frontend/src/lib/running.test.ts new file mode 100644 index 000000000..22452a28c --- /dev/null +++ b/apps/gui/frontend/src/lib/running.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { LIVE_TURN_ID, runningRows } from "~/lib/running"; +import type { RunningTask } from "~/types"; + +const tool = (id: string): RunningTask => ({ + toolCallId: id, + projectId: "grow", + itemId: null, + name: "list_dir", + label: "/tmp", + startedAt: "2026-09-14T12:00:00.000Z", + isCancelable: true, +}); + +const turn = { + agent: "grok" as const, + activity: "thinking…", + startedAt: Date.parse("2026-09-14T12:00:10.000Z"), +}; + +describe("runningRows", () => { + it("lists in-flight tools and ignores the live turn while they are open", () => { + const rows = runningRows("grow", [tool("a"), tool("b")], turn); + expect(rows.map((row) => row.toolCallId)).toEqual(["a", "b"]); + }); + + it("keeps Claude and Codex tool rows; the turn fallback does not replace them", () => { + const bash: RunningTask = { + toolCallId: "tc-bash", + projectId: "worktable", + itemId: "worktable-0", + name: "Bash", + label: "cargo test -p az-core", + startedAt: "2026-09-14T12:00:00.000Z", + isCancelable: true, + }; + const claudeTurn = { + agent: "claude" as const, + activity: "running Bash…", + startedAt: Date.parse("2026-09-14T12:00:00.000Z"), + }; + expect(runningRows("worktable", [bash], claudeTurn)).toEqual([bash]); + }); + + it("shows the live turn when a run is accepted and no tool is in flight", () => { + const rows = runningRows("grow", [], turn); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + toolCallId: LIVE_TURN_ID, + projectId: "grow", + name: "grok", + label: "thinking…", + isCancelable: true, + }); + expect(rows[0].startedAt).toBe("2026-09-14T12:00:10.000Z"); + }); + + it("treats a missing tool list the same as an empty one", () => { + expect(runningRows("grow", undefined, turn)).toHaveLength(1); + }); + + it("is empty when nothing is running", () => { + expect(runningRows("grow", [], undefined)).toEqual([]); + expect(runningRows("grow", undefined, undefined)).toEqual([]); + }); +}); diff --git a/apps/gui/frontend/src/lib/running.ts b/apps/gui/frontend/src/lib/running.ts new file mode 100644 index 000000000..8bfcf925d --- /dev/null +++ b/apps/gui/frontend/src/lib/running.ts @@ -0,0 +1,44 @@ +import type { Agent, RunningTask } from "~/types"; + +/** + * The live-turn row the Running panel shows when a run is accepted but no + * tool is in flight. Fast calls (any agent) finish in the same tick, so the + * tool list is empty for most of a working turn; this row is what keeps the + * panel from reading as idle. In-flight tools still win for every agent. + * + * Never matches a `task:finished` id — it is derived, not stored. + */ +export const LIVE_TURN_ID = "__turn__"; + +/** The slice of `RunStatus` the panel needs. Kept local so this file stays store-free. */ +export type LiveTurn = { + agent: Agent; + activity: string; + /** Wall-clock ms when the send was accepted. */ + startedAt: number; +}; + +/** + * What the Running panel lists: in-flight tools if any, otherwise one row + * for the live turn. Empty only when nothing is actually running. + */ +export function runningRows( + projectId: string, + tools: readonly RunningTask[] | undefined, + turn: LiveTurn | undefined, +): RunningTask[] { + const live = tools ?? []; + if (live.length > 0) return [...live]; + if (!turn) return []; + return [ + { + toolCallId: LIVE_TURN_ID, + projectId, + itemId: null, + name: turn.agent, + label: turn.activity, + startedAt: new Date(turn.startedAt).toISOString(), + isCancelable: true, + }, + ]; +} diff --git a/apps/gui/frontend/src/lib/stats.test.ts b/apps/gui/frontend/src/lib/stats.test.ts index 01d762b39..d8fd994ac 100644 --- a/apps/gui/frontend/src/lib/stats.test.ts +++ b/apps/gui/frontend/src/lib/stats.test.ts @@ -224,6 +224,15 @@ describe("the accumulation rule", () => { ]); expect(contextUsed(totals)).toBe(1); }); + + it("ignores a billed turn aggregate that cannot be occupancy", () => { + const totals = usageTotals([ + turn("agent", usage({ tokens: 100, contextTokens: 71_933, contextWindow: 500_000 })), + turn("agent", usage({ tokens: 7_641_353, contextTokens: 7_641_353, contextWindow: 500_000 })), + ]); + expect(totals.contextTokens).toBe(71_933); + expect(contextUsed(totals)).toBeCloseTo(71_933 / 500_000, 6); + }); }); /* diff --git a/apps/gui/frontend/src/lib/stats.ts b/apps/gui/frontend/src/lib/stats.ts index 1c0899031..0cafd8bbd 100644 --- a/apps/gui/frontend/src/lib/stats.ts +++ b/apps/gui/frontend/src/lib/stats.ts @@ -49,6 +49,17 @@ function isNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } +/** + * Occupancy is how full the window is *now*. Grok's turn `totalTokens` / + * multi-call `inputTokens` are billed sums across every model call (7.6M on + * a 500k window). Treating those as context makes the header a cumulative + * total clipped to 100%. Skip anything larger than twice the window. + */ +function isOccupancy(tokens: number, window: number | null): boolean { + if (!isNumber(window) || window <= 0) return tokens > 0 && tokens < 1_000_000; + return tokens > 0 && tokens <= window * 2; +} + /** * Add up the agent turns in `messages`. * @@ -88,12 +99,18 @@ export function usageTotals(messages: readonly Message[]): UsageTotals { * the conversation totals. */ if (message.author === "system" && message.stop === "completed" && message.usage) { - if (isNumber(message.usage.contextTokens)) { - totals.contextTokens = message.usage.contextTokens; - } if (isNumber(message.usage.contextWindow)) { totals.contextWindow = message.usage.contextWindow; } + if ( + isNumber(message.usage.contextTokens) && + isOccupancy( + message.usage.contextTokens, + message.usage.contextWindow ?? totals.contextWindow, + ) + ) { + totals.contextTokens = message.usage.contextTokens; + } continue; } // A live owner reply closes the text above it as a durable `continued` @@ -116,8 +133,13 @@ export function usageTotals(messages: readonly Message[]): UsageTotals { } // Latest wins for the context-shaped figures. - if (isNumber(usage.contextTokens)) totals.contextTokens = usage.contextTokens; if (isNumber(usage.contextWindow)) totals.contextWindow = usage.contextWindow; + if ( + isNumber(usage.contextTokens) && + isOccupancy(usage.contextTokens, usage.contextWindow ?? totals.contextWindow) + ) { + totals.contextTokens = usage.contextTokens; + } } return totals; @@ -179,10 +201,15 @@ export function withLiveContext( live: { contextTokens: number | null; contextWindow: number | null } | undefined, ): UsageTotals { if (!live) return totals; + const window = live.contextWindow ?? totals.contextWindow; + const liveContext = + isNumber(live.contextTokens) && isOccupancy(live.contextTokens, window) + ? live.contextTokens + : totals.contextTokens; return { ...totals, - contextTokens: live.contextTokens ?? totals.contextTokens, - contextWindow: live.contextWindow ?? totals.contextWindow, + contextTokens: liveContext, + contextWindow: window, }; } diff --git a/apps/gui/frontend/src/stores/models.test.tsx b/apps/gui/frontend/src/stores/models.test.tsx index def84f4c2..dc0123c7c 100644 --- a/apps/gui/frontend/src/stores/models.test.tsx +++ b/apps/gui/frontend/src/stores/models.test.tsx @@ -29,6 +29,7 @@ describe("the catalogue", () => { expect(workspace.state.models.map((entry) => entry.agent)).toEqual([ "claude", "codex", + "grok", "copilot", ]); }); @@ -155,7 +156,7 @@ describe("choosing models", () => { }); describe("what the prompt offers", () => { - it("offers the enabled Claude and OpenAI models, in catalogue order", async () => { + it("offers every enabled model, in catalogue order", async () => { const workspace = await mountWorkspace(); expect(workspace.promptModels().map((option) => option.value)).toEqual([ "claude:default", @@ -168,6 +169,8 @@ describe("what the prompt offers", () => { "codex:gpt-5.6-terra", "codex:gpt-5.6-luna", "codex:gpt-5.5", + "grok:grok-4.6", + "grok:grok-4.5", ]); }); @@ -186,7 +189,7 @@ describe("what the prompt offers", () => { const sonnet = workspace.promptModels().find((option) => option.value === "claude:sonnet"); const sol = workspace.promptModels().find((option) => option.value === "codex:gpt-5.6-sol"); expect(sonnet?.label).toBe("Claude · Sonnet"); - expect(sol?.label).toBe("OpenAI · GPT-5.6-Sol"); + expect(sol?.label).toBe("Codex · GPT-5.6-Sol"); }); it("follows the selection as it changes", async () => { diff --git a/apps/gui/frontend/src/stores/workspace.queue.test.tsx b/apps/gui/frontend/src/stores/workspace.queue.test.tsx index 8ab9de456..82f7cb22b 100644 --- a/apps/gui/frontend/src/stores/workspace.queue.test.tsx +++ b/apps/gui/frontend/src/stores/workspace.queue.test.tsx @@ -182,3 +182,55 @@ describe("queued live follow-ups", () => { ); }); }); + +describe("live turn in Running", () => { + it("treats an accepted turn with no in-flight tools as running", async () => { + const workspace = await mountWorkspace(); + expect(workspace.tabStatus("quux")).toBe("quiet"); + expect(workspace.runningFor("quux")).toEqual([]); + + queueHarness.handlers.get("run:accepted")?.({ + projectId: "quux", + agent: "codex", + model: "gpt-5.6-sol", + permission: "auto", + }); + flush(); + + expect(workspace.tabStatus("quux")).toBe("running"); + expect(workspace.runningFor("quux")).toEqual([ + expect.objectContaining({ + projectId: "quux", + name: "codex", + label: "waiting for the agent…", + isCancelable: true, + }), + ]); + }); + + it("still lists in-flight tools instead of the turn placeholder", async () => { + const workspace = await mountWorkspace(); + queueHarness.handlers.get("run:accepted")?.({ + projectId: "quux", + agent: "claude", + model: "opus", + permission: "auto", + }); + queueHarness.handlers.get("task:started")?.({ + toolCallId: "tc-bash", + projectId: "quux", + itemId: null, + name: "Bash", + label: "cargo test -p az-core", + startedAt: new Date().toISOString(), + isCancelable: true, + }); + flush(); + + const rows = workspace.runningFor("quux"); + expect(rows).toHaveLength(1); + expect(rows[0].toolCallId).toBe("tc-bash"); + expect(rows[0].name).toBe("Bash"); + expect(workspace.tabStatus("quux")).toBe("running"); + }); +}); diff --git a/apps/gui/frontend/src/stores/workspace.tsx b/apps/gui/frontend/src/stores/workspace.tsx index ce93a99f7..069bc8795 100644 --- a/apps/gui/frontend/src/stores/workspace.tsx +++ b/apps/gui/frontend/src/stores/workspace.tsx @@ -15,9 +15,10 @@ import { import type { AgencyZeroApi, AppEvents, Unlisten } from "~/api"; import { selectApi } from "~/api"; import { setItemReferenceHandler } from "~/lib/itemReference"; -import { PERMISSION_ORDER } from "~/lib/labels"; +import { AGENT_LABELS, PERMISSION_ORDER } from "~/lib/labels"; import { describeError, installGlobalErrorLogging, log } from "~/lib/log"; import { record as recordPerf } from "~/lib/perf"; +import { runningRows } from "~/lib/running"; import { usageTotals } from "~/lib/stats"; import { installSubscriptions, type SubscriptionFactory } from "~/lib/subscriptions"; import { applyTheme, windowChromeForTheme } from "~/lib/theme"; @@ -435,9 +436,9 @@ const HOME_TAB: Tab = { status: "quiet", }; -/** Project runs support these two providers; Copilot remains Settings-only. */ -function isProjectAgent(agent: Agent): agent is "claude" | "codex" { - return agent === "claude" || agent === "codex"; +/** Project runs support these providers; Copilot remains Settings-only. */ +function isProjectAgent(agent: Agent): agent is "claude" | "codex" | "grok" { + return agent === "claude" || agent === "codex" || agent === "grok"; } function compatiblePermission( @@ -699,7 +700,7 @@ export function createWorkspace() { } const status = state.agents.find((candidate) => candidate.agent === agent); if (status?.state === "connected") return; - const label = agent === "claude" ? "Claude" : agent === "codex" ? "Codex" : "Copilot"; + const label = AGENT_LABELS[agent]; throw new Error( `${label} is not ready. Install or sign in from Settings, then run the agent checks again.`, ); @@ -999,7 +1000,14 @@ export function createWorkspace() { "claude"; const limit = state.rateLimits[projectId]?.[selectedAgent]; if (limit?.isBlocking && isLimitLive(limit, clock())) return "blocked"; - if ((state.running[projectId] ?? []).length > 0) return "running"; + /* + * A live turn, not only an in-flight tool. Fast tools finish in the same + * tick they start, so `state.running` is empty for most of a working turn; + * `runStatus` exists from `run:accepted` to `run:stopped` for every agent. + */ + if (projectId in state.runStatus || (state.running[projectId] ?? []).length > 0) { + return "running"; + } /* * Idle, so the question is whether this project is still live work. @@ -1015,10 +1023,10 @@ export function createWorkspace() { /** Models enabled in Settings for the two project-capable agents. */ const promptModels = createMemo(() => { - return (["claude", "codex"] as const).flatMap((agent) => { + return (["claude", "codex", "grok"] as const).flatMap((agent) => { const catalogue = state.models.find((entry) => entry.agent === agent); const enabled = state.settings?.models[agent].enabled ?? []; - const provider = agent === "claude" ? "Claude" : "OpenAI"; + const provider = AGENT_LABELS[agent]; return (catalogue?.models ?? []) .filter((model) => enabled.includes(model.id)) .map((model) => ({ @@ -1073,6 +1081,14 @@ export function createWorkspace() { : PERMISSION_ORDER.filter((permission) => permission !== "ask"); } + /** + * What the Running panel lists. In-flight tools if any, otherwise one row + * for the live turn so a working agent does not read as idle. + */ + function runningFor(projectId: string): RunningTask[] { + return runningRows(projectId, state.running[projectId], state.runStatus[projectId]); + } + function itemsFor(projectId: string): ProjectItem[] { // Sorted here, not trusted from the array: a reorder arrives as // `item:updated` events that change `order` in place, and the array's @@ -1752,8 +1768,10 @@ export function createWorkspace() { return state.settings?.defaultEffort ?? FALLBACK_EFFORT; } - function defaultAgent(): "claude" | "codex" { - return state.settings?.defaultAgent === "codex" ? "codex" : "claude"; + function defaultAgent(): "claude" | "codex" | "grok" { + const agent = state.settings?.defaultAgent; + if (agent === "codex" || agent === "grok") return agent; + return "claude"; } function defaultModel(): string { @@ -2715,7 +2733,7 @@ export function createWorkspace() { * editing an unrelated setting should not silently reset it. */ function reconcileTabModels(settings: GlobalSettings): void { - const defaultAgent = settings.defaultAgent === "codex" ? "codex" : "claude"; + const defaultAgent = isProjectAgent(settings.defaultAgent) ? settings.defaultAgent : "claude"; const selection = settings.models[defaultAgent]; if (!selection || selection.enabled.length === 0) return; @@ -3784,6 +3802,7 @@ export function createWorkspace() { capabilitiesFor, permissionsFor, itemsFor, + runningFor, openItemCount, promptModels, init, diff --git a/apps/gui/frontend/src/styles/windowTransparency.test.ts b/apps/gui/frontend/src/styles/windowTransparency.test.ts index a6ec78b8e..5da034661 100644 --- a/apps/gui/frontend/src/styles/windowTransparency.test.ts +++ b/apps/gui/frontend/src/styles/windowTransparency.test.ts @@ -53,14 +53,10 @@ describe("the window can be seen through", () => { it.each(WINDOW_CONFIGS)("keeps %s compatible with the transparent base window", (file) => { const window = JSON.parse(readFileSync(join(GUI, file), "utf8")).app.windows[0]; expect(window.backgroundColor).toBeUndefined(); - if (file === "tauri.conf.json") { - expect(window.transparent).toBe(true); - } else { - // Variant configs merge over the base. An explicit false here silently - // turns only that profile opaque while the frontend still attaches the - // native glass backdrop, which washes a dark theme white at opacity 0. - expect(window.transparent).not.toBe(false); - } + // Tauri replaces the base window entry with the variant entry rather than + // merging each field by label. Every profile must therefore repeat this + // native precondition or it silently becomes opaque. + expect(window.transparent).toBe(true); }); /* diff --git a/apps/gui/frontend/src/types/index.ts b/apps/gui/frontend/src/types/index.ts index 5725b7ff3..69b3d4373 100644 --- a/apps/gui/frontend/src/types/index.ts +++ b/apps/gui/frontend/src/types/index.ts @@ -55,8 +55,8 @@ export type ProjectStatus = */ export type TabStatus = "running" | "blocked" | "error" | "ready" | "quiet"; -/** `Agent` in the crate. Settings covers all three; project tabs expose Claude and Codex. */ -export type Agent = "claude" | "codex" | "copilot"; +/** `Agent` in the crate. Settings covers all four; project tabs expose Claude, Codex, and Grok. */ +export type Agent = "claude" | "codex" | "copilot" | "grok"; /** `Permission` in the crate. `read_only` is the default and widens deliberately. */ export type Permission = "read_only" | "plan" | "ask" | "edit" | "auto" | "bypass"; @@ -303,7 +303,10 @@ export interface ReviewMetadata { headSha: string; } -/** Live now — one per `Event::ToolCall` with no result yet. */ +/** + * Live now — one per in-flight `Event::ToolCall`, or a derived turn row when + * a run is accepted and no tool is open (fast tools often finish same-tick). + */ export interface RunningTask { /** The crate's `ToolCall::id`; null when the agent does not give one. */ toolCallId: string | null; @@ -675,7 +678,7 @@ export interface WorkspaceTabs { export interface ReviewSettings { /** The review instruction, prepended to the PR URL. Empty uses the default. */ prompt: string; - /** Model per reviewer agent ("claude" / "codex" / "copilot"); empty is default. */ + /** Model per reviewer agent ("claude" / "codex" / "copilot" / "grok"); empty is default. */ models: Record; } @@ -1220,6 +1223,8 @@ export interface RateLimit { message: string; /** ISO 8601, or null when the provider does not say. */ resetsAt: string | null; + /** 0–100, when Grok reports weekly window fill. */ + usedPercent?: number | null; } /** What `create_project` hands back once the first reply lands. */ diff --git a/apps/gui/src/agent_proxy.rs b/apps/gui/src/agent_proxy.rs index 88712dc00..fae630233 100644 --- a/apps/gui/src/agent_proxy.rs +++ b/apps/gui/src/agent_proxy.rs @@ -246,7 +246,7 @@ impl AgencyProxy { return Ok(run_ids.len()); } - let deadline = tokio::time::Instant::now() + CANCEL_CONFIRMATION; + let deadline = crate::runtime::deadline_in(CANCEL_CONFIRMATION); loop { let snapshots = match client .request(ClientMessage::ListRuns) @@ -260,14 +260,14 @@ impl AgencyProxy { if still_active.is_empty() { return Ok(run_ids.len()); } - if tokio::time::Instant::now() >= deadline { + if nagoya::now_ns() >= deadline { return Err(format!( "AgencyProxy did not stop {} project run(s) within {}s", still_active.len(), CANCEL_CONFIRMATION.as_secs() )); } - tokio::time::sleep(Duration::from_millis(100)).await; + nagoya::sleep(Duration::from_millis(100)).await; } } @@ -337,7 +337,7 @@ impl AgencyProxy { .await .is_ok() { - tokio::time::sleep(Duration::from_millis(100)).await; + nagoya::sleep(Duration::from_millis(100)).await; } } *self @@ -475,7 +475,7 @@ impl AgencyProxy { SHUTDOWN_CONFIRMATION.as_secs() )); } - tokio::time::sleep(Duration::from_millis(100)).await; + nagoya::sleep(Duration::from_millis(100)).await; } self.disconnected_status(detail.into()) } @@ -604,6 +604,9 @@ impl AgencyProxy { .arg("--socket") .arg(&self.socket_path) .stdin(Stdio::null()); + if let Some(path) = std::env::var_os("PATH") { + command.env("PATH", path); + } if let Some(stderr) = captured { command.stdout( stderr @@ -643,7 +646,7 @@ impl AgencyProxy { self.set_connection_state(ConnectionState::Live); return Ok(client); } - Err(_) => tokio::time::sleep(Duration::from_millis(50)).await, + Err(_) => nagoya::sleep(Duration::from_millis(50)).await, } } Err(self.record_failure(proxy_startup_failure(&output_path, &self.socket_path))) @@ -916,7 +919,7 @@ async fn shutdown_legacy_proxy(client: &Client, mode: ShutdownMode) -> Result<() ServerResponse::Error { code: ErrorCode::Conflict, .. - } => tokio::time::sleep(Duration::from_millis(100)).await, + } => nagoya::sleep(Duration::from_millis(100)).await, response => return Err(response_error(response)), } } @@ -1025,7 +1028,7 @@ impl ProxyControl { } pub async fn send(&self, body: &str, interaction_id: &str) -> Result<(), String> { - let deadline = tokio::time::Instant::now() + INJECTION_CONFIRMATION; + let deadline = crate::runtime::deadline_in(INJECTION_CONFIRMATION); loop { let response = self .client @@ -1044,12 +1047,12 @@ impl ProxyControl { ServerResponse::Error { code: ErrorCode::Conflict, .. - } if tokio::time::Instant::now() < deadline => { + } if nagoya::now_ns() < deadline => { // The run owns its slot before Codex has finished opening // the turn. Keep this ordered steer in flight until the // daemon can attach it instead of handing it back to the // visible prompt queue. - tokio::time::sleep(Duration::from_millis(50)).await; + nagoya::sleep(Duration::from_millis(50)).await; } response => return accepted(response), } @@ -1242,7 +1245,7 @@ impl ProxyRun { let waiting_since = std::time::Instant::now(); let mut seen = 0u32; while self.terminal.is_none() { - if tokio::time::timeout(CANCEL_CONFIRMATION, self.recv()) + if nagoya::timeout(CANCEL_CONFIRMATION, self.recv()) .await .is_err() { diff --git a/apps/gui/src/agents.rs b/apps/gui/src/agents.rs index ed2f80033..fc2270af6 100644 --- a/apps/gui/src/agents.rs +++ b/apps/gui/src/agents.rs @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf}; pub const KEY: &str = "agents"; /// Every agent this build can drive. -pub const AGENTS: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Copilot]; +pub const AGENTS: [Agent; 4] = [Agent::Claude, Agent::Codex, Agent::Copilot, Agent::Grok]; /// Add conventional GUI-invisible agent executable directories to `PATH`. /// @@ -36,9 +36,13 @@ pub(crate) fn with_user_local_bin(path: &OsStr, home: Option<&Path>) -> OsString ]); } #[cfg(target_os = "macos")] - candidates.push(PathBuf::from( - "/Applications/cmux.app/Contents/Resources/bin", - )); + { + candidates.push(PathBuf::from("/opt/homebrew/bin")); + candidates.push(PathBuf::from("/usr/local/bin")); + candidates.push(PathBuf::from( + "/Applications/cmux.app/Contents/Resources/bin", + )); + } for candidate in candidates { if !entries.contains(&candidate) { entries.push(candidate); @@ -129,17 +133,41 @@ pub struct ProviderCapabilitiesDto { /// Probe every agent concurrently. /// /// Concurrent because each probe spawns a process and waits on `--version` plus -/// an auth check; run in series, three agents make Settings visibly slow to +/// an auth check; run in series, four agents make Settings visibly slow to /// open. pub async fn detect_all( proxy: &crate::agent_proxy::AgencyProxy, ) -> Result, String> { - proxy + let mut statuses: Vec = proxy .probe_providers() .await? .into_iter() .map(status_from_proxy) - .collect() + .collect::, _>>()?; + for agent in AGENTS { + if statuses.iter().any(|status| status.agent == agent) { + continue; + } + statuses.push(status_from_proxy(ProviderStatus { + provider: match agent { + Agent::Claude => "claude", + Agent::Codex => "codex", + Agent::Copilot => "copilot", + Agent::Grok => "grok", + } + .into(), + installed: false, + version: None, + outdated: false, + auth_state: "unknown".into(), + detail: String::new(), + auth_method: None, + account: None, + plan: None, + login_hint: String::new(), + })?); + } + Ok(statuses) } fn status_from_proxy(status: ProviderStatus) -> Result { @@ -147,6 +175,7 @@ fn status_from_proxy(status: ProviderStatus) -> Result { "claude" => Agent::Claude, "codex" => Agent::Codex, "copilot" => Agent::Copilot, + "grok" => Agent::Grok, other => return Err(format!("AgencyProxy reported an unknown provider: {other}")), }; let checked_at = chrono::Utc::now().to_rfc3339(); @@ -243,8 +272,29 @@ mod tests { assert_eq!(entries[3], home.join(".npm-global/bin")); assert_eq!(entries[4], home.join(".volta/bin")); #[cfg(target_os = "macos")] + { + assert_eq!(entries[5], Path::new("/usr/local/bin")); + assert_eq!( + entries[6], + Path::new("/Applications/cmux.app/Contents/Resources/bin") + ); + } + } + + /// The dedup in the test above hides the macOS candidates: its input PATH + /// already contains `/opt/homebrew/bin`, so the appended copy is dropped + /// and the indices line up whatever order the candidates are pushed in. + /// Start from a PATH holding none of them so the order is actually read. + #[cfg(target_os = "macos")] + #[test] + fn macos_candidates_are_appended_in_order() { + let amended = with_user_local_bin(OsStr::new("/usr/bin"), None); + let entries: Vec<_> = std::env::split_paths(&amended).collect(); + assert_eq!(entries[0], Path::new("/usr/bin")); + assert_eq!(entries[1], Path::new("/opt/homebrew/bin")); + assert_eq!(entries[2], Path::new("/usr/local/bin")); assert_eq!( - entries[5], + entries[3], Path::new("/Applications/cmux.app/Contents/Resources/bin") ); } @@ -270,6 +320,7 @@ mod tests { ("claude", true, false, "logged_in"), ("codex", true, true, "logged_in"), ("copilot", false, false, "unknown"), + ("grok", true, false, "logged_in"), ] { let status = status_from_proxy(ProviderStatus { provider: provider.into(), @@ -332,11 +383,16 @@ mod tests { fn structured_caps_distinguish_interactive_providers() { let claude = provider_capabilities(Agent::Claude); let codex = provider_capabilities(Agent::Codex); + let grok = provider_capabilities(Agent::Grok); assert!(claude.live_follow_up); assert!(claude.approvals); assert!(claude.commands); assert!(codex.live_follow_up); assert!(codex.approvals); assert!(!codex.commands); + assert!(grok.live_follow_up); + assert!(grok.approvals); + assert!(grok.commands); + assert!(grok.fork); } } diff --git a/apps/gui/src/cancel.rs b/apps/gui/src/cancel.rs new file mode 100644 index 000000000..4cf6446d1 --- /dev/null +++ b/apps/gui/src/cancel.rs @@ -0,0 +1,669 @@ +//! Cancellation that is waited on, never polled. +//! +//! # Why this exists +//! +//! The run loop's stop signal was a `tokio::sync::watch::Sender`, waited +//! on as `cancel.changed()`. Moving to Nagoya loses that: Nagoya's [`Cancel`] +//! is an `Arc` with `is_cancelled()`, which is a *poll*. Asking a +//! loop to check a flag is a worse design than letting it sleep until the flag +//! moves: it either burns a core spinning or it adds latency equal to whatever +//! interval it settles for, and it is the kind of thing that looks fine on an +//! idle machine and shows up as a hot core on a busy one. +//! +//! So the state and the wake are kept as one object. The flag answers "is it +//! cancelled" for free, and [`Cancelled`] is a real future: it registers a +//! waker with `nagoya::sync::Notify` and is woken by [`Cancel::cancel`]. No +//! interval, no spin, no wakeup that is not a cancellation. +//! +//! # Why not just `Notify` +//! +//! A bare `Notify` loses the answer once the wake is consumed, so a task that +//! arrives after cancellation waits forever for a signal that already fired. +//! The `AtomicBool` is what makes this edge-triggered *and* level-readable: +//! [`Cancelled`] checks the flag before it ever registers, so cancelling then +//! awaiting is the same as awaiting then cancelling. `notify_waiters` wakes +//! every current waiter rather than one, because a stop is a broadcast. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Context, Poll}; + +use nagoya::sync::Notify; + +/// The shared half: the flag and the wake queue that belong to one run. +#[derive(Debug)] +struct Inner { + stopped: AtomicBool, + /// Shared, because [`Signals`] gives three facts one queue: a loop waiting + /// on all three then parks once rather than holding three registrations. + /// A `Cancel` built on its own still owns an `Arc` nobody else holds. + wake: Arc, +} + +/// A cancellation switch, cloneable and cheap. +/// +/// Clones share one flag, so any holder can stop the run and every waiter +/// observes it. This replaces a `watch::Sender` and its receivers both: +/// `watch` distinguishes the two ends, and nothing here needs that. +#[derive(Clone, Debug)] +pub struct Cancel(Arc); + +impl Cancel { + /// A switch that has not been thrown. + #[must_use] + pub fn new() -> Self { + Self(Arc::new(Inner { + stopped: AtomicBool::new(false), + wake: Arc::new(Notify::new()), + })) + } + + /// A switch that rings `wake` rather than a queue of its own. + /// + /// For [`Signals`], where three facts share one wake so a loop waiting on + /// all of them parks once. + #[must_use] + fn sharing(wake: &Arc) -> Self { + Self(Arc::new(Inner { + stopped: AtomicBool::new(false), + wake: Arc::clone(wake), + })) + } + + /// Stop the run, waking everything waiting on it. + /// + /// Idempotent, and safe to call from inside a task this cancels. The store + /// is `Release` and [`Cancelled`]'s load is `Acquire`, so a waiter that + /// observes the flag also observes whatever the canceller wrote first. + pub fn cancel(&self) { + // Already stopped: the waiters were woken by whoever got here first, + // and waking them again would be a spurious wake for no state change. + if self.0.stopped.swap(true, Ordering::Release) { + return; + } + // Every waiter, not one. A stop is a broadcast: a run with a reader and + // a supervisor both parked on it must not leave one of them asleep. + self.0.wake.notify_waiters(); + } + + /// Whether the switch has been thrown, without waiting. + /// + /// For the branch that has already been woken and needs to know *why*, not + /// for a loop to call on an interval. Use [`Self::cancelled`] to wait. + /// + /// Kept even with no caller in the tree: being level-readable as well as + /// awaitable is the property that separates this from a bare `Notify`, and + /// the tests assert it. A future caller that has a `Cancel` in hand and + /// needs the answer without awaiting should reach for this rather than + /// inventing a second flag beside it. + #[allow(dead_code, reason = "part of the primitive's contract; see above")] + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.0.stopped.load(Ordering::Acquire) + } + + /// A future that completes when, and only when, the run is cancelled. + /// + /// Resolves immediately if it already was, so there is no race between + /// cancelling and starting to wait. + #[must_use] + pub fn cancelled(&self) -> Cancelled<'_> { + Cancelled { + inner: &self.0, + waiting: None, + } + } +} + +impl Default for Cancel { + fn default() -> Self { + Self::new() + } +} + +/// A one-shot latch: something happened, and it stays happened. +/// +/// # Why this is not a channel +/// +/// The run loop used `mpsc::unbounded_channel::<()>()` for three of these. A +/// queue whose payload is `()` carries no data: it allocates a node, takes a +/// lock and wakes a task to transmit one bit that a single atomic already +/// holds. Worse, a queue is *consuming*: `recv()` takes the message, so two +/// `select!` arms waiting on the same failure race, and only one of them ever +/// learns about it. `injection_failure` is awaited from two different loops +/// for exactly that reason. +/// +/// A latch is level-triggered. Once [`Latch::set`] runs, every waiter past and +/// future completes, in any order, as many times as they ask. That is the real +/// semantic: "this run's injection failed" is a fact about the run, not a +/// message that one observer can take off a queue and hide from the others. +/// +/// Identical machinery to [`Cancel`] and deliberately a separate type: a stop +/// and a failure read the same way but mean different things, and naming them +/// apart keeps a `select!` arm honest about which it is waiting for. +#[derive(Clone, Debug)] +pub struct Latch(Arc); + +impl Latch { + /// A latch that has not fired. + #[must_use] + pub fn new() -> Self { + Self(Arc::new(Inner { + stopped: AtomicBool::new(false), + wake: Arc::new(Notify::new()), + })) + } + + /// A latch that rings `wake` rather than a queue of its own. See + /// [`Cancel::sharing`]. + #[must_use] + fn sharing(wake: &Arc) -> Self { + Self(Arc::new(Inner { + stopped: AtomicBool::new(false), + wake: Arc::clone(wake), + })) + } + + /// Record that it happened, waking every waiter. Idempotent. + pub fn set(&self) { + if self.0.stopped.swap(true, Ordering::Release) { + return; + } + self.0.wake.notify_waiters(); + } + + /// A future that completes when it happens, or at once if it already has. + /// + /// The run loop reaches for [`Signals::stopped`] or [`Signals::changed`] + /// instead, which is the point of those: three facts behind one wake. This + /// stays because it is a latch's defining behaviour and the tests below + /// assert it - a latch is observed by every waiter rather than consumed by + /// one, which is the property that makes sharing a wake safe. + #[allow(dead_code, reason = "the primitive's contract; asserted in tests")] + #[must_use] + pub fn waited(&self) -> Cancelled<'_> { + Cancelled { + inner: &self.0, + waiting: None, + } + } + + /// Whether it has happened, without waiting. + /// + /// Kept for the same reason as [`Cancel::is_cancelled`]: level-readable as + /// well as awaitable is what makes this safe to observe from two places. + #[allow(dead_code, reason = "part of the primitive's contract")] + #[must_use] + pub fn is_set(&self) -> bool { + self.0.stopped.load(Ordering::Acquire) + } +} + +impl Default for Latch { + fn default() -> Self { + Self::new() + } +} + +/// The future returned by [`Cancel::cancelled`]. +/// +/// Holds no timer and no interval. Its only wake comes from +/// [`Cancel::cancel`], through the waker it registered with `Notify`. +pub struct Cancelled<'a> { + inner: &'a Inner, + /// The registered wait, built on first poll. `Notified` borrows the + /// `Notify`, so it cannot be created until the future is pinned. + /// + /// `+ Send` is load bearing: the run loop awaits this from tasks that + /// cross threads, and a bare `dyn Future` is not `Send` even when the + /// concrete future is. + waiting: Option + Send + 'a>>>, +} + +impl Future for Cancelled<'_> { + type Output = (); + + fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> { + let this = self.get_mut(); + // The flag first, both on entry and after a wake. Cancelling before + // anyone waits has to be observable, or a late waiter parks forever. + if this.inner.stopped.load(Ordering::Acquire) { + return Poll::Ready(()); + } + // Register once and keep the same registration across polls: a fresh + // `notified()` each time would drop the queued waker and could miss + // the broadcast that arrives between two polls. + let waiting = this + .waiting + .get_or_insert_with(|| Box::pin(this.inner.wake.notified())); + match waiting.as_mut().poll(context) { + // Woken. Re-read the flag rather than trusting the wake: a + // `notify_waiters` this future was not the target of still wakes + // it, and only the flag says whether the run actually stopped. + Poll::Ready(()) => { + this.waiting = None; + if this.inner.stopped.load(Ordering::Acquire) { + Poll::Ready(()) + } else { + // Spurious. Re-register and park again; do not spin. + let waiting = this + .waiting + .get_or_insert_with(|| Box::pin(this.inner.wake.notified())); + match waiting.as_mut().poll(context) { + // The same rule as above, and it was missing here: a + // second wake is no more evidence of a cancellation + // than the first was. `Signals::around` shares one + // queue between the run's stop facts and its + // `ActiveRun::cancel`, so the wake that lands here is + // routinely a sibling's, and reporting it as a + // cancellation stops a run nobody asked to stop. + Poll::Ready(()) => { + this.waiting = None; + if this.inner.stopped.load(Ordering::Acquire) { + Poll::Ready(()) + } else { + // Woken twice by siblings. Park on a fresh + // registration and wait to be polled again. + let waiting = this + .waiting + .get_or_insert_with(|| Box::pin(this.inner.wake.notified())); + let _ = waiting.as_mut().poll(context); + Poll::Pending + } + } + Poll::Pending => Poll::Pending, + } + } + } + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + use std::time::Duration; + + /// The ordinary case: a waiter is parked, and cancelling wakes it. + #[test] + fn a_waiter_is_woken_by_a_later_cancel() { + let cancel = Cancel::new(); + let waker_side = cancel.clone(); + let stop = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + waker_side.cancel(); + }); + nagoya::block_on(async { + // Completes only because `cancel` fired. Nothing here polls. + cancel.cancelled().await; + }); + assert!(cancel.is_cancelled()); + stop.join().expect("waker thread"); + } + + /// The race that a bare `Notify` gets wrong: cancel first, wait second. + #[test] + fn cancelling_before_the_wait_is_still_observed() { + let cancel = Cancel::new(); + cancel.cancel(); + nagoya::block_on(async { + // Must not hang: the flag is read before any registration. + cancel.cancelled().await; + }); + assert!(cancel.is_cancelled()); + } + + /// A sibling's wake is not a cancellation, however many of them arrive. + /// + /// `Signals::around` puts the run's two failure latches on the same queue + /// as its cancel, so a waiter on `cancelled()` is woken by facts that have + /// nothing to do with stopping. `poll` re-read the flag after the first + /// such wake but not after the second, and reported a cancellation nobody + /// requested: the run ends while the owner is still watching it. + /// + /// Polled by hand rather than through `block_on`, because the property is + /// about what one `poll` does with a wake it was not the target of. + /// + /// This asserts the invariant; it does not reproduce the race. The branch + /// that was wrong needs a `notify_waiters` to land between the + /// re-registration and its immediate poll, both inside this one call, and + /// nagoya's `Notified` snapshots the broadcast generation at construction, + /// so a single thread cannot open that window. Two sibling wakes before a + /// poll is the closest deterministic approach to it. + #[test] + fn repeated_sibling_wakes_are_not_a_cancellation() { + let signals = Signals::around(Cancel::new()); + let cancel = signals.cancel.clone(); + let waker = futures::task::noop_waker(); + let mut context = Context::from_waker(&waker); + let mut cancelled = Box::pin(cancel.cancelled()); + + assert!( + cancelled.as_mut().poll(&mut context).is_pending(), + "nothing has happened yet" + ); + // Both land before the next poll, which is the case `poll` has to + // survive on its own: it re-registers after the first and must not + // read the second as the run being stopped. + signals.injection_failure.set(); + signals.ping_failed.set(); + assert!( + cancelled.as_mut().poll(&mut context).is_pending(), + "a sibling wake was reported as a cancellation" + ); + assert!(!cancel.is_cancelled()); + + // And a real cancel still lands, so the fix did not park it forever. + cancel.cancel(); + assert!(cancelled.as_mut().poll(&mut context).is_ready()); + } + + /// A stop is a broadcast, so every parked waiter has to wake, not one. + #[test] + fn every_waiter_wakes_not_just_one() { + let cancel = Cancel::new(); + let woken = Arc::new(AtomicUsize::new(0)); + let mut handles = Vec::new(); + for _ in 0..4 { + let cancel = cancel.clone(); + let woken = woken.clone(); + handles.push(std::thread::spawn(move || { + nagoya::block_on(async { cancel.cancelled().await }); + woken.fetch_add(1, Ordering::Relaxed); + })); + } + std::thread::sleep(Duration::from_millis(20)); + cancel.cancel(); + for handle in handles { + handle.join().expect("waiter thread"); + } + assert_eq!(woken.load(Ordering::Relaxed), 4, "all four waiters woke"); + } + + /// Cancelling twice must not wake anyone a second time, and must not panic. + #[test] + fn cancelling_twice_is_idempotent() { + let cancel = Cancel::new(); + cancel.cancel(); + cancel.cancel(); + assert!(cancel.is_cancelled()); + } + + /// The property a `()` channel cannot give: two observers, both told. + /// + /// `injection_failure` is awaited from two different loops. With a queue, + /// `recv()` consumes, so whichever arm polls first takes the message and + /// the other waits forever for a failure that already happened. + #[test] + fn a_latch_is_observed_by_every_waiter_not_consumed_by_one() { + let latch = Latch::new(); + latch.set(); + nagoya::block_on(async { + // Both complete. A channel would hand the single `()` to one. + latch.waited().await; + latch.waited().await; + }); + assert!(latch.is_set()); + } + + /// A latch set after the wait began still wakes what is parked on it. + #[test] + fn a_latch_wakes_a_parked_waiter() { + let latch = Latch::new(); + let firing = latch.clone(); + let fire = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + firing.set(); + }); + nagoya::block_on(async { latch.waited().await }); + assert!(latch.is_set()); + fire.join().expect("firing thread"); + } + + /// One wait, woken by whichever of the three fires. + #[test] + fn any_signal_wakes_the_single_wait() { + for which in 0..3 { + let signals = Signals::new(); + let firing = signals.clone(); + let fire = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + match which { + 0 => firing.cancel.cancel(), + 1 => firing.injection_failure.set(), + _ => firing.ping_failed.set(), + } + }); + // Completes for any of the three, through one registration. + nagoya::block_on(async { signals.changed(Handled::default()).await }); + assert!( + signals.pending(Handled::default()), + "signal {which} was observed" + ); + fire.join().expect("firing thread"); + } + } + + /// The race a shared wake must not lose: fire first, wait second. + #[test] + fn a_signal_set_before_the_wait_is_still_observed() { + let signals = Signals::new(); + signals.ping_failed.set(); + // Must not hang: `changed` reads the flags before parking. + nagoya::block_on(async { signals.changed(Handled::default()).await }); + assert!(signals.ping_failed.is_set()); + assert!(!signals.cancel.is_cancelled(), "only the one that fired"); + } + + /// Two facts arriving together are both readable, not one consumed. + /// + /// This is what a queue could not give and why the flags are level + /// triggered: the loop reads all three on wake and acts on each. + #[test] + fn two_signals_are_both_visible() { + let signals = Signals::new(); + signals.cancel.cancel(); + signals.injection_failure.set(); + nagoya::block_on(async { signals.changed(Handled::default()).await }); + assert!(signals.cancel.is_cancelled()); + assert!(signals.injection_failure.is_set()); + assert!(!signals.ping_failed.is_set()); + } + + /// A handled fact stops waking the caller, so the loop cannot spin. + /// + /// `ping_failed` never clears, so once the run loop has noted it and + /// decided to continue, a wait that still counted it would return + /// instantly forever. + #[test] + fn a_handled_signal_no_longer_wakes_the_wait() { + let signals = Signals::new(); + signals.ping_failed.set(); + let handled = Handled { ping_failed: true }; + assert!( + !signals.pending(handled), + "a handled ping is not a reason to wake" + ); + // Still true, and still readable by anyone who cares. + assert!(signals.ping_failed.is_set()); + // A terminal fact still gets through the same filter. + signals.cancel.cancel(); + assert!( + signals.pending(handled), + "cancellation is never handled away" + ); + nagoya::block_on(async { signals.changed(handled).await }); + } + + /// Clones share the flag: stopping through one stops the run. + #[test] + fn a_clone_stops_the_same_run() { + let cancel = Cancel::new(); + let clone = cancel.clone(); + clone.cancel(); + assert!(cancel.is_cancelled(), "the clone shares one flag"); + } +} + +/// The run's three stop-or-retry facts, behind one wake. +/// +/// # Why these are one object +/// +/// The run loop waited on `cancel`, `injection_failure` and `ping_failed` as +/// three separate `select!` arms. Each is a [`Cancel`] or [`Latch`], which is +/// to say each is an `AtomicBool` that already knows exactly when it changed +/// and a `Notify` that already wakes whoever is parked on it. Putting three +/// such things in a poll set asks the loop to re-poll all three every time any +/// one of them — or a provider event, or a timer — fires. +/// +/// So they share a wake instead. "Something wants this loop to stop or retry" +/// is one event; *which* of the three it was is a question the loop answers by +/// reading the flags, which is three `Acquire` loads and no allocation. +/// +/// That works only because the flags are level-triggered: a fact that is set +/// stays set, so reading after the wake cannot miss one, and two arriving +/// together are both seen rather than one being consumed. An edge-triggered +/// signal would need a branch per source to avoid losing the second. +/// +/// The three keep their own types rather than becoming an enum. `Cancel` and +/// `Latch` mean different things, they are held by different parts of the run, +/// and the places that *set* them should not gain the ability to set the others. +#[derive(Clone, Debug)] +pub struct Signals { + /// The owner or a teardown path asked this run to stop. + pub cancel: Cancel, + /// A mid-turn message could not be delivered into the live turn. + pub injection_failure: Latch, + /// A liveness ping could not be delivered, so nothing will answer it. + pub ping_failed: Latch, + /// The one queue every fact above rings. + wake: Arc, +} + +impl Signals { + /// Three unset facts sharing one wake. + #[must_use] + pub fn new() -> Self { + let wake = Arc::new(Notify::new()); + Self { + cancel: Cancel::sharing(&wake), + injection_failure: Latch::sharing(&wake), + ping_failed: Latch::sharing(&wake), + wake, + } + } + + /// Two fresh latches joining an existing switch's wake. + /// + /// A run's `Cancel` is created by whoever starts the run, because stopping + /// it is something the outside world does; the two failure latches belong + /// to the run itself and do not exist until it is under way. This adopts + /// the caller's switch rather than replacing it, so a stop requested + /// through the original handle still reaches this loop. + #[must_use] + pub fn around(cancel: Cancel) -> Self { + let wake = Arc::clone(&cancel.0.wake); + Self { + injection_failure: Latch::sharing(&wake), + ping_failed: Latch::sharing(&wake), + cancel, + wake, + } + } + + /// Whether either fact that *ends a run* has fired. + /// + /// `ping_failed` is deliberately not one of them. It says a liveness probe + /// did not reach the provider, which is a reason for the main loop to stop + /// expecting an answer, not a reason to abandon whatever is in flight. A + /// waiter that treated it as terminal would tear down a run that is merely + /// unmonitored. + #[must_use] + pub fn stopping(&self) -> bool { + self.cancel.is_cancelled() || self.injection_failure.is_set() + } + + /// Wait until this run is being stopped, by cancellation or a failed + /// injection. + /// + /// The counterpart to [`Self::stopping`], for a wait that must end when the + /// run ends but has no interest in liveness. It still shares the one wake, + /// so a `ping_failed` that rings the queue simply re-checks and parks + /// again rather than waking the caller spuriously. + pub async fn stopped(&self) { + loop { + if self.stopping() { + return; + } + let waiting = self.wake.notified(); + if self.stopping() { + return; + } + waiting.await; + } + } + + /// Wait until a fact the caller has not already handled fires. + /// + /// Returns as soon as one is set, so a fact that arrived before the wait + /// began is not missed. The caller then reads the individual flags to learn + /// which, and may see more than one. + /// + /// # Why this takes `handled` + /// + /// The flags are level triggered and a [`Latch`] never clears, which is + /// what makes two simultaneous facts both visible. It also means a fact the + /// caller has *acted on* and decided not to stop for stays set forever, so + /// a bare "is anything set" wait would return instantly on every call and + /// spin the loop at full tilt. + /// + /// `ping_failed` is exactly that case: the run loop notes it, clears its + /// own outstanding-ping state, and carries on. Passing it here afterwards + /// says "I know, do not wake me for this again", which is the honest way to + /// say it - clearing the flag would lie to every other reader. + pub async fn changed(&self, handled: Handled) -> () { + loop { + if self.pending(handled) { + return; + } + let waiting = self.wake.notified(); + // Re-check between registering and parking: a fact set in that + // window has already rung the queue, and without this the loop + // would park on a wake that has been and gone. + if self.pending(handled) { + return; + } + waiting.await; + } + } + + /// Whether a fact outside `handled` is set. + #[must_use] + fn pending(&self, handled: Handled) -> bool { + if self.cancel.is_cancelled() || self.injection_failure.is_set() { + return true; + } + !handled.ping_failed && self.ping_failed.is_set() + } +} + +/// Facts the caller has already acted on and does not want woken for again. +/// +/// Only the non-terminal ones can be named: cancellation and a failed +/// injection end the run, so "I have handled that and wish to continue" is not +/// a thing a caller can mean about them. +#[derive(Clone, Copy, Debug, Default)] +pub struct Handled { + /// The liveness ping's failure has been noted and the run continues. + pub ping_failed: bool, +} + +impl Default for Signals { + fn default() -> Self { + Self::new() + } +} diff --git a/apps/gui/src/chat_import.rs b/apps/gui/src/chat_import.rs index 3498d4552..7fa8ee9a2 100644 --- a/apps/gui/src/chat_import.rs +++ b/apps/gui/src/chat_import.rs @@ -703,6 +703,55 @@ pub fn claude_session_cwd(session_id: &str) -> Option { None } +/// Directory Grok created this session under (`~/.grok/sessions//`). +/// +/// Grok keys sessions by process cwd the same way Claude does. Resume from a +/// different project directory looks in the wrong folder and Grok answers +/// JSON-RPC `-32603 Path not found`, which we were surfacing as a parse error. +#[must_use] +pub fn grok_session_cwd(session_id: &str) -> Option { + if session_id.is_empty() { + return None; + } + let home = match std::env::var_os("GROK_HOME") { + Some(path) => PathBuf::from(path), + None => home().ok()?.join(".grok"), + }; + let root = home.join("sessions"); + let entries = std::fs::read_dir(&root).ok()?; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(encoded) = name.to_str() else { + continue; + }; + if encoded.starts_with('.') || encoded.ends_with(".sqlite") { + continue; + } + if !entry.path().join(session_id).is_dir() { + continue; + } + return percent_decode_cwd(encoded); + } + None +} + +fn percent_decode_cwd(encoded: &str) -> Option { + let bytes = encoded.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok().filter(|cwd| !cwd.is_empty()) +} + fn find_session(root: &Path, extension: &str, id: &str) -> Option { collect(root, extension).into_iter().find(|path| { path.file_stem() @@ -908,4 +957,23 @@ mod tests { ); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn grok_session_cwd_reads_urlencoded_parent() { + let root = std::env::temp_dir().join(format!("az-grok-home-{}", std::process::id())); + let session = "01a09bd5-4a37-7082-89af-26bd58490fca"; + let dir = root + .join("sessions") + .join("%2FUsers%2Frevenge%2FAgencyZero") + .join(session); + std::fs::create_dir_all(&dir).expect("temp grok session dir"); + // SAFETY: this test process does not run grok concurrently. + unsafe { std::env::set_var("GROK_HOME", &root) }; + assert_eq!( + grok_session_cwd(session).as_deref(), + Some("/Users/revenge/AgencyZero") + ); + unsafe { std::env::remove_var("GROK_HOME") }; + let _ = std::fs::remove_dir_all(root); + } } diff --git a/apps/gui/src/db/schema/project.rs b/apps/gui/src/db/schema/project.rs index ed24fb716..dcf9a70bc 100644 --- a/apps/gui/src/db/schema/project.rs +++ b/apps/gui/src/db/schema/project.rs @@ -62,5 +62,13 @@ worktable!( PositionById(position) by id, LastActivityById(last_activity_at) by id, }, + // Reordering the tab strip writes one row per tab. A normal `update` + // reserializes the whole row and reinserts it when the length moves; + // `update_in_place` mutates the archived field where it already sits, + // which is sound here because these columns are fixed size and none of + // them is indexed (`status_idx` is on `status`). + update_in_place: { + PositionInPlace(position) by id, + }, } ); diff --git a/apps/gui/src/db/tables.rs b/apps/gui/src/db/tables.rs index dcf3d075c..8b6fb5958 100644 --- a/apps/gui/src/db/tables.rs +++ b/apps/gui/src/db/tables.rs @@ -298,18 +298,14 @@ impl Tables { /// then erase the evidence that they had ever disagreed. An unreadable /// store is the case with the most to lose, and it was the case with no /// error path at all. - pub async fn peek_fingerprint(dir: &std::path::Path) -> Result, String> { + pub async fn peek_fingerprint(dir: &std::path::Path) -> eyre::Result> { let config = DiskConfig::new_with_table_name( dir.to_string_lossy().into_owned(), KvWorkTable::name_snake_case(), KvWorkTable::version(), ); - let engine = KvPersistenceEngine::new(config) - .await - .map_err(|error| format!("kv would not open: {error}"))?; - let kv = KvWorkTable::load(engine) - .await - .map_err(|error| format!("kv would not load: {error}"))?; + let engine = KvPersistenceEngine::new(config).await?; + let kv = KvWorkTable::load(engine).await?; Ok(kv.select(FINGERPRINT_KEY.to_string()).map(|row| row.value)) } @@ -682,7 +678,9 @@ mod restart_tests { /// mutation shape concurrently, then require both a clean drain and reopen. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_pull_request_refreshes_drain_without_event_gaps() { - use crate::db::schema::pull_request::{PrFactsByIdQuery, PullRequestRow}; + use crate::db::schema::pull_request::{ + PrFactsByIdQuery, PullRequestColumns, PullRequestRow, + }; const TASKS: usize = 16; const UPDATES_PER_TASK: usize = 64; @@ -723,7 +721,9 @@ mod restart_tests { tasks.push(tokio::spawn(async move { for update in 0..UPDATES_PER_TASK { table - .update_pr_facts_by_id( + .update_by_id( + id.clone(), + PullRequestColumns::BRANCH_AND_STATE_AND_ADDITIONS_AND_DELETIONS_AND_CI_AND_UPDATED_AT, PrFactsByIdQuery { branch: format!("task-{task}"), state: "OPEN".into(), @@ -732,7 +732,6 @@ mod restart_tests { ci: "pending".into(), updated_at: format!("{task}-{update}"), }, - id.clone(), ) .await .expect("concurrent update should succeed"); @@ -766,7 +765,9 @@ mod restart_tests { /// two ids, the shape an attempted indexed replacement can emit. #[tokio::test] async fn rejected_duplicate_pull_request_insert_does_not_create_an_event_gap() { - use crate::db::schema::pull_request::{PrFactsByIdQuery, PullRequestRow}; + use crate::db::schema::pull_request::{ + PrFactsByIdQuery, PullRequestColumns, PullRequestRow, + }; let dir = std::env::temp_dir().join(format!("az-pr-duplicate-gap-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); @@ -802,7 +803,9 @@ mod restart_tests { ); tables .pull_request - .update_pr_facts_by_id( + .update_by_id( + "pr-duplicate-gap".to_string(), + PullRequestColumns::BRANCH_AND_STATE_AND_ADDITIONS_AND_DELETIONS_AND_CI_AND_UPDATED_AT, PrFactsByIdQuery { branch: "updated-branch-name".into(), state: "MERGED".into(), @@ -811,7 +814,6 @@ mod restart_tests { ci: "pass".into(), updated_at: "updated".into(), }, - "pr-duplicate-gap".to_string(), ) .await .expect("update should succeed"); @@ -952,7 +954,7 @@ impl Tables { pending: impl std::future::Future, ) -> Result<(), String> { let started = std::time::Instant::now(); - let result = tokio::time::timeout(std::time::Duration::from_secs(5), pending).await; + let result = nagoya::timeout(std::time::Duration::from_secs(5), pending).await; let elapsed = started.elapsed().as_millis(); match result { Ok(Ok(())) => { @@ -969,7 +971,7 @@ impl Tables { } // Independent tables must not make quit ten serial waits. WorkTable - // 1.0 reports a terminal persistence failure immediately; the local + // 1.9 reports a terminal persistence failure immediately; the local // timeout is the last boundary if a future engine regresses to a // parked worker. Each result keeps the table name that needs repair. let results = tokio::join!( diff --git a/apps/gui/src/experimental.rs b/apps/gui/src/experimental.rs index 317117bc1..26654d2fb 100644 --- a/apps/gui/src/experimental.rs +++ b/apps/gui/src/experimental.rs @@ -120,7 +120,7 @@ pub async fn claude_usage() -> Result { .map_err(|error| error.to_string())?; let usage = (|| client.fetch()) .retry(crate::retry::interactive_backoff()) - .sleep(tokio::time::sleep) + .sleep(nagoya::sleep) .await .map_err(|error| error.to_string())?; diff --git a/apps/gui/src/main.rs b/apps/gui/src/main.rs index 3dec842b3..0856cfbce 100644 --- a/apps/gui/src/main.rs +++ b/apps/gui/src/main.rs @@ -3,6 +3,7 @@ mod agent_proxy; mod agents; mod angel; +mod cancel; mod chat_import; mod db; mod directives; @@ -18,6 +19,7 @@ mod qa_profile; mod questions; mod quota; mod retry; +mod runtime; mod settings; mod store_backup; mod study; @@ -307,6 +309,13 @@ const IMPLEMENTED: &[&str] = &[ /// What the GUI carries for the life of the process. pub(crate) struct AppState { tables: Arc, + /// The threads az's own synchronous work runs on, owned rather than + /// borrowed from tokio's process-wide blocking pool. See [`runtime::Pool`]. + /// + /// An `Arc` because a run outlives the command that started it: `drive_run` + /// is spawned and keeps sending pings long after the caller's borrow of + /// this state has gone. + pub(crate) pool: Arc, /// Persistent provider runtime. The GUI is only a client; live agent /// processes survive this application's restart inside AgencyProxy. proxy: Arc, @@ -598,7 +607,7 @@ impl AppState { // true. Let one already in flight finish before asking WorkTable if it // is idle, otherwise that refresh can submit a new operation after the // pull-request table has already reported drained. - tokio::time::timeout( + nagoya::timeout( std::time::Duration::from_secs(15), self.pr_refreshes.wait_until_empty(), ) @@ -611,6 +620,16 @@ impl AppState { if result.is_ok() { self.exit_drain_succeeded .store(true, std::sync::atomic::Ordering::Release); + // Only once the store is safely down, because only then is the + // process definitely going away. A failed drain leaves the app + // running with quit blocked, and a pool stopped there would take + // every store read in the window down with it, turning a state the + // owner can still look at into a dead one. + // + // `stop` is graceful: work already queued runs to completion before + // a worker exits. Nothing is queued by this point, because the + // drain is reached after the window has stopped asking. + self.pool.stop(); } result } @@ -650,7 +669,7 @@ pub(crate) fn schedule_agent_restart( let project_id = project_id.to_string(); let actor = actor.to_string(); tauri::async_runtime::spawn(async move { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(600); + let deadline = runtime::deadline_in(std::time::Duration::from_secs(600)); let token = uuid::Uuid::new_v4().to_string(); let (confirmed, confirmation) = tokio::sync::oneshot::channel(); let ready = async { @@ -665,13 +684,13 @@ pub(crate) fn schedule_agent_restart( serde_json::json!({ "token": token }), ) .map_err(|error| format!("could not announce the scheduled restart: {error}"))?; - tokio::time::timeout_at(deadline, confirmation) + nagoya::timeout(runtime::remaining(deadline), confirmation) .await .map_err(|_| { "agent restart expired while frontend work remained queued".to_string() })? .map_err(|_| "the frontend restart confirmation was dropped".to_string())?; - tokio::time::timeout_at(deadline, state.active.wait_until_idle()) + nagoya::timeout(runtime::remaining(deadline), state.active.wait_until_idle()) .await .map_err(|_| "agent restart expired while runs remained active".to_string())??; Ok::<(), String>(()) @@ -1610,7 +1629,7 @@ async fn quit_app(app: AppHandle, state: State<'_, AppState>) -> Result<(), Stri async fn quit_app_and_proxy(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { state.proxy.terminate().await?; - tokio::time::timeout( + nagoya::timeout( std::time::Duration::from_secs(15), state.active.wait_until_idle(), ) @@ -2288,6 +2307,48 @@ fn is_persistence_load_refusal(error: &eyre::Report) -> bool { .is_some() } +/// Page format v3 deliberately refuses a v2 store, which is the typed signal +/// for the automatic read-only export and staged conversion. This is a release +/// boundary rather than evidence that the old bytes are corrupt. +fn is_v2_page_format_refusal(error: &eyre::Report) -> bool { + error + .downcast_ref::() + .is_some_and(|error| { + error.reason().contains("unsupported page format v2") + && error.reason().contains("this build requires v3") + }) +} + +fn v2_reader_binary() -> Result { + if let Some(path) = std::env::var_os("AZ_WT_V2_READER_BIN") { + let path = PathBuf::from(path); + return path + .is_file() + .then_some(path.clone()) + .ok_or_else(|| format!("AZ_WT_V2_READER_BIN points at missing {path:?}")); + } + let executable = std::env::current_exe() + .map_err(|error| format!("could not locate this executable: {error}"))?; + if let Some(parent) = executable.parent() { + let bundled = parent.join("agencyzero-wt-v2-reader"); + if bundled.is_file() { + return Ok(bundled); + } + } + let staged = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("binaries") + .join(format!( + "agencyzero-wt-v2-reader-{}", + env!("AZ_BUILD_TARGET") + )); + staged.is_file().then_some(staged.clone()).ok_or_else(|| { + format!( + "the WorkTable v2 migration reader is missing; expected the bundled executable or \ + {staged:?}" + ) + }) +} + fn rebuild_rejected_store( location: &mut location::DataLocation, refusal: &str, @@ -2408,14 +2469,7 @@ fn main() { }; let source = std::path::PathBuf::from(source); let destination = std::path::PathBuf::from(destination); - let runtime = match tokio::runtime::Runtime::new() { - Ok(runtime) => runtime, - Err(error) => { - eprintln!("could not start a runtime: {error}"); - std::process::exit(1); - } - }; - match runtime.block_on(qa_profile::build(&source, &destination)) { + match nagoya::block_on(qa_profile::build(&source, &destination)) { Ok(rows) => { println!("scrubbed {rows} rows into {}", destination.display()); return; @@ -2769,7 +2823,71 @@ fn main() { * row through the wrong layout on the way to saying "mismatched", * which is somewhere between garbage and a bus error. */ - let peeked = tauri::async_runtime::block_on(Tables::peek_fingerprint(&location.path)); + match wt_migrate::resume_page_format_migration(&location.path) { + Ok(true) => crate::log!( + log::Level::Info, + "boot", + "completed or recovered the WorkTable v3 promotion at {:?}", + location.path + ), + Ok(false) => {} + Err(error) => { + let message = format!( + "could not recover the interrupted WorkTable v3 migration at {:?}: \ + {error:#}. No table was opened; the v2 backup and staged v3 data were \ + left in place.", + location.path + ); + crate::log!(log::Level::Error, "boot", "{message}"); + return Err(message.into()); + } + } + + let mut peeked = tauri::async_runtime::block_on(Tables::peek_fingerprint(&location.path)); + if peeked + .as_ref() + .is_err_and(is_v2_page_format_refusal) + && !no_migration + { + let reader = v2_reader_binary().map_err(|error| { + let message = format!( + "the store at {:?} uses WorkTable page format v2, but {error}. The v2 \ + store is unchanged and startup stopped before any table opened.", + location.path + ); + crate::log!(log::Level::Error, "boot", "{message}"); + message + })?; + crate::log!( + log::Level::Warn, + "boot", + "converting WorkTable page format v2 at {:?} through {reader:?}", + location.path + ); + let report = wt_migrate::migrate_page_format_v2(&location.path, &reader) + .map_err(|error| { + let message = format!( + "WorkTable v2 to v3 migration failed at {:?}: {error:#}. Startup \ + stopped without opening a partial store; the original v2 data is \ + unchanged or retained at its durable v2-preserved path.", + location.path + ); + crate::log!(log::Level::Error, "boot", "{message}"); + message + })?; + crate::log!( + log::Level::Info, + "boot", + "converted WorkTable v2 to v3: [{}]. Promotion is committed and the displaced v2 directory was removed", + report + .tables + .iter() + .map(|table| format!("{}: {}", table.table, table.rows)) + .collect::>() + .join(", "), + ); + peeked = tauri::async_runtime::block_on(Tables::peek_fingerprint(&location.path)); + } let tables = match peeked { /* * kv is the one table whose shape has never changed, so if it @@ -2784,7 +2902,30 @@ fn main() { * So: touch nothing, run on scratch, and say where the store * is and what can read it. */ - Err(reason) => { + Err(error) if is_v2_page_format_refusal(&error) && no_migration => { + crate::log!( + log::Level::Warn, + "boot", + "the store at {:?} uses WorkTable page format v2 and \ + AZ_NO_DB_MIGRATION is set. The old store is unchanged and this session \ + runs on scratch, keeping nothing.", + location.path + ); + location = ephemeral_location(); + tauri::async_runtime::block_on(Tables::open(&location.path)) + .map_err(|error| format!("could not open a scratch store: {error}"))? + } + Err(error) if is_v2_page_format_refusal(&error) => { + let message = format!( + "the store at {:?} still reports WorkTable page format v2 after its \ + converter completed. Startup stopped before opening any table: {error}", + location.path + ); + crate::log!(log::Level::Error, "boot", "{message}"); + return Err(message.into()); + } + Err(error) => { + let reason = error.to_string(); crate::log!( log::Level::Error, "boot", @@ -2893,6 +3034,7 @@ fn main() { let restart_resume = take_restart_resume(&config_dir); app.manage(AppState { tables: Arc::new(tables), + pool: Arc::new(runtime::Pool::new()), proxy, running: Arc::default(), io: Arc::default(), @@ -2920,8 +3062,8 @@ fn main() { #[cfg(feature = "blitz-runtime")] { let relaunch_handle = app.handle().clone(); - tauri_runtime_blitz::set_agent_control_handler(move |request| match request { - tauri_runtime_blitz::control_protocol::AgentControlRequest::Relaunch => { + blitz_control_protocol::lifecycle::set_lifecycle_handler(move |request| match request { + blitz_control_protocol::AgentControlRequest::Relaunch => { let handle = relaunch_handle.clone(); tauri::async_runtime::spawn(async move { let state = handle.state::(); @@ -2933,10 +3075,10 @@ fn main() { ); } }); - tauri_runtime_blitz::control_protocol::DebugResponse::Ack + blitz_control_protocol::DebugResponse::Ack } - _ => tauri_runtime_blitz::control_protocol::DebugResponse::Error( - tauri_runtime_blitz::control_protocol::DebugError { + _ => blitz_control_protocol::DebugResponse::Error( + blitz_control_protocol::DebugError { code: "unsupportedEmbedderAction".into(), message: "AgencyZero delegates only relaunch to its restart Angel" .into(), diff --git a/apps/gui/src/models.rs b/apps/gui/src/models.rs index b546caadb..8b1072e1a 100644 --- a/apps/gui/src/models.rs +++ b/apps/gui/src/models.rs @@ -87,7 +87,7 @@ pub fn verified_against(agent: Agent) -> String { /// Every agent's model catalogue. /// /// With `discover`, each CLI is asked to enumerate rather than trusting the -/// crate's compiled list. Only Codex can answer that today; Claude and Copilot +/// crate's compiled list. Codex and Grok can answer; Claude and Copilot /// return `Error::Unsupported` and fall back here. /// /// A discovery failure is **not** an error for the whole call. It falls back to diff --git a/apps/gui/src/pricing.rs b/apps/gui/src/pricing.rs index c8992da71..d3b45ce3a 100644 --- a/apps/gui/src/pricing.rs +++ b/apps/gui/src/pricing.rs @@ -114,6 +114,22 @@ const PRICES: &[Price] = &[ output: 15.00, cache_read: 0.25, }, + // Grok — short-context list prices from docs.x.ai (2026-09-07). grok-4.6 + // before grok-4.5 so substring match cannot collapse them. Cache-read + // differs ($0.50 vs $0.30). Long-context (>=200k) rates are not modelled; + // the table is an estimate, and the provider's turn cost still wins. + Price { + key: "grok-4.6", + input: 2.00, + output: 6.00, + cache_read: 0.50, + }, + Price { + key: "grok-4.5", + input: 2.00, + output: 6.00, + cache_read: 0.30, + }, ]; // The estimate itself runs in the frontend, per keystroke and offline, from the @@ -330,6 +346,28 @@ mod tests { // A dated or suffixed id still resolves by substring. assert_eq!(price_for("claude-opus-4-8").unwrap().output, 25.00); assert_eq!(price_for("gpt-5.6-terra-2026").unwrap().input, 2.00); + assert_eq!(price_for("grok-4.6").unwrap().input, 2.00); + assert_eq!(price_for("grok-4.6").unwrap().cache_read, 0.50); + assert_eq!(price_for("grok-4.5").unwrap().cache_read, 0.30); + } + + /// `price_for` is first-match-wins on substring, so a key that contains an + /// earlier key can never be reached. `grok-4.6` before `grok-4.5` is the + /// case that prompted this, but the constraint is general and nothing in + /// the table's shape enforces it: a reorder would silently bill the wrong + /// cache rate while every by-id assertion still passed. + #[test] + fn no_price_key_is_shadowed_by_an_earlier_substring() { + for (later, price) in PRICES.iter().enumerate() { + for earlier in &PRICES[..later] { + assert!( + !price.key.contains(earlier.key), + "{} is unreachable: {} appears earlier and is a substring of it", + price.key, + earlier.key + ); + } + } } #[test] diff --git a/apps/gui/src/projects.rs b/apps/gui/src/projects.rs index 8368306b6..82b4368e2 100644 --- a/apps/gui/src/projects.rs +++ b/apps/gui/src/projects.rs @@ -31,16 +31,9 @@ use tokio::io::{AsyncRead, AsyncReadExt}; // `execute` on a select builder is a trait method. use worktable::prelude::*; -use crate::db::schema::message::{FinalizeByIdQuery, MessageRow}; -use crate::db::schema::project::{ - DirsByIdQuery, LastActivityByIdQuery, ModeratorByIdQuery, NameByIdQuery, PinnedByIdQuery, - PositionByIdQuery, ProjectRow, -}; -use crate::db::schema::project_item::{ - PositionByIdQuery as ItemPositionByIdQuery, ProjectItemRow, - ReferenceByIdQuery as ItemReferenceByIdQuery, StatusByIdQuery as ItemStatusByIdQuery, - TitleByIdQuery as ItemTitleByIdQuery, -}; +use crate::db::schema::message::{FinalizeByIdQuery, MessageColumns, MessageRow}; +use crate::db::schema::project::{ProjectColumns, ProjectRow}; +use crate::db::schema::project_item::{ProjectItemColumns, ProjectItemRow}; use crate::db::schema::reply_checkpoint::ReplyCheckpointRow; use crate::db::schema::task_log::TaskLogRow; use crate::db::tables::Tables; @@ -90,7 +83,7 @@ pub struct ProjectPanelData { /// Attach the project's session id, which lives in `kv` rather than on the row. fn with_session(mut dto: ProjectDto, tables: &crate::db::tables::Tables) -> ProjectDto { - for agent in [Agent::Claude, Agent::Codex] { + for agent in [Agent::Claude, Agent::Codex, Agent::Grok] { // The real pointer, not the effective one: a session the owner set // aside is still theirs and still resumable, and hiding it is what // made a non-destructive reset look exactly like the destructive one. @@ -325,11 +318,10 @@ async fn touch_item(tables: &Tables, item_id: &str) { async fn touch_project(tables: &Tables, project_id: &str) { if let Err(error) = tables .project - .update_last_activity_by_id( - LastActivityByIdQuery { - last_activity_at: now(), - }, + .update_by_id( project_id.to_string(), + ProjectColumns::LAST_ACTIVITY_AT, + now(), ) .await { @@ -417,7 +409,7 @@ async fn record_item_completion(tables: &Tables, row: &ProjectItemRow, actor: Op return; } let agent = actor - .filter(|agent| matches!(*agent, "claude" | "codex" | "copilot")) + .filter(|agent| matches!(*agent, "claude" | "codex" | "copilot" | "grok")) .map(str::to_string) .or_else(|| tables.kv_get(&item_agent_key(&row.id))) .unwrap_or_else(|| "owner".to_string()); @@ -456,17 +448,19 @@ pub struct UsageDto { pub input_tokens: Option, /// Generated tokens across every model call in this turn. pub output_tokens: Option, - /// Every input token the turn was charged for, cached or not — the size of - /// the conversation as the model saw it. + /// Live occupancy: how full the context window is *now*. /// - /// **Already cumulative.** The agent re-sends the whole conversation each - /// turn and reports it, so summing this across turns counts the same - /// conversation once per turn and the error grows with the session. The - /// crate ships `Usage::accumulate` precisely because the obvious loop is - /// wrong; the frontend's `usageTotals` follows the same rule. + /// **Already cumulative across the conversation, not across turns.** The + /// agent re-sends the whole prompt each turn and reports its size, so + /// summing this counts the same conversation once per turn. Grok's turn + /// `totalTokens` is a billed sum across model calls and must not land + /// here. The crate ships `Usage::accumulate` precisely because the + /// obvious loop is wrong; the frontend's `usageTotals` follows the same + /// rule. pub context_tokens: Option, - /// The model's context window, where the agent reports one. Claude alone - /// does, so a share of the limit is only shown when it is there. + /// The model's context window, where the agent reports one. Claude reports + /// it natively; Grok's adapter fills 500k. Without it a share of the + /// limit cannot be shown. pub context_window: Option, /// Tokens served from cache during this turn. Additive across turns. pub cache_reads: Option, @@ -714,7 +708,12 @@ impl RunMeasurement { cache_write_tokens: count(usage.cache_write_tokens), cost_micro, duration_ms, - status: status.to_string(), + // The one field on this row a caller can hand an arbitrary + // provider string. Every current caller passes a stop label or a + // fixed literal except the failure path, which capped its error + // before calling; this is the backstop so the next caller does not + // have to know that a row must fit one page. + status: truncate_to_bytes(status, MAX_PERSISTED_BLOB), started_at: self.started_at.clone(), finished_at: now(), }; @@ -937,25 +936,17 @@ pub async fn backfill_imported_usage(tables: &Tables) -> usize { stored.sort_by(|left, right| left.created_at.cmp(&right.created_at)); let source = source.to_string(); let session_id = session_id.to_string(); - let loaded = - tokio::task::spawn_blocking(move || crate::chat_import::load(&source, &session_id)) - .await; + // Called directly: `load` is synchronous, and handing it to an + // executor only to await it back is the handoff that wedged + // `discover_chat_imports`. See [`list_item_rows`]. + let loaded = crate::chat_import::load(&source, &session_id); let chat = match loaded { - Ok(Ok(chat)) => chat, - Ok(Err(error)) => { - crate::log!( - crate::log::Level::Warn, - "analytics", - "{}: could not reload imported transcript usage: {error}", - import.value - ); - continue; - } + Ok(chat) => chat, Err(error) => { crate::log!( crate::log::Level::Warn, "analytics", - "{}: imported transcript reload stopped unexpectedly: {error}", + "{}: could not reload imported transcript usage: {error}", import.value ); continue; @@ -983,13 +974,14 @@ pub async fn backfill_imported_usage(tables: &Tables) -> usize { } if let Err(error) = tables .message - .update_finalize_by_id( + .update_by_id( + row.id.clone(), + MessageColumns::USAGE_AND_STOP_AND_EXIT_CODE, FinalizeByIdQuery { usage: recovered.usage.clone(), stop: row.stop.clone(), exit_code: row.exit_code, }, - row.id.clone(), ) .await { @@ -1204,6 +1196,7 @@ fn agent_session_key(project_id: &str, agent: Agent) -> String { Agent::Claude => session_key(project_id), Agent::Codex => format!("session:codex:{project_id}"), Agent::Copilot => format!("session:copilot:{project_id}"), + Agent::Grok => format!("session:grok:{project_id}"), } } @@ -1363,6 +1356,7 @@ fn agent_wire_name(agent: Agent) -> &'static str { Agent::Claude => "claude", Agent::Codex => "codex", Agent::Copilot => "copilot", + Agent::Grok => "grok", } } @@ -1439,8 +1433,14 @@ fn take_incomplete_prompt_syntax_tail(body: &mut String) -> Option { /// means this, and opening the channel would replace that mode with `manual` /// (see `argv_claude` in agent-abstraction), turning every gated tool call into /// a round trip this app would only answer yes to anyway. +/// +/// Grok Auto is native `--permission-mode auto` *and* still emits ACP +/// `session/request_permission` for writes outside the workspace. Session +/// 01a09ca7 sat 30 minutes on `~/.grok/config.toml` because this returned +/// false and the host never answered. Open the channel and let +/// [`auto_allows`] supply the yes, same as Codex. fn should_route_approvals(permission: &str, agent: Agent) -> bool { - permission == "ask" || (permission == "auto" && agent == Agent::Codex) + permission == "ask" || (permission == "auto" && matches!(agent, Agent::Codex | Agent::Grok)) } /// Whether Auto answers this run's approvals itself rather than asking. @@ -1631,12 +1631,23 @@ fn body_head(body: &str) -> String { /// Call after the message row's id is known; the chunks key off it. A body /// within the cap writes nothing. Every caller mints a fresh message id, so /// there are never prior chunks to clear: this is insert-only. -async fn store_body(tables: &Tables, message_id: &str, project_id: &str, body: &str) { - if body.len() <= MAX_MESSAGE_BODY { +/// +/// `head_len` is what the row actually stored, which is not always +/// [`MAX_MESSAGE_BODY`]: [`fit_message_row_to_page`] shortens the head further +/// when the rest of the row needs the space. The spill has to start where the +/// stored head ends, because [`full_body`] reassembles by concatenating the two +/// and any other split point loses or repeats the bytes between them. +async fn store_body( + tables: &Tables, + message_id: &str, + project_id: &str, + body: &str, + head_len: usize, +) { + if body.len() <= head_len { return; } - let head = body_head(body); - let rest = &body[head.len()..]; + let rest = &body[split_boundary(body, head_len)..]; for (seq, chunk) in chunk_bytes(rest, MAX_MESSAGE_BODY).into_iter().enumerate() { let row = crate::db::schema::message_chunk::MessageChunkRow { id: format!("{message_id}#{seq}"), @@ -1698,6 +1709,66 @@ fn full_body(tables: &Tables, message_id: &str, head: &str) -> String { body } +/// Take one injected message into the live turn, and say whether the agent +/// text it interrupted was left mid-directive. +/// +/// Both waits in `drive_run` have to do this: the main loop, and the nested +/// wait that runs while an approval question stands, because "the moment the +/// user hits enter" is the delivery contract and an approval dialog on screen +/// is exactly when someone types "deny that and do X instead". +/// +/// It lived twice, once per wait, and the copies had already drifted: the +/// duplicate is what this exists to delete. Everything here is common to both, +/// and the one genuine difference between them is the return value. The main +/// loop reads it as `last_was_text`, because a user message is normally a +/// block boundary and an unfinished directive is the exception - the next +/// delta must finish that line rather than gain the paragraph break that broke +/// the span. The approval wait folds it into `preserve_text_adjacency` and +/// applies it at the end of the turn instead, having no `last_was_text` of its +/// own to set. +/// +/// Delivery itself is queued, never awaited: `deliver_injection` waits for a +/// provider receipt, and this runs on the task that must keep draining +/// provider events. +struct StreamedChunk<'a> { + /// Agent text streamed since the last row was closed. + body: &'a mut String, + /// When that text began, for the row it will eventually become. + started_at: &'a mut Option, + /// The row this chunk continued, once one has been written. + last_id: &'a mut Option, + /// The owner message id directives in this turn are attributed to. + directive_turn_id: &'a mut String, +} + +async fn accept_injection( + app: &AppHandle, + tables: &Tables, + context: AgentMessageContext<'_>, + injected: InjectedMessage, + chunk: StreamedChunk<'_>, + delivery: &tokio::sync::mpsc::UnboundedSender, +) -> bool { + // The user row was persisted and broadcast by `send_message`. Close the + // agent text the owner was replying to before delivering the new words. + let partial_directive = take_incomplete_prompt_syntax_tail(chunk.body); + if let Some(id) = + flush_continued_agent_chunk(app, tables, context, chunk.body, chunk.started_at).await + { + *chunk.last_id = Some(id); + } + if let Some(partial) = partial_directive.as_deref() { + *chunk.started_at = Some(now()); + chunk.body.push_str(partial); + } + if let InjectedMessage::Owner { message_id, .. } = &injected { + chunk.directive_turn_id.clear(); + chunk.directive_turn_id.push_str(message_id); + } + let _ = delivery.send(injected); + partial_directive.is_some() +} + /// A non-terminal slice of one agent turn, closed when the owner speaks into /// the live run. The final slice carries the run's real stop and usage. const CONTINUED_STOP: &str = "continued"; @@ -1716,17 +1787,69 @@ struct AgentMessageOutcome { exit_code: i64, } +/// What one `MessageRow`'s variable-length columns may sum to. +/// +/// A row must fit one 16356-byte page whole. Capping each column on its own +/// does not give that: `body` is allowed [`MAX_MESSAGE_BODY`] and `stop` +/// [`MAX_PERSISTED_BLOB`], and 12000 + 8000 is past the page on a row that +/// satisfies both. That is reachable, not theoretical: a reply over 12K that +/// then fails with a large provider error is one streamed turn plus one bad +/// response. The per-column caps stay, because each is also the right answer +/// for what that column is worth keeping; this is the budget they share. +/// +/// The margin covers the fixed columns (ids, agent, model, permission, +/// timestamps) and the row framing, which together are bounded and small. +const MAX_MESSAGE_ROW_BYTES: usize = 13_500; + +/// Bring `row` inside [`MAX_MESSAGE_ROW_BYTES`] by shortening the columns that +/// have somewhere else to be. +/// +/// Order is by what is recoverable. `body`'s tail is not lost when it is cut +/// here: [`store_body`] writes it to `message_chunk` from the caller's full +/// text, and the read path stitches it back, so the head shrinks with no loss +/// at all. `stop` has nowhere to spill, so it is trimmed only once `body` is at +/// its floor, and it keeps its head, which is the part that names the failure. +fn fit_message_row_to_page(row: &mut MessageRow) { + /// Enough of a failing `stop` to classify it and show the owner why. + const STOP_FLOOR: usize = 1_000; + let fixed = row.usage.len() + row.moderation.len(); + let Some(variable) = MAX_MESSAGE_ROW_BYTES.checked_sub(fixed) else { + // Usage and moderation are generated, bounded JSON, so this is not + // reachable from anything a provider sends. Cut both blobs to nothing + // rather than silently overflow if it ever becomes so. + row.body.clear(); + row.stop = truncate_to_bytes(&row.stop, STOP_FLOOR); + return; + }; + if row.body.len() + row.stop.len() <= variable { + return; + } + let stop_reserved = row.stop.len().min(STOP_FLOOR); + let body_budget = variable.saturating_sub(stop_reserved); + if row.body.len() > body_budget { + row.body.truncate(split_boundary(&row.body, body_budget)); + } + let stop_budget = variable.saturating_sub(row.body.len()); + if row.stop.len() > stop_budget { + row.stop = truncate_to_bytes(&row.stop, stop_budget); + } +} + async fn persist_message_body( tables: &Tables, - row: MessageRow, + mut row: MessageRow, body: &str, ) -> Result { + // Every message insert goes through here, which is why the row's page + // budget is enforced here and not at the twelve call sites that build one. + fit_message_row_to_page(&mut row); + let head_len = row.body.len(); tables .message .insert(row.clone()) .await .map_err(|error| error.to_string())?; - store_body(tables, &row.id, &row.project_id, body).await; + store_body(tables, &row.id, &row.project_id, body, head_len).await; let mut dto = MessageDto::from(row); dto.body = body.to_string(); Ok(dto) @@ -1801,13 +1924,14 @@ async fn finalize_agent_chunk( ) -> Result { tables .message - .update_finalize_by_id( + .update_by_id( + message_id.to_string(), + MessageColumns::USAGE_AND_STOP_AND_EXIT_CODE, FinalizeByIdQuery { usage, stop, exit_code, }, - message_id.to_string(), ) .await .map_err(|error| error.to_string())?; @@ -1827,8 +1951,17 @@ async fn persist_terminal_agent_chunk( body: String, started_at: Option, last_chunk_id: Option<&str>, - outcome: AgentMessageOutcome, + mut outcome: AgentMessageOutcome, ) -> Result { + // `body` has [`body_head`] and [`store_body`] to keep it inside the row's + // page; `stop` shares that page and had nothing. It is a short label for + // every ordinary outcome and the provider's error text for a failure, and + // one of those arrived as 17776 bytes of `claude` control JSON, which the + // engine answers with `PageTooSmall` after having twice corrupted this + // store on this machine. Capped here rather than at the two call sites so + // both the insert below and the `finalize_agent_chunk` path above it are + // covered. + outcome.stop = truncate_to_bytes(&outcome.stop, MAX_PERSISTED_BLOB); // A cancellation, provider failure, or clean stop can all land between two // deltas of an authored span. Persist the prose before it, never the // executable-looking fragment the agent did not finish authoring. @@ -2019,6 +2152,7 @@ fn parse_agent(raw: Option<&str>) -> Result { match raw.unwrap_or("claude") { "claude" => Ok(Agent::Claude), "codex" => Ok(Agent::Codex), + "grok" => Ok(Agent::Grok), "copilot" => Err("Copilot projects are not available yet".into()), other => Err(format!("unknown project agent: {other}")), } @@ -2037,6 +2171,7 @@ fn parse_review_agent(raw: Option<&str>) -> Result { "claude" => Ok(Agent::Claude), "codex" => Ok(Agent::Codex), "copilot" => Ok(Agent::Copilot), + "grok" => Ok(Agent::Grok), other => Err(format!("unknown review agent: {other}")), } } @@ -2117,17 +2252,11 @@ pub fn get_home_snapshot(state: State<'_, AppState>) -> HomeSnapshotDto { } #[tauri::command] -pub async fn list_items( - project_id: String, - state: State<'_, AppState>, -) -> Result, String> { - let tables = std::sync::Arc::clone(&state.tables); - tokio::task::spawn_blocking(move || list_item_rows(&tables, project_id)) - .await - .map_err(|error| error.to_string()) +pub fn list_items(project_id: String, state: State<'_, AppState>) -> Vec { + list_item_rows(&state.tables, project_id) } -/// The read itself, off both the window thread and the async workers. +/// The read itself, off the async workers because it is not async. /// /// A plain `async fn` was tried first and made this worse, not better: /// `list_items` went from 10.8ms average to 52.5ms. Tauri runs async commands @@ -2136,9 +2265,17 @@ pub async fn list_items( /// store read there stops it queueing behind other reads and starts it queueing /// behind those. /// -/// `spawn_blocking` is a tokio task on the dedicated blocking pool, which is -/// neither the window thread nor an async worker, so a read waits on nothing it -/// has no reason to wait on. +/// The answer is not to ship it somewhere else, it is not to make it async at +/// all. A synchronous `#[tauri::command]` runs on the invoke thread rather than +/// the async runtime, so it never joins that queue, and there is no executor +/// between the caller and the answer. Two designs did ship it elsewhere first, +/// `tokio::task::spawn_blocking` and then a nagoya pool, and the second bought +/// a defect with it: awaiting a nagoya `JoinHandle` from Tauri's tokio task +/// registers the waker with one executor and wakes it from the other, and a +/// wake lost in that handoff is a command that never returns. Sixteen +/// `discover_chat_imports` dispatches were answered seven times in one +/// session, the last eight wedged, which is what left Settings showing "No +/// sessions discovered" and ps-qa's `select` group failing. fn list_item_rows(tables: &Tables, project_id: String) -> Vec { let mut rows: Vec = tables .project_item @@ -2214,7 +2351,7 @@ async fn write_item_positions( futures::future::try_join_all(changes.into_iter().map(|(item_id, position)| async move { tables .project_item - .update_position_by_id(ItemPositionByIdQuery { position }, item_id) + .update_by_id(item_id, ProjectItemColumns::POSITION, position) .await .map_err(|error| error.to_string()) })) @@ -2481,11 +2618,10 @@ async fn write_item_status( tables .project_item - .update_status_by_id( - ItemStatusByIdQuery { - status: status.to_string(), - }, + .update_by_id( id.to_string(), + ProjectItemColumns::STATUS, + status.to_string(), ) .await .map_err(|error| error.to_string())?; @@ -2830,11 +2966,11 @@ pub async fn update_item( if title.is_empty() { return Err("an item needs a title".into()); } - let (write, ()) = tokio::join!( + let (write, ()) = futures::join!( state .tables .project_item - .update_title_by_id(ItemTitleByIdQuery { title }, id.clone()), + .update_by_id(id.clone(), ProjectItemColumns::TITLE, title), touch_item(&state.tables, &id), ); write.map_err(|error| error.to_string())?; @@ -2890,12 +3026,11 @@ async fn link_item_issue_inner( let url = github_issue_url(authored_url).map_err(|reason| format!("ENTITY_NOT_FOUND: {reason}"))?; let reference = format!("issue:{url}"); - let (write, ()) = tokio::join!( - tables.project_item.update_reference_by_id( - ItemReferenceByIdQuery { - reference: reference.clone(), - }, + let (write, ()) = futures::join!( + tables.project_item.update_by_id( id.to_string(), + ProjectItemColumns::REFERENCE, + reference.clone(), ), touch_item(tables, id), ); @@ -3001,9 +3136,17 @@ pub async fn unmark_item_deletion( /// stable enough for the desktop-sized lists this handles, and refusing a /// partial list would make every caller re-fetch before every move. /// +/// Returns the rows it moved, not the project's whole list. This is an async +/// command, so its body runs on the async workers, and [`list_items`] is +/// synchronous precisely because that is the wrong place for a scan of every +/// item in a project: see [`list_item_rows`] for the 10.8ms to 52.5ms this +/// cost when the read sat there. The moved rows are point lookups by id, and +/// they are all the caller consumes, which the store's `reorderItems` shows by +/// upserting each returned row and dropping the rest. +/// /// # Errors /// Returns the first store failure; positions written before it stand, which -/// the returned (re-read) list makes visible rather than papering over. +/// the returned rows make visible rather than papering over. #[tauri::command] pub async fn reorder_items( app: AppHandle, @@ -3013,9 +3156,12 @@ pub async fn reorder_items( ) -> Result, String> { let started = std::time::Instant::now(); let moved = write_item_positions(&state.tables, &ids, 0).await?; - let items = list_items(project_id.clone(), state.clone()).await?; - let moved: std::collections::HashSet<&str> = moved.iter().map(String::as_str).collect(); - for item in items.iter().filter(|item| moved.contains(item.id.as_str())) { + let items: Vec = moved + .iter() + .filter_map(|id| state.tables.project_item.select(id.clone())) + .map(|row| item_dto(row, &state.tables)) + .collect(); + for item in &items { let _ = app.emit("item:updated", item.clone()); } let mut study = @@ -3047,19 +3193,15 @@ pub struct MessagePage { /// /// Passing `None` still returns everything, for callers that genuinely want it. #[tauri::command] -pub async fn list_messages( +pub fn list_messages( project_id: String, limit: Option, state: State<'_, AppState>, -) -> Result { - let tables = std::sync::Arc::clone(&state.tables); - tokio::task::spawn_blocking(move || message_page(&tables, project_id, limit)) - .await - .map_err(|error| error.to_string()) +) -> MessagePage { + message_page(&state.tables, project_id, limit) } -/// See [`list_item_rows`] for why this is a blocking-pool task and not an -/// `async fn`. +/// See [`list_item_rows`] for why its command is synchronous. fn message_page(tables: &Tables, project_id: String, limit: Option) -> MessagePage { let reply_targets: std::collections::HashMap = tables .question_reply @@ -3858,11 +4000,10 @@ async fn apply_directive( if let Some(number) = pr_number.as_deref() && let Err(error) = tables .project_item - .update_reference_by_id( - ItemReferenceByIdQuery { - reference: number.to_string(), - }, + .update_by_id( resolved.clone(), + ProjectItemColumns::REFERENCE, + number.to_string(), ) .await { @@ -4296,11 +4437,10 @@ async fn apply_directive( }; match tables .project_item - .update_reference_by_id( - ItemReferenceByIdQuery { - reference: number.clone(), - }, + .update_by_id( resolved.clone(), + ProjectItemColumns::REFERENCE, + number.clone(), ) .await { @@ -5254,7 +5394,14 @@ async fn user_message_for_send( .insert(row.clone()) .await .map_err(|error| error.to_string())?; - store_body(&state.tables, &row.id, &input.project_id, &input.body).await; + store_body( + &state.tables, + &row.id, + &input.project_id, + &input.body, + MAX_MESSAGE_BODY, + ) + .await; // The emitted DTO carries the whole body, not just the stored head: the // caller has it in hand and the reader would otherwise have to round-trip @@ -5413,7 +5560,7 @@ async fn clear_partial_reply(tables: &Tables, project_id: &str) { ); } // One-time cleanup for 0.1.124-0.1.131. - if let Err(error) = tokio::fs::remove_file(legacy_partial_reply_path(tables, project_id)).await + if let Err(error) = std::fs::remove_file(legacy_partial_reply_path(tables, project_id)) && error.kind() != std::io::ErrorKind::NotFound { crate::log!( @@ -5607,7 +5754,14 @@ async fn recover_partial_reply(tables: &Tables, project_id: &str, raw: String) - ); return false; } - store_body(tables, &message_id, project_id, &checkpoint_body).await; + store_body( + tables, + &message_id, + project_id, + &checkpoint_body, + MAX_MESSAGE_BODY, + ) + .await; crate::log!( crate::log::Level::Info, "run", @@ -5667,35 +5821,50 @@ pub async fn recover_partial_replies_excluding( // Upgrade path for builds 0.1.124 through 0.1.131. These files are consumed // and removed; no new build writes them. + // + // Synchronous on purpose. This runs once at boot over a directory that no + // build since 0.1.131 writes to, so it is a handful of files read before + // the window exists. `tokio::fs` is a thread pool behind an async facade; + // paying for that indirection to read three files at startup bought an + // executor dependency and no concurrency, because the recovery below is + // sequential anyway. let legacy_recovery_dir = tables.data_dir.join("recovery"); - if let Ok(mut entries) = tokio::fs::read_dir(&legacy_recovery_dir).await { - while let Ok(Some(entry)) = entries.next_entry().await { - let name = entry.file_name(); - let name = name.to_string_lossy(); - let Some(project_id) = name - .strip_prefix(PARTIAL_REPLY_FILE_PREFIX) - .and_then(|name| name.strip_suffix(PARTIAL_REPLY_FILE_SUFFIX)) - else { - if name.starts_with(PARTIAL_REPLY_FILE_PREFIX) && name.contains(".tmp-") { - let _ = tokio::fs::remove_file(entry.path()).await; - } - continue; - }; - match tokio::fs::read_to_string(entry.path()).await { - Ok(raw) => { - if recover_partial_reply(tables, project_id, raw).await { - let _ = tokio::fs::remove_file(entry.path()).await; - } + let legacy_entries: Vec = std::fs::read_dir(&legacy_recovery_dir) + .map(|entries| { + entries + .filter_map(std::result::Result::ok) + .map(|entry| entry.path()) + .collect() + }) + .unwrap_or_default(); + for path in legacy_entries { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let Some(project_id) = name + .strip_prefix(PARTIAL_REPLY_FILE_PREFIX) + .and_then(|name| name.strip_suffix(PARTIAL_REPLY_FILE_SUFFIX)) + else { + if name.starts_with(PARTIAL_REPLY_FILE_PREFIX) && name.contains(".tmp-") { + let _ = std::fs::remove_file(&path); + } + continue; + }; + match std::fs::read_to_string(&path) { + Ok(raw) => { + if recover_partial_reply(tables, project_id, raw).await { + let _ = std::fs::remove_file(&path); } - Err(error) => crate::log!( - crate::log::Level::Warn, - "run", - "{project_id}: could not read the reply checkpoint: {error}" - ), } + Err(error) => crate::log!( + crate::log::Level::Warn, + "run", + "{project_id}: could not read the reply checkpoint: {error}" + ), } } - let _ = tokio::fs::remove_dir(&legacy_recovery_dir).await; + let _ = std::fs::remove_dir(&legacy_recovery_dir); // Upgrade path for builds through 0.1.123. let rows = tables.kv.select_all().execute().unwrap_or_default(); @@ -5920,6 +6089,10 @@ pub struct RateLimitReport { pub is_warning: bool, /// When this arrived, so a stale report can be recognised as one. pub at: String, + /// 0–100, when Grok's `x.ai/session/usage` (or similar) reports how full + /// the weekly window is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub used_percent: Option, } impl RateLimitReport { @@ -6196,6 +6369,13 @@ fn run_start_timeout(resume: Option<&str>) -> std::time::Duration { /// recoverable stall, not a failed run: the session resumes. const RUN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5 * 60); +/// Grok 4.6/4.5 double input/output prices once the prompt is ≥200k. Compact +/// at 180k so the next tool-heavy turn stays under the cliff. Native Grok +/// auto-compact is 85% of 500k (425k), which is already in the 2× zone. +const GROK_COMPACT_BEFORE_CLIFF: u64 = 180_000; +/// Second mid-turn steer: stop after the current tool; 200k is next. +const GROK_CLIFF_URGENT: u64 = 190_000; + /// Home cleanup is one classification request, never an open-ended agent turn. const TASK_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -6246,6 +6426,72 @@ const LIVENESS_PING: &str = "\n\nLiveness check, not owner in line to confirm you are still working, then carry on with what you were \ doing. Do not describe this check to the owner."; +/// In-channel Grok steer at 180k. Compact waits until this turn ends so +/// in-flight tools are not torn down; this is how unfinished work reaches disk +/// first. Delivered as a mid-turn user message (`_x.ai/interject`). +fn grok_cliff_steer(used: u64, urgent: bool) -> String { + if urgent { + format!( + "Context is {used}/500000 — the 200k long-context price doubling is next.\n\ + Stop after the current tool. Write any unfinished intent to disk now \ + (the files you were changing, and the next concrete step).\n\ + Do not open more files, spawn more work, or start a new investigation.\n\ + After this turn AgencyZero will take durable notes and compact." + ) + } else { + format!( + "Context is {used}/500000. Grok doubles input/output prices at 200k \ + for the whole prompt.\n\ + Checkpoint now: persist unfinished work to disk (files in progress, \ + next concrete step). Finish the current edit; do not start a new \ + large investigation or more large reads.\n\ + After this turn AgencyZero will take durable notes (outside the \ + conversation) and compact." + ) + } +} + +/// Persist the auto-steer as a user row so the transcript shows what AZ +/// injected, then deliver it on the same in-channel path as an owner follow-up. +async fn persist_grok_cliff_steer( + app: &AppHandle, + tables: &Tables, + project_id: &str, + agent: Agent, + model: &str, + body: &str, +) -> Option { + let row = MessageRow { + id: id("msg"), + project_id: project_id.to_string(), + item_id: String::new(), + author: "user".into(), + agent: agent_wire_name(agent).into(), + moderation: String::new(), + model: model.to_string(), + permission: "auto".into(), + usage: String::new(), + stop: "completed".into(), + exit_code: -1, + body: body_head(body), + created_at: now(), + }; + if let Err(error) = tables.message.insert(row.clone()).await { + crate::log!( + crate::log::Level::Warn, + "run", + "{project_id}: could not persist the Grok cliff steer: {error}" + ); + return None; + } + store_body(tables, &row.id, project_id, body, MAX_MESSAGE_BODY).await; + let mut message = MessageDto::from(row); + message.body = body.to_string(); + let message_id = message.id.clone(); + let _ = app.emit("message:appended", message); + Some(message_id) +} + /// The live run in each project: a reservation that there is at most one, and /// the signal that stops it. /// @@ -6264,7 +6510,12 @@ pub struct ActiveRun { /// Identifies this reservation, so an older driver finishing late cannot /// remove a newer run that has already claimed the same project slot. pub reservation_id: String, - pub cancel: tokio::sync::watch::Sender, + /// The run's stop switch. + /// + /// A `watch::Sender` before, which split one fact across a sender + /// and its receivers and made "has it stopped" a channel question. This is + /// the fact itself: any holder can throw it, and every waiter is woken. + pub cancel: crate::cancel::Cancel, /// Provider owning the live session. A tab may switch providers while it /// runs, but its next message must not be injected into the old provider. pub agent: Agent, @@ -6293,26 +6544,26 @@ pub enum InjectedMessage { /// An owner-authored follow-up whose visible transcript row may need a /// fresh-turn retry if interactive delivery fails. Owner { - body: String, - original_body: String, - reply_question_id: Option, - message_id: String, + body: Arc, + original_body: Arc, + reply_question_id: Option>, + message_id: Arc, }, /// Reviewer output already durable as an `author = review` message. A live /// failure leaves it for the ordinary next-turn snapshot and never cancels /// the owner's active run. Review { - body: String, - message_id: String, - reviewer: String, - url: String, + body: Arc, + message_id: Arc, + reviewer: Arc, + url: Arc, }, } #[derive(Default)] pub struct ActiveRuns { runs: std::sync::Mutex>, - released: tokio::sync::Notify, + released: nagoya::sync::Notify, } impl ActiveRuns { @@ -6346,9 +6597,12 @@ impl ActiveRuns { pub async fn wait_until_idle(&self) -> Result<(), String> { loop { + // Built before the state is read, and that order is the whole + // guarantee: `notified()` snapshots the broadcast generation, so a + // release landing between the check below and the await is seen as + // a generation that moved rather than a wake that arrived with + // nobody parked. Tokio needed `enable()` to buy the same thing. let released = self.released.notified(); - tokio::pin!(released); - released.as_mut().enable(); if self .runs .lock() @@ -6363,9 +6617,8 @@ impl ActiveRuns { pub async fn wait_until_released(&self, project_id: &str) -> Result<(), String> { loop { + // Same order as [`Self::wait_until_idle`], for the same reason. let released = self.released.notified(); - tokio::pin!(released); - released.as_mut().enable(); if !self .runs .lock() @@ -6419,10 +6672,10 @@ fn queue_mid_turn_review( }; inject .send(InjectedMessage::Review { - body: mid_turn_review_context(reviewer, url, exit_code, body), - message_id: message_id.to_string(), - reviewer: reviewer.to_string(), - url: url.to_string(), + body: mid_turn_review_context(reviewer, url, exit_code, body).into(), + message_id: message_id.into(), + reviewer: reviewer.into(), + url: url.into(), }) .is_ok() } @@ -6508,20 +6761,13 @@ impl Drop for RunReservation { /// What is running in this project right now. #[tauri::command] -pub async fn list_running_tasks( - project_id: String, - state: State<'_, AppState>, -) -> Result, String> { - let running = std::sync::Arc::clone(&state.running); - // See [`list_item_rows`]: a tokio blocking task, not an async worker. - tokio::task::spawn_blocking(move || { - running - .lock() - .map(|tasks| tasks.get(&project_id).cloned().unwrap_or_default()) - .unwrap_or_default() - }) - .await - .map_err(|error| error.to_string()) +pub fn list_running_tasks(project_id: String, state: State<'_, AppState>) -> Vec { + // See [`list_item_rows`]: a mutex read is not async work. + state + .running + .lock() + .map(|tasks| tasks.get(&project_id).cloned().unwrap_or_default()) + .unwrap_or_default() } /// A page of the task log, plus the total the page came out of. @@ -7699,7 +7945,7 @@ pub async fn cancel_run(project_id: String, state: State<'_, AppState>) -> Resul if canceled_proxy_runs == 0 && let Some((_, cancel)) = ®istered { - let _ = cancel.send(true); + cancel.cancel(); } if let Some((reservation_id, _)) = ®istered { @@ -7900,97 +8146,536 @@ fn compacted_context_tokens(before: u64) -> u64 { } } -/// Summarise the conversation so far and continue from the summary. -/// -/// The answer to a session that has filled its context window: past about -/// four-fifths of it the model is measurably worse at what it was doing, and -/// there is nothing a user can do about it from the composer. +/// Standing occupancy to store on the compact system row. /// -/// Runs `agent-abstraction`'s `Command::Compact` rather than sending the text -/// `/compact`, which is the whole point of the crate's typed surface: the -/// literal would reach an agent without a command vocabulary as prose and come -/// back as an essay about compaction, indistinguishable from success. +/// Learn and `/compact` are 1-call turns whose billed `inputTokens` are the +/// *old* window (session 01a09ca7: learn 223k, compact 264k, live fill 29k). +/// Folding that into `context_tokens` made the next prompt look like 260k. +/// Keep a post-compact `session/info` / `tokens_after` figure when it is +/// clearly the new fill; otherwise estimate. +fn post_compact_standing( + agent: Agent, + learned: Option<&agent_abstraction::Usage>, + compact: Option<&agent_abstraction::Usage>, +) -> agent_abstraction::Usage { + let before = learned.and_then(|usage| usage.context_tokens).or_else(|| { + compact + .and_then(|usage| usage.context_tokens) + .filter(|&used| used >= GROK_COMPACT_BEFORE_CLIFF) + }); + let mut standing = agent_abstraction::Usage::default(); + if let Some(usage) = learned { + let mut copy = *usage; + copy.context_tokens = None; + standing.accumulate(©); + } + if let Some(usage) = compact { + let mut copy = *usage; + if copy + .context_tokens + .is_some_and(|used| used >= GROK_COMPACT_BEFORE_CLIFF) + { + copy.context_tokens = None; + } + standing.accumulate(©); + } + if agent == Agent::Grok { + if standing.context_tokens.is_none() { + standing.context_tokens = + Some(compacted_context_tokens(before.unwrap_or(0)).max(8_000)); + } + standing.context_window = standing.context_window.or(Some(500_000)); + } else { + standing.context_tokens = standing + .context_tokens + .or(before) + .map(compacted_context_tokens); + } + standing +} + +/// In-channel continue after compact so unfinished work does not sit idle. +fn compact_resume_prompt() -> &'static str { + "Compaction finished. Continue the in-flight work. Unfinished intent is on \ + disk in this project's memory directory; notes survived outside the \ + conversation. Do not recap. Do not wait for another owner message." +} + +/// TUI markup. Grok's interactive client parses `` and runs it; +/// ACP `session/prompt` does not. The model then `end_turn`s, Running stays +/// empty, and the XML sits in the transcript (session 01a09ca7 turns 14–15). +fn grok_leaked_xml_tools(text: &str) -> bool { + text.contains("") +} + +/// A `` span on its own line whose namespace is not the one this app +/// declares, captured whole. /// -/// # Its own turn, and its own slot +/// # Recognise and record, never dispatch /// -/// A compaction is a turn: it resumes the session, rewrites it, and settles. -/// So it claims the same one-run-per-project slot a message does — really -/// claims it, by holding a [`RunReservation`] for its whole body. Checking the -/// registry without inserting into it was the bug behind "why is my message not -/// queued": nothing knew a compaction was running, so a send during one started -/// a second run against the same session. +/// Two different operations hide under "parse". Prompt Syntax 13.2 makes model +/// output inert unless it names the declared authoring namespace, and +/// `directives.rs` refuses a foreign one so it can never reach the executor. +/// That refusal is a security boundary and does not move: `@antml:invoke` is +/// the *sending* model's own tool-call vocabulary, and making a foreign +/// namespace live would let one session's malformed tool call address another +/// session's executor. /// -/// It writes no assistant reply — a compaction produces no answer — so the -/// transcript gets a system note instead, which is the only durable record that -/// the conversation the user is reading was rewritten underneath them. +/// Reading a span to understand and record it is the other operation, and it +/// grants no authority at all. The span was sent for a purpose: the verb and +/// arguments are the model's stated intent, and dropping them loses the only +/// evidence of what the turn meant to do. So the whole span is kept — for the +/// task log, and to hand back in the correction. /// -/// # Without a session +/// A leaked span is a tool call the model believed it made: it ends the turn +/// awaiting a result, nothing ran, and the work silently vanishes. The same +/// failure as [`grok_leaked_xml_tools`], reached through a different grammar. /// -/// Runs on a fresh one and records the id the agent hands back. A command that -/// demanded an existing session made `/compact` fail on an untouched project -/// until the user sent a throwaway message to bring a session into being, which -/// is the opposite of what a command is for. Compacting an empty conversation -/// is the agent's own question to answer, and it answers it. +/// Deliberately narrow: only a well-formed span alone on its line counts, so +/// prose *about* Prompt Syntax (this codebase discusses it constantly) and any +/// quoted or fenced example stay clear of it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ForeignSpan { + /// The namespace that is not live here, e.g. `antml`. + namespace: String, + /// The verb it tried to address, e.g. `invoke`. Empty if unparseable. + verb: String, + /// The span exactly as the model wrote it, arguments and all. + raw: String, +} + +/// Every leaked span in the turn, in the order the model wrote them. /// -/// # Errors -/// When a run is already active, or when the agent refuses. A conversation too -/// short to summarise is the agent's own refusal and comes back as its message, -/// not as a crash. -#[tauri::command] -pub async fn compact_project( - app: AppHandle, - project_id: String, - agent: Option, - state: State<'_, AppState>, -) -> Result<(), String> { - let agent = parse_agent(agent.as_deref())?; - let app_owned_rollover = !agent.caps().commands && agent == Agent::Codex; - if !agent.caps().commands && !app_owned_rollover { - return Err(format!( - "{} does not expose a command vocabulary, so this conversation cannot be compacted from AgencyZero", - agent_wire_name(agent) - )); - } - let session = state - .tables - .kv_get(&agent_session_key(&project_id, agent)) - .filter(|id| !id.is_empty()); - if app_owned_rollover && session.is_none() { - return Err("this Codex conversation is already fresh".into()); +/// All of them, never just the first. A turn that blended grammars once will +/// usually do it repeatedly, and each span is a separate call the model +/// believed it made. Reporting one and dropping the rest would hide the size +/// of the problem behind the very silence this exists to remove: the owner +/// must be able to see exactly what was attempted, with no mystery about +/// which tools were called. +fn leaked_foreign_namespace_spans(text: &str) -> Vec { + let mut fenced = FenceState::default(); + let mut found = Vec::new(); + for line in text.lines() { + let Some(trimmed) = authored_directive_line(line, &mut fenced) else { + continue; + }; + let Some(header) = trimmed.strip_prefix("') { + continue; + } + let Some((namespace, rest)) = header + .trim_start() + .strip_prefix('@') + .and_then(|rest| rest.split_once(':')) + else { + continue; + }; + if namespace.is_empty() + || !namespace + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + || namespace.eq_ignore_ascii_case(crate::directives::SURFACE.namespace) + { + continue; + } + // The verb runs to `(` for a call, or to the closing `>` without one. + let verb = rest + .split_once('(') + .map_or_else( + || rest.trim_end_matches('>').trim(), + |(verb, _)| verb.trim(), + ) + .to_string(); + found.push(ForeignSpan { + namespace: namespace.to_string(), + verb, + raw: trimmed.to_string(), + }); } + found +} - // Held for the rest of the body: the slot is released when this drops, - // however the compaction ends. - let (_reservation, mut cancel) = { - let mut active = state - .active - .lock() - .map_err(|_| "the run registry is unavailable".to_string())?; - if active.contains_key(&project_id) { - return Err(BUSY_WITH_RUN_ALREADY.into()); - } - let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); - let reservation_id = id("reservation"); - active.insert( - project_id.clone(), - ActiveRun { - reservation_id: reservation_id.clone(), - cancel: cancel_tx, - agent, - workspace_roots: Vec::new(), - ready_for_followup: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - // Nothing to say into: see `ActiveRun::inject`. - inject: None, - }, - ); - ( - RunReservation { - active: state.active.clone(), - app: Some(app.clone()), - project_id: project_id.clone(), - reservation_id, - }, - cancel_rx, +/// Render every leaked span as a list, one fenced block each. +/// +/// Verbatim and unabridged: no truncation, no "and N more". The point of the +/// record is that the owner can see exactly which calls were attempted. +fn foreign_span_inventory(spans: &[ForeignSpan]) -> String { + spans + .iter() + .map(|span| { + let verb = if span.verb.is_empty() { + String::new() + } else { + format!(" — verb `{}`", span.verb) + }; + format!( + "- `@{}`{verb}\n\n ```text\n {}\n ```", + span.namespace, span.raw + ) + }) + .collect::>() + .join("\n") +} + +/// Put the leak in the transcript the owner actually reads. +/// +/// Not a study row: study is opt-in and its `detail` is counters-only by +/// design, so it cannot hold the span text and is not the owner's task log. +/// A system row is the app's own voice, which is what this is — AgencyZero +/// reporting that something addressed it and was not run. +async fn persist_foreign_namespace_leak( + app: &AppHandle, + tables: &Tables, + project_id: &str, + agent: Agent, + model: &str, + spans: &[ForeignSpan], +) { + if spans.is_empty() { + return; + } + let mut namespaces: Vec<&str> = spans.iter().map(|span| span.namespace.as_str()).collect(); + namespaces.sort_unstable(); + namespaces.dedup(); + let heading = if spans.len() == 1 { + "A `` span was not executed.".to_string() + } else { + format!("{} `` spans were not executed.", spans.len()) + }; + let body = format!( + "{heading}\n\n\ + {} not a live namespace in this application, so {} stayed inert text \ + and nothing ran. Recorded here in full because {} sent for a purpose: \ + if {} tool calls, those calls did not happen.\n\n{}", + if namespaces.len() == 1 { + format!("`@{}` is", namespaces[0]) + } else { + format!( + "{} are", + namespaces + .iter() + .map(|namespace| format!("`@{namespace}`")) + .collect::>() + .join(", ") + ) + }, + if spans.len() == 1 { "it" } else { "they" }, + if spans.len() == 1 { + "it was" + } else { + "they were" + }, + if spans.len() == 1 { + "it was a tool call" + } else { + "they were tool calls" + }, + foreign_span_inventory(spans) + ); + let row = MessageRow { + id: id("msg"), + project_id: project_id.to_string(), + item_id: String::new(), + author: "system".into(), + agent: agent_wire_name(agent).into(), + moderation: String::new(), + model: model.to_string(), + permission: String::new(), + usage: String::new(), + stop: "completed".into(), + exit_code: 0, + body: body_head(&body), + created_at: now(), + }; + if let Err(error) = tables.message.insert(row.clone()).await { + crate::log!( + crate::log::Level::Warn, + "run", + "{project_id}: could not record {} inert foreign span(s): {error}", + spans.len() + ); + return; + } + store_body(tables, &row.id, project_id, &body, MAX_MESSAGE_BODY).await; + let mut message = MessageDto::from(row); + message.body = body; + let _ = app.emit("message:appended", message); +} + +/// Tell the model its spans did not execute, and give it back what it wrote. +/// +/// Quoting them is the point. The model's intent lives in the verbs and +/// arguments, and a correction that only says "that failed" throws away the +/// very thing needed to retry. Naming both live surfaces matters too: without +/// that the model cannot tell whether it wanted a native tool or an +/// AgencyZero directive, and the usual retry is the same span with the +/// namespace edited. +/// The one sentence of [`foreign_namespace_resume_prompt`] that does not +/// inflect, and so the only safe thing for the loop guard to match on. +/// +/// The guard used to look for "was not executed:", which is the singular +/// opening. Two or more leaked spans open with "were", so the guard missed its +/// own correction, resumed, and corrected the correction: the run re-prompted +/// itself for as long as the model kept quoting the spans back. Both sides name +/// this constant now, which is what stops them drifting apart again. +const FOREIGN_NAMESPACE_CORRECTION: &str = "Those namespaces are not live here."; + +fn foreign_namespace_resume_prompt(spans: &[ForeignSpan]) -> String { + let (subject, were, them, they, subj) = if spans.len() == 1 { + ("This span", "was", "it", "it was", "it") + } else { + ("These spans", "were", "them", "they were", "they") + }; + format!( + "{subject} in your last reply {were} not executed:\n\n{}\n\n\ + {FOREIGN_NAMESPACE_CORRECTION} AgencyZero declares `@{agency}` \ + only; every other namespace stays inert text, so {subj} reached the \ + transcript verbatim and nothing ran.\n\n\ + If {they} tool calls, make {them} natively now — the calls did not \ + happen and their results never came back. If {they} meant to be \ + AgencyZero directives, reissue with `@{agency}:` and a verb from the \ + per-turn list. Continue the in-flight work; do not recap, and do not \ + wait for another owner message.", + foreign_span_inventory(spans), + agency = crate::directives::SURFACE.namespace + ) +} + +fn should_resume_after_foreign_namespace(prompt: &str, cancelled: bool) -> bool { + !cancelled && !prompt.contains(FOREIGN_NAMESPACE_CORRECTION) +} + +fn grok_xml_resume_prompt() -> &'static str { + "Those XML tool blocks in the last reply were not executed. AgencyZero \ + talks to Grok over ACP: tools must be native function calls, not markup \ + in assistant text. Continue the in-flight work with native tools only. \ + Do not recap. Do not wait for another owner message." +} + +fn should_resume_after_xml_leak(agent: Agent, prompt: &str, cancelled: bool) -> bool { + agent == Agent::Grok + && !cancelled + && !prompt.contains("Those XML tool blocks in the last reply were not executed") +} + +/// Sent every Grok turn via `--rules` / `_meta.rules`. Compaction-immune. +fn grok_system_rules() -> &'static str { + "Reply only to the current user request. AgencyZero Prompt Syntax \ + item/PR lists in the prompt are structured state, not a question. \ + Do not recap prior turns or enumerate items unless asked. \ + Do not call enter_plan_mode or exit_plan_mode — they hang over ACP; \ + write plans as ordinary assistant text. Do not edit ~/.grok/config.toml. \ + Never write XML tool markup in assistant text — ACP does not execute it \ + and the turn ends. Use native tools only." +} + +fn last_run_permission(tables: &Tables, project_id: &str, agent: Agent) -> Option { + tables + .message + .select_by_project_id(project_id.to_string()) + .execute() + .unwrap_or_default() + .into_iter() + .filter(|row| { + row.agent == agent_wire_name(agent) + && (row.author == "user" || row.author == "agent") + && !row.permission.is_empty() + }) + .max_by(|left, right| left.created_at.cmp(&right.created_at)) + .map(|row| row.permission) +} + +/// Why a compaction happened, which decides whether the turn resumes itself. +/// +/// Compaction interrupts work that was already in flight *only* when the app +/// started it. Grok is compacted by AgencyZero before the 200k price cliff, +/// mid-task and unasked, so leaving the conversation summarised and idle +/// strands the work that triggered it: that case resumes. +/// +/// An owner pressing Compact is the opposite. They chose the moment, and the +/// next instruction is theirs to give. Resuming there spends a turn nobody +/// asked for, which is what this exists to prevent. The distinction is the +/// trigger and not the agent, so an owner-driven compaction of a Grok project +/// stays silent too. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompactTrigger { + /// The owner pressed Compact. Say nothing afterwards. + Owner, + /// The app compacted to stay under a provider limit, interrupting a turn. + Automatic, +} + +impl CompactTrigger { + /// Whether the interrupted work should be picked back up. + const fn resumes(self) -> bool { + matches!(self, Self::Automatic) + } +} + +fn spawn_resume_after_compact(app: AppHandle, project_id: String, agent: Agent, model: String) { + spawn_host_resume( + app, + project_id, + agent, + model, + compact_resume_prompt().into(), + "compact", + ); +} + +fn spawn_host_resume( + app: AppHandle, + project_id: String, + agent: Agent, + model: String, + body: String, + reason: &'static str, +) { + tauri::async_runtime::spawn(async move { + tokio::task::yield_now().await; + let state = app.state::(); + let permission = last_run_permission(&state.tables, &project_id, agent) + .or_else(|| (agent == Agent::Grok).then(|| "auto".into())); + match send_message( + app.clone(), + SendMessageInput { + project_id: project_id.clone(), + body, + retry_message_id: None, + reply_question_id: None, + item_id: None, + agent: Some(agent_wire_name(agent).into()), + model: if model.is_empty() { None } else { Some(model) }, + permission, + effort: None, + extra_thinking: None, + stateless: false, + study: None, + }, + state, + ) + .await + { + Ok(_) => crate::log!( + crate::log::Level::Info, + "run", + "{project_id}: resumed after {reason}" + ), + Err(error) => crate::log!( + crate::log::Level::Warn, + "run", + "{project_id}: could not resume after {reason}: {error}" + ), + } + }); +} + +/// Summarise the conversation so far and continue from the summary. +/// +/// The answer to a session that has filled its context window: past about +/// four-fifths of it the model is measurably worse at what it was doing, and +/// there is nothing a user can do about it from the composer. +/// +/// Runs `agent-abstraction`'s `Command::Compact` rather than sending the text +/// `/compact`, which is the whole point of the crate's typed surface: the +/// literal would reach an agent without a command vocabulary as prose and come +/// back as an essay about compaction, indistinguishable from success. +/// +/// # Its own turn, and its own slot +/// +/// A compaction is a turn: it resumes the session, rewrites it, and settles. +/// So it claims the same one-run-per-project slot a message does — really +/// claims it, by holding a [`RunReservation`] for its whole body. Checking the +/// registry without inserting into it was the bug behind "why is my message not +/// queued": nothing knew a compaction was running, so a send during one started +/// a second run against the same session. +/// +/// It writes no assistant reply — a compaction produces no answer — so the +/// transcript gets a system note instead, which is the only durable record that +/// the conversation the user is reading was rewritten underneath them. +/// +/// # Without a session +/// +/// Runs on a fresh one and records the id the agent hands back. A command that +/// demanded an existing session made `/compact` fail on an untouched project +/// until the user sent a throwaway message to bring a session into being, which +/// is the opposite of what a command is for. Compacting an empty conversation +/// is the agent's own question to answer, and it answers it. +/// +/// # Errors +/// When a run is already active, or when the agent refuses. A conversation too +/// short to summarise is the agent's own refusal and comes back as its message, +/// not as a crash. +#[tauri::command] +pub async fn compact_project( + app: AppHandle, + project_id: String, + agent: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + // The command is the owner's own hand on the button. Anything the app + // starts by itself calls `compact_project_with` and says so. + compact_project_with(app, project_id, agent, CompactTrigger::Owner, state).await +} + +pub async fn compact_project_with( + app: AppHandle, + project_id: String, + agent: Option, + trigger: CompactTrigger, + state: State<'_, AppState>, +) -> Result<(), String> { + let agent = parse_agent(agent.as_deref())?; + let app_owned_rollover = !agent.caps().commands && agent == Agent::Codex; + if !agent.caps().commands && !app_owned_rollover { + return Err(format!( + "{} does not expose a command vocabulary, so this conversation cannot be compacted from AgencyZero", + agent_wire_name(agent) + )); + } + let session = state + .tables + .kv_get(&agent_session_key(&project_id, agent)) + .filter(|id| !id.is_empty()); + if app_owned_rollover && session.is_none() { + return Err("this Codex conversation is already fresh".into()); + } + + // Held for the rest of the body: the slot is released when this drops, + // however the compaction ends. + let (_reservation, cancel) = { + let mut active = state + .active + .lock() + .map_err(|_| "the run registry is unavailable".to_string())?; + if active.contains_key(&project_id) { + return Err(BUSY_WITH_RUN_ALREADY.into()); + } + let cancel_tx = crate::cancel::Cancel::new(); + let cancel_rx = cancel_tx.clone(); + let reservation_id = id("reservation"); + active.insert( + project_id.clone(), + ActiveRun { + reservation_id: reservation_id.clone(), + cancel: cancel_tx, + agent, + workspace_roots: Vec::new(), + ready_for_followup: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + // Nothing to say into: see `ActiveRun::inject`. + inject: None, + }, + ); + ( + RunReservation { + active: state.active.clone(), + app: Some(app.clone()), + project_id: project_id.clone(), + reservation_id, + }, + cancel_rx, ) }; @@ -8014,9 +8699,13 @@ pub async fn compact_project( // from the project's directories and never asked the session where it // lives, so compacting a project whose session was recorded elsewhere // failed every time while an ordinary turn on the same project worked. - if agent == Agent::Claude + if matches!(agent, Agent::Claude | Agent::Grok) && let Some(session) = session.as_deref() - && let Some(home) = crate::chat_import::claude_session_cwd(session) + && let Some(home) = match agent { + Agent::Claude => crate::chat_import::claude_session_cwd(session), + Agent::Grok => crate::chat_import::grok_session_cwd(session), + _ => None, + } && home != cwd { if !dirs.contains(&cwd) { @@ -8242,6 +8931,7 @@ pub async fn compact_project( */ let mut outcome_note = None; let mut spoken = String::new(); + let mut compact_live = agent_abstraction::Usage::default(); let mut compact_model = state .tables .message @@ -8256,7 +8946,7 @@ pub async fn compact_project( let mut cancelled = false; loop { let event = tokio::select! { - _ = cancel.changed() => { + () = cancel.cancelled() => { crate::log!(crate::log::Level::Info, "run", "{project_id}: stop observed while streaming a one-shot run"); cancelled = true; break; @@ -8271,6 +8961,7 @@ pub async fn compact_project( ok, error, }) => outcome_note = Some((ok, error)), + agent_abstraction::Event::Usage(usage) => compact_live.accumulate(&usage), // Kept only as a fallback reason. A compaction that works says // nothing here, so text almost always means it did not. agent_abstraction::Event::Text(text) => spoken.push_str(&text), @@ -8349,24 +9040,31 @@ pub async fn compact_project( body.clone(), ); - let mut compact_usage = agent_abstraction::Usage::default(); - if let Some((_, usage)) = &learned { - compact_usage.accumulate(usage); - } + let mut compact_usage = compact_live; if let Ok(outcome) = &finished { compact_usage.accumulate(&outcome.usage); } - let has_usage = compact_usage.cost_usd.is_some() - || compact_usage.input_tokens.is_some() - || compact_usage.output_tokens.is_some() - || compact_usage.cache_read_tokens.is_some() - || compact_usage.cache_write_tokens.is_some(); - let usage_json = if has_usage { - let mut standing_usage = compact_usage; - if ok { - standing_usage.context_tokens = - standing_usage.context_tokens.map(compacted_context_tokens); + let standing_usage = if ok { + post_compact_standing( + agent, + learned.as_ref().map(|(_, usage)| usage), + Some(&compact_usage), + ) + } else { + let mut failed = agent_abstraction::Usage::default(); + if let Some((_, usage)) = &learned { + failed.accumulate(usage); } + failed.accumulate(&compact_usage); + failed + }; + let has_usage = standing_usage.cost_usd.is_some() + || standing_usage.input_tokens.is_some() + || standing_usage.output_tokens.is_some() + || standing_usage.cache_read_tokens.is_some() + || standing_usage.cache_write_tokens.is_some() + || standing_usage.context_tokens.is_some(); + let usage_json = if has_usage { serde_json::to_string(&UsageDto::from(&standing_usage)).unwrap_or_default() } else { String::new() @@ -8403,7 +9101,7 @@ pub async fn compact_project( } else { compact_model.clone() }; - record_turn_usage(&state.tables, &project_id, agent, &model, &compact_usage).await; + record_turn_usage(&state.tables, &project_id, agent, &model, &standing_usage).await; } let _ = app.emit( "run:compaction", @@ -8418,6 +9116,9 @@ pub async fn compact_project( ); if ok { + if trigger.resumes() { + spawn_resume_after_compact(app.clone(), project_id.clone(), agent, compact_model); + } Ok(()) } else { Err(why.unwrap_or_else(|| "the compaction did not complete".into())) @@ -8510,7 +9211,7 @@ pub async fn reset_project_session( ); } active.get(&project_id).map(|run| { - let _ = run.cancel.send(true); + run.cancel.cancel(); (run.reservation_id.clone(), run.agent) }) } else { @@ -8523,7 +9224,7 @@ pub async fn reset_project_session( } else { 0 }; - let interrupted_provider_turn = if force && agent == Agent::Codex { + let interrupted_provider_turn = if force && matches!(agent, Agent::Codex | Agent::Grok) { match provider_session.as_deref() { Some(session_id) => { state @@ -8771,7 +9472,7 @@ async fn write_project_dirs( let encoded = serde_json::to_string(&dirs).map_err(|error| error.to_string())?; tables .project - .update_dirs_by_id(DirsByIdQuery { dirs: encoded }, id.to_string()) + .update_by_id(id.to_string(), ProjectColumns::DIRS, encoded) .await .map_err(|error| error.to_string())?; let row = tables @@ -8877,7 +9578,7 @@ pub async fn rename_project( state .tables .project - .update_name_by_id(NameByIdQuery { name: name.clone() }, id.clone()) + .update_by_id(id.clone(), ProjectColumns::NAME, name.clone()) .await .map_err(|error| { crate::log!( @@ -8926,7 +9627,7 @@ pub async fn set_project_pinned( state .tables .project - .update_pinned_by_id(PinnedByIdQuery { pinned }, id.clone()) + .update_by_id(id.clone(), ProjectColumns::PINNED, pinned) .await .map_err(|error| { crate::log!( @@ -8971,12 +9672,7 @@ pub async fn set_project_moderator( state .tables .project - .update_moderator_by_id( - ModeratorByIdQuery { - moderator_enabled: enabled, - }, - id.clone(), - ) + .update_by_id(id.clone(), ProjectColumns::MODERATOR_ENABLED, enabled) .await .map_err(|error| { crate::log!( @@ -9027,7 +9723,13 @@ pub async fn reorder_projects( state .tables .project - .update_position_by_id(PositionByIdQuery { position }, id.clone()) + .update_in_place_by_id( + id.clone(), + ProjectColumns::POSITION, + |slot: &mut ::Archived| { + *slot = position.into(); + }, + ) .await .map_err(|error| error.to_string())?; } @@ -9093,7 +9795,7 @@ pub async fn delete_project( active .get(&id) .map(|run| { - let _ = run.cancel.send(true); + run.cancel.cancel(); }) .is_some() }) @@ -9109,7 +9811,7 @@ pub async fn delete_project( // avoidable latency. Refuse the delete if a hung provider keeps the // slot: its rows remain visible and retryable instead of being removed // while the provider can still write to them. - tokio::time::timeout( + nagoya::timeout( std::time::Duration::from_secs(10), state.active.wait_until_released(&id), ) @@ -9255,7 +9957,7 @@ pub async fn delete_project( ); } } - let mut keys = [Agent::Claude, Agent::Codex, Agent::Copilot] + let mut keys = [Agent::Claude, Agent::Codex, Agent::Copilot, Agent::Grok] .map(|agent| agent_session_key(&id, agent)) .to_vec(); keys.extend([ @@ -9264,6 +9966,7 @@ pub async fn delete_project( crate::notes::checkpoint_mark_key(&id, "claude"), crate::notes::checkpoint_mark_key(&id, "codex"), crate::notes::checkpoint_mark_key(&id, "copilot"), + crate::notes::checkpoint_mark_key(&id, "grok"), // The notes kept across compactions. Ids are not recycled, so this is // only an orphan — but it is an orphan that would be fed to an agent as // standing instructions if one ever were. @@ -9556,14 +10259,15 @@ fn exclude_owned_imports( } #[tauri::command] -pub async fn discover_chat_imports( +pub fn discover_chat_imports( state: State<'_, AppState>, ) -> Result, String> { let claude = owned_provider_sessions(&state.tables, Agent::Claude); let codex = owned_provider_sessions(&state.tables, Agent::Codex); - let mut sources = tokio::task::spawn_blocking(crate::chat_import::discover) - .await - .map_err(|error| format!("chat discovery stopped unexpectedly: {error}"))??; + // A directory walk bounded by `MAX_DISCOVERED_FILES`, on the invoke thread + // rather than an executor. See [`list_item_rows`]: this command is the one + // the cross-executor await actually broke. + let mut sources = crate::chat_import::discover()?; exclude_owned_imports(&mut sources, &claude, &codex); Ok(sources) } @@ -9654,13 +10358,11 @@ pub async fn import_chat_session( return Ok(with_session(ProjectDto::from(row), &state.tables)); } - let parse_source = source.clone(); - let parse_session = session_id.clone(); - let chat = tokio::task::spawn_blocking(move || { - crate::chat_import::load(&parse_source, &parse_session) - }) - .await - .map_err(|error| format!("chat import stopped unexpectedly: {error}"))??; + // Synchronous, like the rest of the import path. See [`list_item_rows`]: + // this command stays `async` for the writes below it, and a transcript + // parse is the one long unit here, but shipping it to an executor and + // awaiting it back is the handoff that wedged discovery. + let chat = crate::chat_import::load(&source, &session_id)?; if chat.messages.is_empty() { return Err("the selected session contains no importable user or agent messages".into()); } @@ -9971,8 +10673,12 @@ fn invocation_scope( // the honest arrangement: the session decides where it runs, the project // decides what it may touch. let (cwd, extra_dirs) = match resume.as_deref().filter(|id| !id.is_empty()) { - Some(session) if agent == Agent::Claude => { - match crate::chat_import::claude_session_cwd(session) { + Some(session) if matches!(agent, Agent::Claude | Agent::Grok) => { + match match agent { + Agent::Claude => crate::chat_import::claude_session_cwd(session), + Agent::Grok => crate::chat_import::grok_session_cwd(session), + _ => None, + } { Some(home) if home != cwd => { let mut granted = extra_dirs; for dir in std::iter::once(cwd).chain(std::mem::take(&mut granted)) { @@ -10067,7 +10773,7 @@ pub async fn send_message( Inject(tokio::sync::mpsc::UnboundedSender), Start { reservation: Box, - cancel: tokio::sync::watch::Receiver, + cancel: crate::cancel::Cancel, inject_rx: tokio::sync::mpsc::UnboundedReceiver, ready_for_followup: std::sync::Arc, }, @@ -10104,7 +10810,7 @@ pub async fn send_message( // invocation that resumes the same session with the wider sandbox // as soon as the slot clears. if agent == Agent::Codex && !same_roots(&running.workspace_roots, &workspace_roots) { - let _ = running.cancel.send(true); + running.cancel.cancel(); crate::log!( crate::log::Level::Info, "run", @@ -10127,7 +10833,8 @@ pub async fn send_message( }; SendRoute::Inject(inject) } else { - let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let cancel_tx = crate::cancel::Cancel::new(); + let cancel_rx = cancel_tx.clone(); let (inject_tx, inject_rx) = tokio::sync::mpsc::unbounded_channel(); let ready_for_followup = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let reservation_id = id("reservation"); @@ -10181,10 +10888,10 @@ pub async fn send_message( if inject .send(InjectedMessage::Owner { - body: provider_body, - original_body: input.body.clone(), - reply_question_id: user_message.reply_to_question_id.clone(), - message_id: user_message.id.clone(), + body: provider_body.into(), + original_body: input.body.as_str().into(), + reply_question_id: user_message.reply_to_question_id.as_deref().map(Arc::from), + message_id: user_message.id.as_str().into(), }) .is_err() { @@ -10264,6 +10971,7 @@ pub async fn send_message( let approvals = state.approvals.clone(); let limits = state.limits.clone(); let receipts = state.receipts.clone(); + let pool = std::sync::Arc::clone(&state.pool); let item_id = input.item_id.clone(); /* @@ -10286,11 +10994,10 @@ pub async fn send_message( match state .tables .project_item - .update_status_by_id( - ItemStatusByIdQuery { - status: "active".into(), - }, + .update_by_id( item.to_string(), + ProjectItemColumns::STATUS, + "active".to_string(), ) .await { @@ -10336,6 +11043,7 @@ pub async fn send_message( item_id, reservation, cancel, + pool, inject_rx, ready_for_followup, project_id, @@ -10411,7 +11119,7 @@ pub async fn sync_project( "run", "{project_id}: releasing a run the proxy no longer has; the project was showing a run nothing was executing" ); - let _ = run.cancel.send(true); + run.cancel.cancel(); emit_run_stopped(&app, &project_id, run.agent, "", "", "orphaned", None); } } @@ -10533,7 +11241,8 @@ pub async fn sync_project( if active.contains_key(&project_id) { continue; } - let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let cancel_tx = crate::cancel::Cancel::new(); + let cancel_rx = cancel_tx.clone(); let (inject_tx, inject_rx) = tokio::sync::mpsc::unbounded_channel(); let ready = std::sync::Arc::new(std::sync::atomic::AtomicBool::new( recovered_run_ready_for_followup(&snapshot.state), @@ -10603,6 +11312,7 @@ pub async fn sync_project( let approvals = state.approvals.clone(); let limits = state.limits.clone(); let receipts = state.receipts.clone(); + let pool = std::sync::Arc::clone(&state.pool); let model = snapshot.model.clone(); let recovered_app = app.clone(); tauri::async_runtime::spawn(async move { @@ -10617,6 +11327,7 @@ pub async fn sync_project( item_id, reservation, cancel, + pool, inject_rx, ready_for_followup, project_id, @@ -10736,13 +11447,13 @@ async fn fetch_pull_request_diff(url: &str) -> Result { }) }; - tokio::time::timeout(REVIEW_DIFF_TIMEOUT, collect) + nagoya::timeout(REVIEW_DIFF_TIMEOUT, collect) .await .map_err(|_| "GitHub CLI timed out after 60 seconds while fetching the diff".to_string())? } async fn fetch_pull_request_head(url: &str) -> Result { - let output = tokio::time::timeout( + let output = nagoya::timeout( REVIEW_HEAD_TIMEOUT, tokio::process::Command::new("gh") .args([ @@ -10954,7 +11665,14 @@ async fn append_review_message( .insert(row.clone()) .await .map_err(|error| error.to_string())?; - store_body(&state.tables, &message_id, review.project_id, &body).await; + store_body( + &state.tables, + &message_id, + review.project_id, + &body, + MAX_MESSAGE_BODY, + ) + .await; let mut appended = MessageDto::from(row); appended.body = body; let _ = app.emit("message:appended", &appended); @@ -11072,7 +11790,10 @@ async fn drive_run( // Held for the whole run and dropped on any exit path, so the project's // run slot frees exactly when no agent can still be alive. _reservation: RunReservation, - mut cancel: tokio::sync::watch::Receiver, + cancel: crate::cancel::Cancel, + // Az's own threads, for the sends this loop must not await. See + // `crate::runtime::Pool`. + pool: std::sync::Arc, // Messages typed while this run is live, to deliver into the open turn. mut inject_rx: tokio::sync::mpsc::UnboundedReceiver, ready_for_followup: std::sync::Arc, @@ -11109,7 +11830,11 @@ async fn drive_run( * theirs, the format is ours. */ let is_task_manager = project_id == crate::tasks::TASK_MANAGER_ID; - let provider_handoff = if stateless { + let provider_handoff = if stateless + || (agent == Agent::Grok && resume.as_deref().is_some_and(|id| !id.is_empty())) + { + // Grok ACP session/load already has the native transcript. Re-attaching + // the AZ thread as a "handoff" made it recap the whole session. String::new() } else { provider_handoff(&tables, &project_id, &turn_id, agent) @@ -11240,6 +11965,8 @@ async fn drive_run( extra_thinking, &scope, ); + // Grok operating rules ride the same system string as notes/AgencyZero.md + // so a later `request.system = Some(system)` cannot drop them. request .metadata .insert("projectId".into(), project_id.clone().into()); @@ -11285,6 +12012,9 @@ async fn drive_run( .unwrap_or_default() }; let mut system = String::new(); + if agent == Agent::Grok { + system.push_str(grok_system_rules()); + } /* * The repository's own rules file, first and whole. @@ -11566,8 +12296,20 @@ async fn drive_run( */ let (injection_delivery_tx, mut injection_delivery_rx) = tokio::sync::mpsc::unbounded_channel::(); - let (injection_failure_tx, mut injection_failure_rx) = - tokio::sync::mpsc::unbounded_channel::<()>(); + // One wake for the three facts that stop or retry this loop. + // + // They were three `select!` arms, which asked the loop to re-poll all + // three on every provider event to be told, almost always, that nothing + // had changed. Each is an `AtomicBool` that knows exactly when it moved, + // so they share a queue instead: one registration, and the loop reads the + // flags to learn which fired. Level triggered, so two arriving together + // are both seen rather than one being consumed. + let signals = crate::cancel::Signals::around(cancel); + // A latch, not a queue. This is read from two different loops below, and + // `recv()` consumes: whichever polled first took the one `()` and the + // other waited forever for a failure that had already happened. + let injection_failure = signals.injection_failure.clone(); + let injection_failure_delivery = injection_failure.clone(); let injection_app = app.clone(); let injection_tables = tables.clone(); let injection_io = io.clone(); @@ -11585,23 +12327,38 @@ async fn drive_run( * manufacture the deadlock instead. The loop asks for a ping and carries * on draining; this worker waits. */ - let (ping_request_tx, mut ping_request_rx) = tokio::sync::mpsc::unbounded_channel::<()>(); - let (ping_failed_tx, mut ping_failed_rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + /* + * No queue and no worker for the ping. + * + * There was one of each: a bounded channel of `()` and a task that read + * from it and called `control.send`. The channel carried no data, so all + * it did was move the await off this 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 a ping straight to the pool: the send + * happens somewhere else, which is the only thing the worker achieved, + * and the loop returns to `run.recv` without awaiting it. + * + * The attempt counter lives in the loop now rather than in the worker, + * which is where the decision to ping is made anyway. + */ + let ping_failed = signals.ping_failed.clone(); let ping_control = run.control(); let ping_turn_id = turn_id.clone(); - let ping_delivery = tokio::spawn(async move { - let mut attempt = 0u32; - while ping_request_rx.recv().await.is_some() { - attempt = attempt.saturating_add(1); - if ping_control - .send(LIVENESS_PING, &format!("{ping_turn_id}:ping:{attempt}")) - .await - .is_err() - { - let _ = ping_failed_tx.send(()); - } - } - }); + // The cliff steers go to the pool for the same reason the ping does, and + // need even less around them: there are three for the life of a turn, the + // 180k checkpoint, the 190k stop-now and the post-compact resume, each + // behind its own one-shot flag. The queue's capacity of three restated + // those flags, and the worker only moved the await off this loop. + // + // Nothing reads the result. A steer that does not land is not a reason to + // stop a run the owner is still watching, which is why the old worker + // discarded the error too. + let cliff_control = run.control(); + let cliff_turn_id = turn_id.clone(); + let cliff_attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); let injection_delivery = tokio::spawn(async move { while let Some(injected) = injection_delivery_rx.recv().await { let delivered = deliver_injection( @@ -11615,7 +12372,7 @@ async fn drive_run( ) .await; if !delivered { - let _ = injection_failure_tx.send(()); + injection_failure_delivery.set(); } } }); @@ -11702,6 +12459,9 @@ async fn drive_run( * eye only — `Outcome::usage` remains the record. */ let mut turn_usage = agent_abstraction::Usage::default(); + let mut grok_steered_180 = false; + let mut grok_steered_190 = false; + let mut compact_resume_steered = false; // Set by the cancel signal, wherever the loop happens to be waiting when // it lands. The loop exits, and the tail below tears the agent down. @@ -11719,8 +12479,15 @@ async fn drive_run( // Set when the idle deadline trips: a run that went silent long enough to be // treated as wedged. Recovered like a stall rather than reported as a crash. let mut idle_stalled = false; - let cleanup_deadline = - (stateless && is_task_manager).then(|| tokio::time::Instant::now() + TASK_CLEANUP_TIMEOUT); + // A bound on the whole run rather than a thing to wait for. + // + // Only the stateless task manager has one, so this was a `select!` arm + // wrapping `pending()` to be never-ready for every other run - a branch + // whose job was to never fire, re-polled on every provider event. The + // bound is the same fact stated as what it is: when this run started, and + // how long it is allowed. + let cleanup_started = std::time::Instant::now(); + let cleanup_limit = (stateless && is_task_manager).then_some(TASK_CLEANUP_TIMEOUT); let mut cleanup_timed_out = false; /// What woke the loop: an agent event, or a message to queue for the @@ -11729,49 +12496,111 @@ async fn drive_run( enum Wake { Event(Event), Inject(InjectedMessage), + /// The idle window closed with no provider event. A run that has gone + /// quiet long enough to be worth asking about, not necessarily a + /// wedged one: the handler decides. + Idle, } // Before the turn begins this is an absolute startup bound: thread setup // events do not extend it. After the first real turn event it becomes the // ordinary sliding idle deadline. let start_timeout = run_start_timeout(resume.as_deref()); - let mut idle_deadline = tokio::time::Instant::now() + start_timeout; + // How long this loop will wait for the next provider event, not a point in + // time. It was `Instant::now() + window`, which is the same thing said in + // a way a `select!` arm could race; the window is what every assignment + // below actually means. + let mut idle_window = start_timeout; // Set when a liveness ping has been injected and not yet answered. One ping // per silence: a second expiry with this still set is the wedged case. + // Facts this loop has acted on and does not want woken for again. A + // `Latch` never clears, so without this a handled non-terminal fact would + // make its arm ready on every iteration and spin the loop. + let mut handled = crate::cancel::Handled::default(); let mut ping_outstanding = false; // Bounded retries, so a tool that legitimately outlives one ping window is // not mistaken for a wedged run. Reset wherever the ping itself is. let mut unanswered_pings: u32 = 0; loop { + // The run-level bound, checked rather than awaited. Only the stateless + // task manager has one; every other run has nothing to check. + if let Some(limit) = cleanup_limit + && cleanup_started.elapsed() >= limit + { + crate::log!( + crate::log::Level::Warn, + "tasks", + "Home cleanup exceeded {}s; stopping its one-shot run", + TASK_CLEANUP_TIMEOUT.as_secs() + ); + cancelled = true; + cleanup_timed_out = true; + break; + } + // Wait no longer than whichever bound comes first. Without this a run + // that goes silent would sit in the idle window past its cleanup + // limit, and the check above would only notice once an event happened + // to arrive. + let wait_for = match cleanup_limit { + Some(limit) => idle_window.min(limit.saturating_sub(cleanup_started.elapsed())), + None => idle_window, + }; let wake = tokio::select! { - event = run.recv() => match event { - Some(event) => Wake::Event(event), - None => break, - }, /* - * `Ok` is the signal; `Err` means the sender vanished from the - * registry, which only teardown paths do — both read as "stop". + * The idle window belongs to the receive, not beside it. + * + * It was a seventh arm, `sleep_until(idle_deadline)`, racing this + * one, with the deadline recomputed as `Instant::now() + window` + * at four places - every one of them on a provider event. That is + * not a clock. It is "how long since the last event", which is a + * property of this receive, and saying it as a peer was what + * forced the `Instant` in the first place: a duration cannot be + * raced, only a point in time can. + * + * `timeout` polls the inner future first on every wake, so an + * event arriving as the window closes is delivered rather than + * discarded. */ - _ = cancel.changed() => { - crate::log!(crate::log::Level::Info, "run", "{project_id}: stop observed in the main event loop"); - cancelled = true; - break; - } - Some(()) = injection_failure_rx.recv() => { - // The visible message is already queued for retry. Free the - // one-run-per-project slot so that retry can resume the same - // session instead of waiting behind a dead app-server forever. - cancelled = true; - stalled_injection = true; - break; - } + received = nagoya::timeout(wait_for, run.recv()) => match received { + Ok(Some(event)) => Wake::Event(event), + Ok(None) => break, + Err(_) => Wake::Idle, + }, /* - * The ping could not be delivered, so nothing is going to answer - * it. Clearing the flag rather than stopping here keeps the - * decision in one place: the next expiry finds no outstanding ping - * and takes the ordinary wedged path. + * One arm for the three facts that stop or redirect this loop. + * + * They were three arms, so every provider event re-polled all + * three to be told nothing had changed. They share a wake now: + * this parks once and reads the flags, which is three `Acquire` + * loads. Level triggered, so two arriving together are both acted + * on rather than one being consumed. */ - Some(()) = ping_failed_rx.recv() => { + () = signals.changed(handled) => { + if signals.cancel.is_cancelled() { + crate::log!(crate::log::Level::Info, "run", "{project_id}: stop observed in the main event loop"); + cancelled = true; + break; + } + if signals.injection_failure.is_set() { + // The visible message is already queued for retry. Free the + // one-run-per-project slot so that retry can resume the same + // session instead of waiting behind a dead app-server forever. + cancelled = true; + stalled_injection = true; + break; + } + /* + * The ping could not be delivered, so nothing is going to + * answer it. Clearing the outstanding flag rather than stopping + * keeps the decision in one place: the next expiry finds no + * outstanding ping and takes the ordinary wedged path. + * + * `ping_failed` stays set, so this arm would be ready forever + * and spin the loop. Taking the ping worker's queue down is + * what makes it quiet: the fact has been acted on, and the + * only thing that could set it again is a worker that no + * longer exists. + */ crate::log!( crate::log::Level::Warn, "run", @@ -11779,25 +12608,27 @@ async fn drive_run( ); ping_outstanding = false; unanswered_pings = 0; + handled.ping_failed = true; continue; } - () = async { - match cleanup_deadline.as_ref() { - Some(deadline) => tokio::time::sleep_until(*deadline).await, - None => std::future::pending::<()>().await, + injected = inject_rx.recv() => match injected { + Some(body) => Wake::Inject(body), + // The sender lives in the registry this run owns a slot in; + // it closing early is a teardown already in progress. + None => continue, + }, + }; + let event = match wake { + Wake::Event(event) => event, + Wake::Idle => { + // The cleanup bound and the idle window share one wait, so a + // wait that ended because the bound arrived must not be read + // as silence. The top of the loop owns that decision. + if let Some(limit) = cleanup_limit + && cleanup_started.elapsed() >= limit + { + continue; } - } => { - crate::log!( - crate::log::Level::Warn, - "tasks", - "Home cleanup exceeded {}s; stopping its one-shot run", - TASK_CLEANUP_TIMEOUT.as_secs() - ); - cancelled = true; - cleanup_timed_out = true; - break; - } - () = tokio::time::sleep_until(idle_deadline) => { /* * A tool still in flight is the turn working, not a wedged run. * @@ -11847,10 +12678,30 @@ async fn drive_run( "{project_id}: no output for {}s with a tool still running — pinging before deciding it is wedged", RUN_IDLE_TIMEOUT.as_secs() ); - // Handed to the ping worker rather than awaited here: this - // task must keep draining `run.recv`. A send that cannot be - // queued means the worker is gone, which is teardown. - if ping_request_tx.send(()).is_ok() { + // Sent from the pool rather than awaited here: this task + // must keep draining `run.recv`, because `control.send` + // waits for the provider and the provider acknowledges + // only after emitting a burst of events. Awaiting it here + // fills the bounded event channel and manufactures the + // deadlock the ping exists to detect. + // + // `should_ping_again` above already refused this if four + // pings are outstanding, so there is no second bound to + // enforce here. + { + let control = ping_control.clone(); + let failed = ping_failed.clone(); + let turn = ping_turn_id.clone(); + let attempt = unanswered_pings.saturating_add(1); + pool.spawn(async move { + if control + .send(LIVENESS_PING, &format!("{turn}:ping:{attempt}")) + .await + .is_err() + { + failed.set(); + } + }); ping_outstanding = true; unanswered_pings += 1; // Visible in the run's I/O trail, so a stop that follows @@ -11875,16 +12726,9 @@ async fn drive_run( "waitSeconds": LIVENESS_PING_TIMEOUT.as_secs(), }), ); - idle_deadline = tokio::time::Instant::now() + LIVENESS_PING_TIMEOUT; + idle_window = LIVENESS_PING_TIMEOUT; continue; } - // The ping worker is gone, so there is nothing alive to - // answer. Fall through and stop the run. - crate::log!( - crate::log::Level::Warn, - "run", - "{project_id}: the liveness ping could not be delivered; treating the run as wedged" - ); } if opening_message_read && ping_outstanding { crate::log!( @@ -11923,46 +12767,28 @@ async fn drive_run( idle_stalled = true; break; } - injected = inject_rx.recv() => match injected { - Some(body) => Wake::Inject(body), - // The sender lives in the registry this run owns a slot in; - // it closing early is a teardown already in progress. - None => continue, - }, - }; - let event = match wake { - Wake::Event(event) => event, Wake::Inject(injected) => { // Injection is exposed only after a real turn event, so this is // activity on an already-started turn. - idle_deadline = tokio::time::Instant::now() + RUN_IDLE_TIMEOUT; - // A correction typed mid-turn. The user row was persisted and - // broadcast by `send_message`. Close the agent text the owner - // was replying to before delivering the new words. - let partial_directive = take_incomplete_prompt_syntax_tail(&mut streamed_chunk); - if let Some(id) = flush_continued_agent_chunk( + idle_window = RUN_IDLE_TIMEOUT; + // A correction typed mid-turn. Shared with the approval wait + // below, which must accept one on exactly the same terms; see + // [`accept_injection`] for why the returned flag is read as + // `last_was_text` here and folded into text adjacency there. + last_was_text = accept_injection( &app, &tables, message_context, - &mut streamed_chunk, - &mut chunk_started_at, + injected, + StreamedChunk { + body: &mut streamed_chunk, + started_at: &mut chunk_started_at, + last_id: &mut last_chunk_id, + directive_turn_id: &mut directive_turn_id, + }, + &injection_delivery_tx, ) - .await - { - last_chunk_id = Some(id); - } - if let Some(partial) = partial_directive.as_deref() { - chunk_started_at = Some(now()); - streamed_chunk.push_str(partial); - } - if let InjectedMessage::Owner { message_id, .. } = &injected { - directive_turn_id.clone_from(message_id); - } - let _ = injection_delivery_tx.send(injected); - // A user message is normally a block boundary. An unfinished - // directive is the exception: its next delta must complete the - // same line, not gain the paragraph break that broke the span. - last_was_text = partial_directive.is_some(); + .await; continue; } }; @@ -11979,7 +12805,7 @@ async fn drive_run( // Once the turn is real, every provider event extends the ordinary idle // window. Setup events before that point never extend startup. if opening_message_read || turn_started { - idle_deadline = tokio::time::Instant::now() + RUN_IDLE_TIMEOUT; + idle_window = RUN_IDLE_TIMEOUT; // Any provider event answers the question a ping asks, so a run // that simply resumed streaming is not held to replying in words. ping_outstanding = false; @@ -12116,46 +12942,64 @@ async fn drive_run( * worker; the deadline is absolute so servicing a message * cannot extend the timeout. */ - let deadline = tokio::time::Instant::now() + APPROVAL_TIMEOUT; + // Nagoya's clock, read once: `sleep_until` takes the instant + // as nanoseconds on it, so each pass of the loop arms a fresh + // timer on the same point rather than restarting a window. + let deadline = nagoya::now_ns() + .saturating_add(u64::try_from(APPROVAL_TIMEOUT.as_nanos()).unwrap_or(u64::MAX)); let mut answer_rx = answer_rx; let answer = loop { tokio::select! { answer = &mut answer_rx => break answer.ok(), - () = tokio::time::sleep_until(deadline) => break None, + () = nagoya::sleep_until(deadline) => break None, // Stop can arrive while the question stands; the pending // tool call is denied and the loop tail tears down. - _ = cancel.changed() => { - crate::log!(crate::log::Level::Info, "run", "{project_id}: stop observed while waiting on an approval"); - cancelled = true; - break None; - } - Some(()) = injection_failure_rx.recv() => { + /* + * One arm for every reason this wait ends early. + * + * This was two arms, `cancel` and `injection_failure`, + * and it silently omitted `ping_failed`: a liveness + * ping that could not be delivered went unobserved + * until the approval resolved, because the outer loop + * that watches for it is not running while this one + * is. Duplicating a poll set is how that happens. + * + * `stopped` is the right set rather than all three: a + * failed ping means this run is unmonitored, not that + * it is over, and the owner is still being asked a + * question. It shares the wake, so a ping failure + * re-checks and parks again instead of waking this. + */ + () = signals.stopped() => { + if signals.cancel.is_cancelled() { + crate::log!(crate::log::Level::Info, "run", "{project_id}: stop observed while waiting on an approval"); + } else { + stalled_injection = true; + } cancelled = true; - stalled_injection = true; break None; } injected = inject_rx.recv() => { if let Some(injected) = injected { - let partial_directive = - take_incomplete_prompt_syntax_tail(&mut streamed_chunk); - if let Some(id) = flush_continued_agent_chunk( + // The same acceptance as the main loop's, and + // the same function, so the two cannot drift + // apart again. This wait has no `last_was_text` + // to set, so the unfinished-directive flag is + // folded into the turn's text adjacency. + preserve_text_adjacency |= accept_injection( &app, &tables, message_context, - &mut streamed_chunk, - &mut chunk_started_at, - ).await { - last_chunk_id = Some(id); - } - if let Some(partial) = partial_directive.as_deref() { - chunk_started_at = Some(now()); - streamed_chunk.push_str(partial); - } - preserve_text_adjacency |= partial_directive.is_some(); - if let InjectedMessage::Owner { message_id, .. } = &injected { - directive_turn_id.clone_from(message_id); - } - let _ = injection_delivery_tx.send(injected); + injected, + StreamedChunk { + body: &mut streamed_chunk, + started_at: &mut chunk_started_at, + last_id: &mut last_chunk_id, + directive_turn_id: &mut directive_turn_id, + }, + &injection_delivery_tx, + ) + .await; } } } @@ -12326,8 +13170,7 @@ async fn drive_run( "seconds": granted, }), ); - idle_deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(granted); + idle_window = std::time::Duration::from_secs(granted); ping_outstanding = false; unanswered_pings = 0; continue; @@ -12577,12 +13420,14 @@ async fn drive_run( is_blocking: limit.is_blocking(), is_warning, at: now(), + used_percent: limit.used_percent, }; if let Ok(mut kept) = limits.lock() { // Plain `allowed` is a heartbeat: it replaces nothing and // is not worth keeping, so a warning stays visible until // the provider says something else that matters. - if report.is_blocking || report.is_warning { + // Grok's weekly % rides an `allowed` record; keep that. + if report.is_blocking || report.is_warning || report.used_percent.is_some() { kept.insert((project_id.clone(), agent), report.clone()); } else { kept.remove(&(project_id.clone(), agent)); @@ -12639,6 +13484,37 @@ async fn drive_run( "error": why, }), ); + if done && ok && !compact_resume_steered { + compact_resume_steered = true; + let body = compact_resume_prompt().to_string(); + crate::log!( + crate::log::Level::Info, + "run", + "{project_id}: compact finished mid-turn; resuming in-channel" + ); + if let Some(message_id) = + persist_grok_cliff_steer(&app, &tables, &project_id, agent, &model, &body) + .await + { + emit_message_receipt(&app, &project_id, &message_id, "sent"); + } + // Sent from the pool for the same reason the ping is: this + // loop must keep draining `run.recv`. Each steer is behind + // its own one-shot flag, so this runs at most once. + let control = cliff_control.clone(); + let turn = cliff_turn_id.clone(); + let attempt = cliff_attempt + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + .saturating_add(1); + pool.spawn(async move { + let _ = control + .send( + &mid_turn_owner_context(&body), + &format!("{turn}:cliff:{attempt}"), + ) + .await; + }); + } } Event::Usage(usage) => { /* @@ -12691,6 +13567,66 @@ async fn drive_run( "estimatedCostUsd": estimated_cost_usd, }), ); + if agent == Agent::Grok + && let Some(used) = turn_usage.context_tokens + { + let urgent = used >= GROK_CLIFF_URGENT; + let wrap = used >= GROK_COMPACT_BEFORE_CLIFF; + if (urgent && !grok_steered_190) || (wrap && !grok_steered_180 && !urgent) { + if urgent { + grok_steered_190 = true; + grok_steered_180 = true; + } else { + grok_steered_180 = true; + } + let body = grok_cliff_steer(used, urgent); + crate::log!( + crate::log::Level::Info, + "run", + "{project_id}: Grok context {used}; steering to checkpoint before compact" + ); + note_io( + &app, + &io, + &project_id, + "sent", + "steer", + format!( + "auto cliff {}k: persist work, then compact after this turn", + used / 1_000 + ), + ); + if let Some(message_id) = persist_grok_cliff_steer( + &app, + &tables, + &project_id, + agent, + &model, + &body, + ) + .await + { + emit_message_receipt(&app, &project_id, &message_id, "sent"); + } + // Sent from the pool for the same reason the ping is: + // this loop must keep draining `run.recv`. Each steer + // is behind its own one-shot flag, so this runs at + // most once. + let control = cliff_control.clone(); + let turn = cliff_turn_id.clone(); + let attempt = cliff_attempt + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + .saturating_add(1); + pool.spawn(async move { + let _ = control + .send( + &mid_turn_owner_context(&body), + &format!("{turn}:cliff:{attempt}"), + ) + .await; + }); + } + } } Event::Started { session, model } => { observed_session.clone_from(&session); @@ -12776,9 +13712,6 @@ async fn drive_run( */ drop(inject_rx); drop(injection_delivery_tx); - // Closes the ping worker's queue so it can finish; a ping still in flight - // is answered into a run that is already ending, which is harmless. - drop(ping_request_tx); // A final ordinary line has no newline to make it classifiable during the // stream. Release it now; an authored PS tail stays private and is applied @@ -12851,13 +13784,6 @@ async fn drive_run( "{project_id}: injection delivery worker failed: {error}" ); } - if let Err(error) = ping_delivery.await { - crate::log!( - crate::log::Level::Error, - "run", - "{project_id}: liveness ping worker failed: {error}" - ); - } /* * The tombstone check. `delete_project` cancels the run and waits for @@ -12899,6 +13825,8 @@ async fn drive_run( return; } + let mut grok_xml_leak = false; + let mut foreign_namespace_leaks: Vec = Vec::new(); match result { Ok(outcome) => { // A successful reopen ends any bounded transient-resume recovery @@ -12912,6 +13840,16 @@ async fn drive_run( * text remains the fallback for a run that never streamed. */ let used_streamed_body = !streamed_text.trim().is_empty(); + grok_xml_leak = grok_leaked_xml_tools(if used_streamed_body { + &streamed_text + } else { + &outcome.text + }); + foreign_namespace_leaks = leaked_foreign_namespace_spans(if used_streamed_body { + &streamed_text + } else { + &outcome.text + }); let mut body = if used_streamed_body { streamed_text } else { @@ -13272,8 +14210,29 @@ async fn drive_run( emit_run_stopped(&app, &project_id, agent, &model, &permission, stop, None); } Err(error) => { - let error_text = error.to_string(); - let rejected_resume = claude_rejected_resume(agent, resume.as_deref(), &error_text); + /* + * Capped before it is ever persisted, and only after the + * classifiers have read the whole thing. + * + * A provider error is a message on a good day and an unbounded + * dump on a bad one: `claude` answers a rejected argument with its + * entire `control_response` - every skill description, every model + * entry - which arrived here as 17776 bytes. Both persistence + * paths below put this string in a row (the measurement's + * `status`, the failed turn's `stop`), a WorkTable row must fit + * one 16356-byte page, and an oversized insert has twice corrupted + * this store on this machine rather than merely failing. See + * [`MAX_PERSISTED_BLOB`]. + * + * The order matters: `claude_rejected_resume` and + * `is_cybersecurity_refusal` below both match with `contains`, so + * they run against the full text. What is persisted and shown is + * the head, which is where a provider puts the sentence and not + * the dump. + */ + let raw_error_text = error.to_string(); + let rejected_resume = claude_rejected_resume(agent, resume.as_deref(), &raw_error_text); + let error_text = truncate_to_bytes(&raw_error_text, MAX_PERSISTED_BLOB); let mut visible_error = error_text.clone(); if rejected_resume { let retry_key = missing_resume_retry_key(&turn_id); @@ -13290,7 +14249,7 @@ async fn drive_run( (crate::retry::interactive_delay(attempt), opening) { let _ = tables.kv_put(&retry_key, attempt.to_string()).await; - tokio::time::sleep(delay).await; + nagoya::sleep(delay).await; let body = full_body(&tables, &row.id, &row.body); if app .emit( @@ -13363,7 +14322,11 @@ async fn drive_run( * the partial message and in the durable ledger. */ let mut visible_chunk = without_incomplete_prompt_syntax_tail(&streamed_chunk); - if visible_chunk.trim().is_empty() && is_cybersecurity_refusal(&visible_error) { + // Matched against the uncapped text for the same reason the other + // classifier above is: a refusal marker past the cap would + // otherwise read as an ordinary failure. What gets stored is + // still `visible_error`, which is the capped copy. + if visible_chunk.trim().is_empty() && is_cybersecurity_refusal(&raw_error_text) { // Keep the exact refusal durably in the transcript. The // session-local stopped event disappears on restart, which // makes a safety decision too easy to miss. @@ -13423,6 +14386,12 @@ async fn drive_run( checkpoint_dir.as_deref(), ) .await; + let grok_auto_compact = agent == Agent::Grok + && !stateless + && !cancelled + && turn_usage + .context_tokens + .is_some_and(|used| used >= GROK_COMPACT_BEFORE_CLIFF); match tables .kv_put(&format!("agency-proxy-complete:{turn_id}"), now()) .await @@ -13444,6 +14413,84 @@ async fn drive_run( ); } } + // Host-owned Grok compact has to wait until this run's slot is free. + drop(_reservation); + if grok_auto_compact { + crate::log!( + crate::log::Level::Info, + "run", + "{project_id}: Grok context {} ≥ {GROK_COMPACT_BEFORE_CLIFF}; compacting before the 200k price cliff", + turn_usage.context_tokens.unwrap_or(0) + ); + let app = app.clone(); + let project_id = project_id.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Err(error) = compact_project_with( + app.clone(), + project_id.clone(), + Some("grok".into()), + CompactTrigger::Automatic, + state, + ) + .await + { + crate::log!( + crate::log::Level::Warn, + "run", + "{project_id}: could not auto-compact before the 200k cliff: {error}" + ); + } + }); + } else if grok_xml_leak && should_resume_after_xml_leak(agent, &prompt_echo, cancelled) { + crate::log!( + crate::log::Level::Info, + "run", + "{project_id}: Grok wrote XML tool markup; ACP did not execute it — resuming" + ); + spawn_host_resume( + app, + project_id, + agent, + model, + grok_xml_resume_prompt().into(), + "xml-tool-leak", + ); + } else if !foreign_namespace_leaks.is_empty() { + // Every span, individually, so the log never implies fewer calls were + // attempted than actually were. + for span in &foreign_namespace_leaks { + crate::log!( + crate::log::Level::Info, + "run", + "{project_id}: is not a live namespace here; it was not executed", + span.namespace, + span.verb + ); + } + // Record whatever happens next: the spans were sent for a purpose, and + // a cancelled or looping run must not be the reason they vanish. + persist_foreign_namespace_leak( + &app, + &tables, + &project_id, + agent, + &model, + &foreign_namespace_leaks, + ) + .await; + if should_resume_after_foreign_namespace(&prompt_echo, cancelled) { + let body = foreign_namespace_resume_prompt(&foreign_namespace_leaks); + spawn_host_resume( + app, + project_id, + agent, + model, + body, + "foreign-namespace-span", + ); + } + } } /// Take a knowledge sample if this turn pushed the conversation past a mark. @@ -13875,7 +14922,7 @@ mod tests { // A run loop that awaited the acknowledgement here would never reach // this line, because the provider is still blocked on `send`. - tokio::time::timeout(std::time::Duration::from_secs(5), ping_worker) + nagoya::timeout(std::time::Duration::from_secs(5), ping_worker) .await .expect("the ping worker finished rather than deadlocking") .expect("the ping worker did not panic"); @@ -14206,6 +15253,130 @@ mod tests { ); } + /// The write that actually failed, against a real store. + /// + /// The cap is asserted as a string elsewhere; this drives the row. On + /// 2026-09-18 `claude` answered a rejected argument with its whole + /// `control_response` and `drive_run` put that string in a message row's + /// `stop`. WorkTable refused it - `need 16512, but 15260 allowed` - so the + /// turn the owner had just watched was never persisted and the process + /// went down with no record of it. + /// + /// A page is 16356 bytes and the row's other columns share it, so this + /// also carries a body: an error capped to exactly the page would still + /// fail beside anything else. It asserts the insert succeeds and the row + /// reads back, because "the string got shorter" is not the property that + /// was broken. + #[tokio::test] + async fn a_turn_failing_with_an_unbounded_provider_error_still_persists() { + let dir = std::env::temp_dir().join(format!( + "az-oversized-stop-{}-{}", + std::process::id(), + uuid::Uuid::now_v7() + )); + let _ = std::fs::remove_dir_all(&dir); + let tables = Tables::open(&dir).await.expect("oversized store opens"); + tables + .project + .insert(project_row("project-a", "Project A")) + .await + .expect("project inserts"); + + // The shape that did it: a sentence, then kilobytes of JSON. + let dump = format!( + "`claude` rejected an argument, which usually means its version differs: {}", + r#"{"type":"control_response","response":{"commands":[]}}"#.repeat(400) + ); + assert!( + dump.len() > 16_356, + "the setup must exceed a page, or it proves nothing" + ); + + let context = AgentMessageContext { + project_id: "project-a", + agent: Agent::Claude, + model: "claude-opus-5", + permission: "auto", + }; + let persisted = persist_terminal_agent_chunk( + &tables, + context, + "The answer the owner watched arrive.".into(), + Some("2026-09-18T00:00:00Z".into()), + None, + AgentMessageOutcome { + usage: String::new(), + stop: dump.clone(), + exit_code: -1, + }, + ) + .await + .expect("the failed turn persists despite an oversized provider error"); + + assert_eq!(persisted.body, "The answer the owner watched arrive."); + let stored = tables + .message + .select(persisted.id.clone()) + .expect("the row reads back"); + assert!( + stored.stop.len() <= MAX_PERSISTED_BLOB, + "the stop field is capped to fit the row's page" + ); + assert!( + stored.stop.starts_with("`claude` rejected an argument"), + "the head is what is kept" + ); + + // The combination, which capping each column on its own does not + // cover: a reply past MAX_MESSAGE_BODY that then fails with the same + // oversized error. 12000 + 8000 satisfies both per-column caps and is + // still a row that will not fit a 16356-byte page. + let long_reply = format!("{}The visible tail.", "streamed prose ".repeat(1_200)); + assert!( + long_reply.len() > MAX_MESSAGE_BODY, + "the reply must overflow the body cap, or it proves nothing" + ); + let both = persist_terminal_agent_chunk( + &tables, + context, + long_reply.clone(), + Some("2026-09-18T00:00:01Z".into()), + None, + AgentMessageOutcome { + usage: String::new(), + stop: dump.clone(), + exit_code: -1, + }, + ) + .await + .expect("a long reply that then fails still persists"); + + let stored = tables + .message + .select(both.id.clone()) + .expect("the combined row reads back"); + assert!( + stored.body.len() + stored.stop.len() <= MAX_MESSAGE_ROW_BYTES, + "body {} + stop {} must fit one page", + stored.body.len(), + stored.stop.len() + ); + assert!( + !stored.stop.is_empty() && stored.stop.starts_with("`claude` rejected"), + "the failure stays legible after the body takes its share" + ); + // The head shrank to make room, so the spill must start where the head + // now ends rather than at MAX_MESSAGE_BODY, or the stitch loses bytes. + assert_eq!( + full_body(&tables, &both.id, &stored.body), + long_reply, + "the whole reply survives a shortened head" + ); + + drop(tables); + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn recovery_discards_a_legacy_checkpoint_prefix_owned_by_a_durable_chunk() { let dir = std::env::temp_dir().join(format!( @@ -14238,7 +15409,14 @@ mod tests { created_at: "2026-08-07T00:00:00Z".into(), }; tables.message.insert(row).await.expect("chunk inserts"); - store_body(&tables, "durable-chunk", "project-a", &durable_body).await; + store_body( + &tables, + "durable-chunk", + "project-a", + &durable_body, + MAX_MESSAGE_BODY, + ) + .await; let legacy = serde_json::to_string(&PartialReply { version: 1, @@ -14472,6 +15650,7 @@ mod tests { assert_eq!(parse_agent(None), Ok(Agent::Claude)); assert_eq!(parse_agent(Some("claude")), Ok(Agent::Claude)); assert_eq!(parse_agent(Some("codex")), Ok(Agent::Codex)); + assert_eq!(parse_agent(Some("grok")), Ok(Agent::Grok)); assert!(parse_agent(Some("copilot")).is_err()); assert!(parse_agent(Some("unknown")).is_err()); } @@ -14485,12 +15664,13 @@ mod tests { assert_eq!(parse_review_agent(Some("claude")), Ok(Agent::Claude)); assert_eq!(parse_review_agent(Some("codex")), Ok(Agent::Codex)); assert_eq!(parse_review_agent(Some("copilot")), Ok(Agent::Copilot)); + assert_eq!(parse_review_agent(Some("grok")), Ok(Agent::Grok)); assert!(parse_review_agent(Some("unknown")).is_err()); } #[test] fn every_review_provider_receives_the_discovery_guard() { - for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] { + for agent in [Agent::Claude, Agent::Codex, Agent::Copilot, Agent::Grok] { let request = read_only_proxy_request( agent, "review this diff".into(), @@ -14998,7 +16178,7 @@ mod tests { #[test] fn an_old_driver_cannot_release_a_newer_run_slot() { - let (cancel, _) = tokio::sync::watch::channel(false); + let cancel = crate::cancel::Cancel::new(); let active = std::sync::Arc::new(ActiveRuns::default()); active.lock().expect("registry locks").insert( "project-race".to_string(), @@ -15030,7 +16210,7 @@ mod tests { #[tokio::test] async fn run_registry_wakes_when_the_matching_reservation_releases() { - let (cancel, _) = tokio::sync::watch::channel(false); + let cancel = crate::cancel::Cancel::new(); let active = std::sync::Arc::new(ActiveRuns::default()); active.lock().expect("registry locks").insert( "project-idle".into(), @@ -15070,7 +16250,7 @@ mod tests { ("project-delete", "reservation-delete"), ("project-stays", "reservation-stays"), ] { - let (cancel, _) = tokio::sync::watch::channel(false); + let cancel = crate::cancel::Cancel::new(); active.lock().expect("registry locks").insert( project_id.into(), ActiveRun { @@ -15113,7 +16293,7 @@ mod tests { #[test] fn optional_side_channel_delivery_waits_for_the_first_turn_event() { - let (cancel, _) = tokio::sync::watch::channel(false); + let cancel = crate::cancel::Cancel::new(); let ready = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let run = ActiveRun { reservation_id: "reservation-ready".into(), @@ -15142,7 +16322,7 @@ mod tests { #[test] fn completed_review_queues_plain_markdown_for_the_active_turn() { - let (cancel, _) = tokio::sync::watch::channel(false); + let cancel = crate::cancel::Cancel::new(); let ready = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); let (inject, mut injected) = tokio::sync::mpsc::unbounded_channel(); let active = ActiveRuns::default(); @@ -15179,9 +16359,9 @@ mod tests { else { panic!("review was queued as an owner message"); }; - assert_eq!(message_id, "review-message"); - assert_eq!(reviewer, "claude"); - assert_eq!(url, "https://github.com/pathscale/WorkTable/pull/61"); + assert_eq!(&*message_id, "review-message"); + assert_eq!(&*reviewer, "claude"); + assert_eq!(&*url, "https://github.com/pathscale/WorkTable/pull/61"); assert!(body.contains("Mid-turn code review from claude")); assert!(body.contains(markdown)); assert!(!body.contains("\"body\":"), "Markdown is not JSON encoded"); @@ -15270,6 +16450,8 @@ mod tests { // this app would only ever answer yes to. assert!(!should_route_approvals("auto", Agent::Claude)); assert!(should_route_approvals("ask", Agent::Claude)); + assert!(should_route_approvals("auto", Agent::Grok)); + assert!(should_route_approvals("ask", Agent::Grok)); // Only Auto answers for itself. Ask must still reach a human. assert!(auto_allows("auto")); @@ -15682,8 +16864,10 @@ mod tests { .await .expect("provider session persists"); + let pool = crate::runtime::Pool::new(); assert_eq!(backfill_imported_usage(&tables).await, 1); assert_eq!(backfill_imported_usage(&tables).await, 0); + pool.stop(); let ledger = tables .usage_ledger @@ -15837,6 +17021,36 @@ mod tests { assert_eq!(truncate_to_bytes("short", 8_000), "short"); } + /// A provider error is not a sentence when it goes wrong. + /// + /// `claude` answered a rejected argument with its whole + /// `control_response` - every skill description and model entry - and the + /// run loop put that string in two rows: the measurement's `status` and + /// the failed turn's `stop`. A WorkTable row must fit one 16356-byte page, + /// so both writes failed with `need 17776, but 12716 allowed`, and an + /// oversized insert has twice left this store corrupt rather than merely + /// refusing. Both fields are capped now, so the row fits with the rest of + /// its columns to spare. + #[test] + fn an_unbounded_provider_error_is_capped_before_it_reaches_a_row() { + // The shape that did it: a short sentence, then kilobytes of JSON. + let dump = format!( + "`claude` rejected an argument: {}", + r#"{"type":"control_response","commands":[]}"#.repeat(500) + ); + assert!( + dump.len() > 16_356, + "the setup must exceed a page, or it proves nothing" + ); + + let capped = truncate_to_bytes(&dump, MAX_PERSISTED_BLOB); + assert!(capped.len() <= MAX_PERSISTED_BLOB); + assert!( + capped.starts_with("`claude` rejected an argument:"), + "the head is the part worth keeping" + ); + } + #[tokio::test] async fn a_rejected_live_steer_retries_the_same_transcript_row() { let store = std::env::temp_dir().join(format!( @@ -15944,7 +17158,7 @@ mod tests { created_at: now(), }; tables.message.insert(row).await.expect("head row inserts"); - store_body(&tables, "msg-big", "proj-big", &body).await; + store_body(&tables, "msg-big", "proj-big", &body, MAX_MESSAGE_BODY).await; // The inline head alone is capped; the whole body comes back only once // the chunks are stitched on. @@ -16325,6 +17539,288 @@ mod tests { assert_eq!(compacted_context_tokens(900_000), 18_000); } + #[test] + fn grok_post_compact_drops_learn_and_compact_window_as_occupancy() { + let mut learned = agent_abstraction::Usage::default(); + learned.input_tokens = Some(223_697); + learned.context_tokens = Some(223_697); + learned.context_window = Some(500_000); + let mut compact = agent_abstraction::Usage::default(); + compact.input_tokens = Some(264_675); + compact.context_tokens = Some(264_675); + compact.context_window = Some(500_000); + let standing = post_compact_standing(Agent::Grok, Some(&learned), Some(&compact)); + assert_eq!(standing.context_tokens, Some(8_000)); + assert_eq!(standing.context_window, Some(500_000)); + assert_eq!(standing.input_tokens, Some(223_697 + 264_675)); + } + + #[test] + fn grok_post_compact_keeps_session_info_fill() { + let mut learned = agent_abstraction::Usage::default(); + learned.context_tokens = Some(223_697); + let mut compact = agent_abstraction::Usage::default(); + compact.context_tokens = Some(28_978); + compact.context_window = Some(500_000); + let standing = post_compact_standing(Agent::Grok, Some(&learned), Some(&compact)); + assert_eq!(standing.context_tokens, Some(28_978)); + } + + #[test] + fn compact_resume_prompt_tells_the_agent_to_continue() { + assert!(compact_resume_prompt().contains("Compaction finished")); + assert!(compact_resume_prompt().contains("Continue")); + } + + /// An in-place position write must reach disk like any other update. + /// + /// `update_in_place` mutates the archived bytes where the row already sits + /// rather than reserializing and reinserting it. That is the whole point, + /// and it is also the risk: a fast path that never marks the page dirty + /// would look correct in memory for the rest of the session and lose the + /// tab order on the next launch. Reorder, drain, reopen, and read it back. + #[tokio::test] + async fn an_in_place_reorder_survives_a_reopen() { + let dir = std::env::temp_dir().join(format!( + "az-inplace-reorder-{}-{}", + std::process::id(), + uuid::Uuid::now_v7() + )); + let _ = std::fs::remove_dir_all(&dir); + let tables = Tables::open(&dir).await.expect("store opens"); + + let ids: Vec = (0..4).map(|n| format!("proj-{n}")).collect(); + for (position, id) in ids.iter().enumerate() { + tables + .project + .insert(ProjectRow { + id: id.clone(), + name: format!("project {position}"), + status: "active".into(), + position: u32::try_from(position).expect("small"), + dirs: String::new(), + pinned: false, + moderator_enabled: false, + forked_from: String::new(), + last_activity_at: now(), + }) + .await + .expect("seed row inserts"); + } + + // Reverse the strip, the way a drag does. + for (position, id) in ids.iter().rev().enumerate() { + let position = u32::try_from(position).expect("small"); + tables + .project + .update_in_place_by_id( + id.clone(), + ProjectColumns::POSITION, + |slot: &mut ::Archived| { + *slot = position.into(); + }, + ) + .await + .expect("in-place position update"); + } + + tables.shutdown().await.expect("store drains"); + drop(tables); + + let reopened = Tables::open(&dir).await.expect("store reopens"); + for (expected, id) in ids.iter().rev().enumerate() { + let row = reopened + .project + .select(id.clone()) + .expect("seeded row is still there"); + assert_eq!( + row.position, + u32::try_from(expected).expect("small"), + "{id} lost its in-place position across the reopen" + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// Compaction the owner asked for must not spend a turn afterwards. + /// + /// Only an app-started compaction interrupts work that was already in + /// flight. Pressing Compact is a deliberate stopping point, and resuming + /// there answers a question nobody asked. + #[test] + fn only_an_automatic_compaction_resumes_the_interrupted_work() { + assert!(CompactTrigger::Automatic.resumes()); + assert!(!CompactTrigger::Owner.resumes()); + } + + /// The reported case: another session sent `` and it + /// rendered verbatim. `antml` is the sending model's own tool-call + /// namespace, never one AgencyZero declares. + #[test] + fn a_foreign_namespace_span_is_detected_as_a_leak() { + let spans = leaked_foreign_namespace_spans(""); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].namespace, "antml"); + assert_eq!(spans[0].verb, "invoke"); + assert_eq!(spans[0].raw, ""); + } + + /// The span was sent for a purpose, so the verb and every argument survive + /// detection. Losing them would repeat the silent drop this fix exists to + /// stop: the arguments are the only record of what the turn meant to do. + #[test] + fn a_leaked_span_keeps_the_verb_and_arguments() { + let raw = r#""#; + let spans = leaked_foreign_namespace_spans(&format!("Working.\n{raw}\nMore.")); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].verb, "invoke"); + assert_eq!(spans[0].raw, raw, "arguments must survive for the task log"); + } + + /// Scope must never be hidden. A turn that blends grammars once usually + /// does it repeatedly, and each span is a separate call the model believed + /// it made — reporting one would understate what was attempted. + #[test] + fn every_leaked_span_is_captured_not_just_the_first() { + let text = "Working.\n\ + \n\ + Some prose in between.\n\ + \n\ + \n"; + let spans = leaked_foreign_namespace_spans(text); + assert_eq!(spans.len(), 3, "all three spans must be reported"); + assert!(spans[0].raw.contains("cargo test")); + assert!(spans[1].raw.contains("/tmp/a")); + assert_eq!(spans[2].namespace, "other"); + assert_eq!(spans[2].verb, "do_thing"); + + // The record and the correction both carry all of them, verbatim. + let inventory = foreign_span_inventory(&spans); + assert!(inventory.contains("cargo test")); + assert!(inventory.contains("/tmp/a")); + assert!(inventory.contains("do_thing")); + let correction = foreign_namespace_resume_prompt(&spans); + for span in &spans { + assert!( + correction.contains(&span.raw), + "every span must reach the model: {}", + span.raw + ); + } + } + + /// The correction hands the span back verbatim, so the model can reissue + /// the call instead of guessing what it had written. + #[test] + fn the_correction_quotes_the_span_and_names_both_surfaces() { + let raw = r#""#; + let spans = leaked_foreign_namespace_spans(raw); + let correction = foreign_namespace_resume_prompt(&spans); + assert!(correction.contains(raw), "the span itself must come back"); + assert!(correction.contains("cargo test"), "arguments must survive"); + assert!(correction.contains("@agency"), "the live surface is named"); + assert!(correction.contains("did not happen")); + } + + /// The app's own surface is live, not a leak, and must never trip this. + #[test] + fn the_declared_namespace_is_never_a_leak() { + assert!( + leaked_foreign_namespace_spans( + r#""# + ) + .is_empty() + ); + assert!(leaked_foreign_namespace_spans("").is_empty()); + } + + /// PS inertness decides what counts. A quoted, fenced or indented example + /// is not something the model emitted as a span, and this file and the + /// per-turn block both discuss `` constantly. + #[test] + fn quoted_and_fenced_foreign_spans_are_not_leaks() { + assert!(leaked_foreign_namespace_spans("> ").is_empty()); + assert!(leaked_foreign_namespace_spans(" ").is_empty()); + assert!(leaked_foreign_namespace_spans("```text\n\n```").is_empty()); + // Inline, not alone on its line: prose about the syntax. + assert!( + leaked_foreign_namespace_spans("The span rendered raw.").is_empty() + ); + // Prose that merely mentions a namespace. + assert!( + leaked_foreign_namespace_spans("The antml: namespace is not live here.").is_empty() + ); + } + + /// The correction must not re-trigger on its own echoed text, or the run + /// loop resumes forever. + /// + /// Both counts, because the prompt inflects: one span opens with "was not + /// executed", several with "were". A guard matching the singular wording + /// passed this test on one span while looping on two. + #[test] + fn the_foreign_namespace_correction_does_not_loop() { + for reply in [ + "", + "\ntext between\n", + ] { + let spans = leaked_foreign_namespace_spans(reply); + let correction = foreign_namespace_resume_prompt(&spans); + assert!( + !should_resume_after_foreign_namespace(&correction, false), + "the correction for {} span(s) re-triggered itself:\n{correction}", + spans.len() + ); + } + assert_eq!( + leaked_foreign_namespace_spans("\ntext between\n") + .len(), + 2, + "the plural case needs two spans to exercise the plural wording" + ); + assert!(!should_resume_after_foreign_namespace("anything", true)); + assert!(should_resume_after_foreign_namespace( + "ordinary prompt", + false + )); + } + + #[test] + fn grok_xml_tool_markup_is_a_leak() { + assert!(grok_leaked_xml_tools( + "Working.\n\nlist_dir(target_directory=/tmp)\n" + )); + assert!(!grok_leaked_xml_tools("Working. Opening the benches next.")); + } + + #[test] + fn grok_xml_resume_does_not_loop_on_its_own_prompt() { + assert!(should_resume_after_xml_leak( + Agent::Grok, + "using the primitives can you construct any benchmark", + false + )); + assert!(!should_resume_after_xml_leak( + Agent::Grok, + grok_xml_resume_prompt(), + false + )); + assert!(!should_resume_after_xml_leak( + Agent::Grok, + "using the primitives", + true + )); + assert!(!should_resume_after_xml_leak(Agent::Claude, "hi", false)); + } + + #[test] + fn grok_system_rules_forbid_xml_tool_markup() { + let rules = grok_system_rules(); + assert!(rules.contains("native tools")); + assert!(rules.contains("XML tool markup")); + assert!(rules.contains("enter_plan_mode")); + } + #[test] fn queue_markers() { assert!(BUSY_WITH_COMMAND.contains("a command is running")); diff --git a/apps/gui/src/prs.rs b/apps/gui/src/prs.rs index 2f8ac5153..dc4bd537f 100644 --- a/apps/gui/src/prs.rs +++ b/apps/gui/src/prs.rs @@ -13,7 +13,7 @@ use tauri::{Emitter, Manager, State}; use worktable::prelude::*; use crate::db::schema::pull_request::{ - PrDismissedByIdQuery, PrFactsByIdQuery, PullRequestRow, PullRequestWorkTable, + PrFactsByIdQuery, PullRequestColumns, PullRequestRow, PullRequestWorkTable, }; use crate::db::tables::Tables; use crate::{AppHandle, AppState}; @@ -582,7 +582,11 @@ pub fn refresh_project(app: AppHandle, project_id: String) { if let Err(error) = state .tables .pull_request - .update_pr_facts_by_id(update, pr.id.clone()) + .update_by_id( + pr.id.clone(), + PullRequestColumns::BRANCH_AND_STATE_AND_ADDITIONS_AND_DELETIONS_AND_CI_AND_UPDATED_AT, + update, + ) .await { crate::log!( @@ -634,7 +638,11 @@ async fn mark_unknown(app: &AppHandle, state: &State<'_, AppState>, row: &PullRe if state .tables .pull_request - .update_pr_facts_by_id(update, row.id.clone()) + .update_by_id( + row.id.clone(), + PullRequestColumns::BRANCH_AND_STATE_AND_ADDITIONS_AND_DELETIONS_AND_CI_AND_UPDATED_AT, + update, + ) .await .is_ok() && let Some(updated) = state.tables.pull_request.select(row.id.clone()) @@ -685,10 +693,7 @@ pub async fn dismiss_association( for duplicate in duplicates { tables .pull_request - .update_pr_dismissed_by_id( - PrDismissedByIdQuery { dismissed: true }, - duplicate.id.clone(), - ) + .update_by_id(duplicate.id.clone(), PullRequestColumns::DISMISSED, true) .await .map_err(|error| error.to_string())?; if let Some(row) = tables.pull_request.select(duplicate.id.clone()) { diff --git a/apps/gui/src/questions.rs b/apps/gui/src/questions.rs index f1e4a0285..1ad7c492a 100644 --- a/apps/gui/src/questions.rs +++ b/apps/gui/src/questions.rs @@ -12,7 +12,7 @@ use tauri::Emitter; use worktable::prelude::*; use crate::AppHandle; -use crate::db::schema::question::{QuestionAnsweredByIdQuery, QuestionRow}; +use crate::db::schema::question::{QuestionColumns, QuestionRow}; use crate::db::tables::Tables; #[derive(Serialize, Clone)] @@ -120,7 +120,7 @@ pub async fn answer_question( state .tables .question - .update_question_answered_by_id(QuestionAnsweredByIdQuery { answered }, id.clone()) + .update_by_id(id.clone(), QuestionColumns::ANSWERED, answered) .await .map_err(|error| format!("WRITE_FAILED: {error}"))?; if let Some(row) = state.tables.question.select(id) { @@ -183,10 +183,7 @@ async fn mark_for_reply( } if let Err(error) = tables .question - .update_question_answered_by_id( - QuestionAnsweredByIdQuery { answered: true }, - question_id.to_owned(), - ) + .update_by_id(question_id.to_owned(), QuestionColumns::ANSWERED, true) .await { crate::log!( diff --git a/apps/gui/src/quota.rs b/apps/gui/src/quota.rs index ce27f06ca..54294f868 100644 --- a/apps/gui/src/quota.rs +++ b/apps/gui/src/quota.rs @@ -9,7 +9,7 @@ //! calling and interpreting a failure — a host should be able to decide whether //! to draw the panel without discovering the answer from an error. //! -//! Today that means **Codex answers and Claude does not**. Claude reports quota +//! Today that means **Codex answers and Claude and Grok do not**. Claude reports quota //! only *during* a run, as `Event::RateLimit`, carrying the window, its reset //! time and whether the request was allowed. The percentages its `/usage` screen //! shows are not on the wire, and that screen itself says its figures are @@ -85,6 +85,7 @@ fn from_proxy(provider: ProviderAccountUsage) -> Result { "claude" => Agent::Claude, "codex" => Agent::Codex, "copilot" => Agent::Copilot, + "grok" => Agent::Grok, other => { return Err(format!( "AgencyProxy reported unknown quota provider: {other}" @@ -106,6 +107,10 @@ fn from_proxy(provider: ProviderAccountUsage) -> Result { Agent::Claude => "Claude reports quota only during a run, as a rate limit. \ Its usage percentages are not on the wire." .into(), + Agent::Grok => "Grok's weekly allowance is `_x.ai/billing` \ + creditUsagePercent, piggybacked on a send at \ + most once a minute." + .into(), _ => "This agent does not report account-wide usage.".into(), }; return Ok(quota); diff --git a/apps/gui/src/runtime.rs b/apps/gui/src/runtime.rs new file mode 100644 index 000000000..4877a334c --- /dev/null +++ b/apps/gui/src/runtime.rs @@ -0,0 +1,166 @@ +//! The threads az's own detached work runs on. +//! +//! # What this is not for +//! +//! It is not for a read whose answer a caller needs. Five commands used to +//! hand a synchronous store read to this pool and await the result, and that +//! await was a defect rather than a cost: the caller is a `#[tauri::command]` +//! parked on Tauri's tokio executor while the work finishes on a nagoya +//! worker, so the waker is registered with one executor and woken from the +//! other. A wake lost in that handoff is not a slow command, it is a command +//! that never returns, and the webview promise behind it stays pending for the +//! life of the window. `discover_chat_imports` was dispatched sixteen times in +//! one session and answered seven, the last eight wedged, which left Settings +//! showing "No sessions discovered" while discovery itself worked. +//! +//! Those five are plain synchronous `#[tauri::command]` functions now. Tauri +//! runs those on the invoke thread rather than the async runtime, which is +//! what they wanted in the first place: the measurement at +//! [`crate::projects::list_item_rows`] is about staying off the async workers, +//! where `list_quota` averages over a second, and a synchronous command is +//! never on them. No executor between the caller and the answer means no +//! handoff to lose. +//! +//! # What it is for +//! +//! Work nobody waits for. The run loop's liveness ping and its cliff steers +//! are the cases: each has to happen somewhere other than the task draining +//! provider events, because `control.send` waits for a provider that +//! acknowledges only after emitting a burst of events, and none of them has an +//! answer the loop reads. [`Pool::spawn`] takes those, and it returns nothing +//! precisely so that no caller can reintroduce the await this module exists +//! without. +//! +//! # Why az owns a pool rather than calling `nagoya::runtime::background()` +//! +//! Nagoya ships a shared pool started on first use, and it is honest about +//! being the same shape as tokio's global: it exists so five library crates in +//! one process do not start five pools. That reasoning is about *libraries*. +//! az is the binary. It is the one component that knows how many threads the +//! machine should give this app and when the app is exiting, and a binary +//! reaching for the convenience global gives away both. +//! +//! # Why `Drop` is not the shutdown +//! +//! Nagoya's `Runtime` detaches its threads when dropped, deliberately: the +//! tasks on it are the ones nobody is watching, and tearing them down under a +//! running sweep is worse than letting them finish. That makes drop the wrong +//! shutdown here and an explicit [`Pool::stop`] the right one, called from the +//! persistence drain, after the last send that could still be in flight. + +use std::future::Future; +use std::time::Duration; + +use nagoya::runtime::Runtime; + +/// A pool for work whose answer nobody is waiting for. +/// +/// Everything submitted here is detached: a liveness ping, a cliff steer. It +/// is sent from the task that must keep draining provider events, and the +/// sender carries its own failure back rather than returning one, because +/// there is no handle to return it through. See the module for why the +/// answer-returning half of this was a defect and is gone. +/// +/// That is what bounds the size. A pool for futures wants a thread per core; a +/// pool that also absorbs blocking wants tokio's 512, because it cannot know +/// how long a unit holds its thread. This one can: what it holds is a provider +/// send waiting on an acknowledgement, and there are a handful per turn, so a +/// worker per core leaves one able to start immediately while another waits, +/// without dedicating hundreds of stacks to proving it. +pub struct Pool { + runtime: Runtime, +} + +impl Pool { + /// Start the pool, sized to the machine. + #[must_use] + pub fn new() -> Self { + // `available_parallelism` fails on a container with no cpuset visible. + // Two is the floor rather than one because a single worker turns the + // pool back into a queue: a transcript scan would hold the only thread + // and the store read it is supposed to run beside would wait for it. + let workers = std::thread::available_parallelism().map_or(2, std::num::NonZeroUsize::get); + Self { + // `spread`, not the `locality` default. Locality keeps a woken task + // on the worker that woke it, which is the right answer for tasks + // that suspend and resume: the cache is already warm there. Nothing + // submitted here ever wakes, because nothing here ever suspends, so + // that policy has no work to do and the only scheduling decision + // left is which worker takes a job handed in from outside. `spread` + // puts it where any idle worker can take it. + // + // Not `throughput`, which is the other outside-submission preset: + // its eight-job injector batch lets one worker claim a run of jobs + // its neighbours cannot see. That is a win for a firehose of + // uniform short jobs and a loss here, where a batch can contain one + // transcript scan and seven store reads that then wait behind it. + runtime: Runtime::with_tuning(workers, nagoya::Tuning::spread(), "az"), + } + } + + /// Start `work` on the pool and do not wait for it. + /// + /// For a send whose answer the caller does not need and must not block + /// for. The run loop's liveness ping is the case: it has to keep draining + /// provider events, and awaiting the send fills the bounded event channel + /// and manufactures the deadlock the ping exists to detect. + /// + /// Nothing is returned, so a panic inside `work` is lost rather than + /// propagated. `work` should carry its own failure back, the way the ping + /// sets a latch the loop reads. + /// + /// This is a future rather than a closure, unlike [`Self::run`]: the point + /// is work that suspends, where `run`'s point is work that does not. + pub fn spawn(&self, work: F) + where + F: Future + Send + 'static, + { + // The handle is dropped, which detaches rather than cancels, so the + // task runs to completion with nobody watching. + drop(self.runtime.spawn(work)); + } + + /// Stop the workers and let their threads exit. + /// + /// Idempotent, and safe to call with work still queued: a worker finishes + /// the unit it is running before it sees the shutdown. Called from the one + /// drain every exit path shares, so a read in flight when the window closes + /// completes rather than vanishing with the process. + pub fn stop(&self) { + self.runtime.pool().shut_down(); + } +} + +impl Default for Pool { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for Pool { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("Pool").finish_non_exhaustive() + } +} + +/// A deadline `duration` from now, on the clock [`nagoya::now_ns`] reads. +/// +/// Nagoya has `sleep_until` but no `timeout_at`, so a deadline shared by +/// several awaits is held as an absolute instant here and converted back to +/// what is left of it at each call, by [`remaining`]. That is what +/// `tokio::time::timeout_at` gave: one budget spanning a sequence of steps, +/// rather than a fresh full timeout for each. +#[must_use] +pub fn deadline_in(duration: Duration) -> u64 { + nagoya::now_ns().saturating_add(u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)) +} + +/// What is left of `deadline`, saturating at zero. +/// +/// A deadline already passed returns zero rather than wrapping, and a timeout +/// of zero fires on its first poll, which is the answer a caller past its +/// budget is owed. +#[must_use] +pub fn remaining(deadline: u64) -> Duration { + Duration::from_nanos(deadline.saturating_sub(nagoya::now_ns())) +} diff --git a/apps/gui/src/settings.rs b/apps/gui/src/settings.rs index 4d38c181a..773c46685 100644 --- a/apps/gui/src/settings.rs +++ b/apps/gui/src/settings.rs @@ -132,7 +132,7 @@ pub struct GlobalSettings { pub struct Review { /// The review instruction, prepended to the PR URL. Empty uses the default. pub prompt: String, - /// Model per reviewer agent (`claude` / `codex` / `copilot`); empty is the + /// Model per reviewer agent (`claude` / `codex` / `copilot` / `grok`); empty is the /// agent's default. pub models: BTreeMap, } @@ -515,6 +515,10 @@ impl Default for GlobalSettings { ), ), ("copilot".to_string(), sel(&["auto"], "auto")), + ( + "grok".to_string(), + sel(&["grok-4.6", "grok-4.5"], "grok-4.6"), + ), ]), default_permission: "read_only".into(), default_effort: "high".into(), @@ -644,7 +648,7 @@ pub fn normalize(settings: &mut GlobalSettings) { selection.enabled.push("claude-opus-5".to_string()); } } - if !matches!(settings.default_agent.as_str(), "claude" | "codex") { + if !matches!(settings.default_agent.as_str(), "claude" | "codex" | "grok") { settings.default_agent = defaults.default_agent; } if !valid_permission(&settings.default_permission) { @@ -712,7 +716,7 @@ pub fn normalize(settings: &mut GlobalSettings) { /// agent because different providers may expose the same model id and the /// moderator picker now spans every configured provider. fn normalize_moderator_model(settings: &mut GlobalSettings) { - const AGENTS: [&str; 3] = ["claude", "codex", "copilot"]; + const AGENTS: [&str; 4] = ["claude", "codex", "copilot", "grok"]; let configured = settings.moderator.model.clone(); let canonical = if let Some((agent, model)) = configured.split_once(':') { diff --git a/apps/gui/src/store_backup.rs b/apps/gui/src/store_backup.rs index 379a47f41..5373e6646 100644 --- a/apps/gui/src/store_backup.rs +++ b/apps/gui/src/store_backup.rs @@ -464,13 +464,9 @@ fn verify_store(store: &Path, manifest: &Manifest) -> Result<(), String> { } fn load_store_tables(store: &Path) -> Result<(), String> { - let runtime = tokio::runtime::Runtime::new() - .map_err(|error| format!("could not start backup validation: {error}"))?; - let tables = runtime - .block_on(crate::db::tables::Tables::open(store)) + let tables = nagoya::block_on(crate::db::tables::Tables::open(store)) .map_err(|error| format!("restored WorkTable store would not open: {error}"))?; - runtime - .block_on(tables.shutdown()) + nagoya::block_on(tables.shutdown()) .map_err(|error| format!("restored WorkTable store would not drain: {error}"))?; drop(tables); Ok(()) @@ -657,20 +653,16 @@ mod tests { let _ = std::fs::remove_dir_all(root); } - #[test] - fn semantic_preflight_opens_and_drains_a_real_worktable_store() { + #[tokio::test] + async fn semantic_preflight_opens_and_drains_a_real_worktable_store() { let root = scratch("semantic"); let store = root.join("db"); - let runtime = tokio::runtime::Runtime::new().expect("runtime starts"); - let tables = runtime - .block_on(crate::db::tables::Tables::open(&store)) + let tables = crate::db::tables::Tables::open(&store) + .await .expect("real store opens"); - runtime - .block_on(tables.stamp_schema()) - .expect("schema stamps"); - runtime.block_on(tables.shutdown()).expect("store drains"); + tables.stamp_schema().await.expect("schema stamps"); + tables.shutdown().await.expect("store drains"); drop(tables); - drop(runtime); load_store_tables(&store).expect("restore preflight accepts the real store"); diff --git a/apps/gui/src/update.rs b/apps/gui/src/update.rs index b12a5a249..9fabadd70 100644 --- a/apps/gui/src/update.rs +++ b/apps/gui/src/update.rs @@ -34,7 +34,7 @@ pub(crate) async fn check_for_update(app: AppHandle) -> Result Res let updater = app.updater().map_err(|e| e.to_string())?; let Some(update) = (|| updater.check()) .retry(crate::retry::interactive_backoff()) - .sleep(tokio::time::sleep) + .sleep(nagoya::sleep) .await .map_err(|e| e.to_string())? else { diff --git a/apps/gui/tauri.blitz.conf.json b/apps/gui/tauri.blitz.conf.json index ccdd9260a..afa5b9a38 100644 --- a/apps/gui/tauri.blitz.conf.json +++ b/apps/gui/tauri.blitz.conf.json @@ -15,7 +15,8 @@ "minHeight": 640, "resizable": true, "titleBarStyle": "Overlay", - "hiddenTitle": true + "hiddenTitle": true, + "transparent": true } ] }, diff --git a/apps/gui/tauri.conf.json b/apps/gui/tauri.conf.json index c0c2291b8..48a50250f 100644 --- a/apps/gui/tauri.conf.json +++ b/apps/gui/tauri.conf.json @@ -40,7 +40,8 @@ "bundle": { "active": true, "externalBin": [ - "binaries/agency-proxy" + "binaries/agency-proxy", + "binaries/agencyzero-wt-v2-reader" ], "targets": [ "app" diff --git a/apps/gui/tauri.dev.conf.json b/apps/gui/tauri.dev.conf.json index 0424b37a3..0a85989bd 100644 --- a/apps/gui/tauri.dev.conf.json +++ b/apps/gui/tauri.dev.conf.json @@ -14,7 +14,8 @@ "minHeight": 640, "resizable": true, "titleBarStyle": "Overlay", - "hiddenTitle": true + "hiddenTitle": true, + "transparent": true } ] } diff --git a/apps/gui/tauri.experimental.conf.json b/apps/gui/tauri.experimental.conf.json index d84e89dd1..c3ee0d215 100644 --- a/apps/gui/tauri.experimental.conf.json +++ b/apps/gui/tauri.experimental.conf.json @@ -14,7 +14,8 @@ "minHeight": 640, "resizable": true, "titleBarStyle": "Overlay", - "hiddenTitle": true + "hiddenTitle": true, + "transparent": true } ] }, diff --git a/crates/agency-tools/Cargo.toml b/crates/agency-tools/Cargo.toml index 4ab6918ff..2c1e1f7b8 100644 --- a/crates/agency-tools/Cargo.toml +++ b/crates/agency-tools/Cargo.toml @@ -11,7 +11,7 @@ publish.workspace = true worktable.workspace = true # The `worktable!` macro emits code that names these by bare path rather than # through a re-export, so a consumer of the macro has to declare them too. -# Versions match worktable 1.0 beta's own, since a mismatch produces errors that name +# Versions match WorkTable 1.9's own, since a mismatch produces errors that name # the wrong crate. Kept identical to the block in apps/gui/Cargo.toml — the # schema files compiled here are the gui's own (see src/lib.rs). eyre = "0.6" @@ -19,16 +19,15 @@ rkyv = { version = "0.8.9", features = ["uuid-1"] } derive_more = { version = "2.0.1", features = ["from", "error", "display", "debug", "into"] } futures = "0.3" uuid = "1" -# `fs` and `time` are what the macro-emitted load path and our retry loop call; -# declared here rather than inherited through feature unification so a leaner -# worktable release cannot silently break this crate. -tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "time"] } +nagoya = "^0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" # Stands in for Tauri's `app_config_dir()` / `app_data_dir()`: same platform # directories, without pulling the webview stack into a headless binary. dirs = "6" + + [[bin]] name = "agency-tools" path = "src/main.rs" diff --git a/crates/agency-tools/src/lib.rs b/crates/agency-tools/src/lib.rs index 473d84f70..cdf130b01 100644 --- a/crates/agency-tools/src/lib.rs +++ b/crates/agency-tools/src/lib.rs @@ -145,7 +145,7 @@ macro_rules! open_read_only { let mut last_error = None; for attempt in 0..OPEN_ATTEMPTS { if attempt > 0 { - tokio::time::sleep(std::time::Duration::from_millis( + nagoya::sleep(std::time::Duration::from_millis( RETRY_BASE_MS * u64::from(attempt), )) .await; diff --git a/crates/agency-tools/src/main.rs b/crates/agency-tools/src/main.rs index 57beec323..ad1022bea 100644 --- a/crates/agency-tools/src/main.rs +++ b/crates/agency-tools/src/main.rs @@ -216,10 +216,7 @@ fn run(command: Command) -> eyre::Result<()> { }; let location = agency_tools::data_location_for(identifier)?; let dir = location.path; - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - runtime.block_on(async { + nagoya::block_on(async { match command { Command::ListProjects => { let table = agency_tools::open_projects(&dir).await?; diff --git a/crates/agency-tools/tests/read_store.rs b/crates/agency-tools/tests/read_store.rs index e5b809a79..1e294c6de 100644 --- a/crates/agency-tools/tests/read_store.rs +++ b/crates/agency-tools/tests/read_store.rs @@ -115,210 +115,224 @@ fn store_bytes(dir: &Path) -> Vec<(PathBuf, Vec)> { files } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn projects_round_trip_without_writing() { - let dir = temp_store("projects"); - write_projects( - &dir, - vec![project("proj-b", "Beta", 2), project("proj-a", "Alpha", 1)], - ) - .await; - - let before = store_bytes(&dir); - let table = agency_tools::open_projects(&dir).await.unwrap(); - let projects = agency_tools::list_projects(&table).unwrap(); - - // Ordered by position, not by insertion or id. - assert_eq!(projects.len(), 2); - assert_eq!(projects[0].name, "Alpha"); - assert_eq!(projects[1].name, "Beta"); - // The JSON-encoded column comes back decoded. - assert_eq!(projects[0].dirs, serde_json::json!(["/tmp/alpha"])); - assert_eq!(projects[0].forked_from, serde_json::Value::Null); - - // The read-only path left every byte alone. - assert_eq!(store_bytes(&dir), before); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn items_filter_and_search() { - let dir = temp_store("items"); - write_items( - &dir, - vec![ - item("item-1", "proj-a", "Deploy the staging box", 1), - item("item-2", "proj-a", "Write release notes", 2), - item("item-3", "proj-b", "deploy production", 1), - ], - ) - .await; - write_descriptions( - &dir, - vec![KvRow { - key: "item-context:item-2".into(), - value: "Explain the release outcome".into(), - updated_at: "2026-08-09T00:00:00Z".into(), - }], - ) - .await; - - let table = agency_tools::open_items(&dir).await.unwrap(); - let kv = agency_tools::open_kv(&dir).await.unwrap(); - - let all = agency_tools::list_items(&table, None).unwrap(); - assert_eq!(all.len(), 3); - - let only_a = agency_tools::list_items(&table, Some("proj-a")).unwrap(); - assert_eq!(only_a.len(), 2); - assert!(only_a.iter().all(|item| item.project_id == "proj-a")); - - let described = agency_tools::list_items_with_descriptions(&table, &kv, Some("proj-a")) - .expect("descriptions join"); - assert_eq!(described[0].description, ""); - assert_eq!(described[1].description, "Explain the release outcome"); - - // Case-insensitive substring, across projects. - let hits = agency_tools::search_items(&table, "DEPLOY").unwrap(); - let ids: Vec<&str> = hits.iter().map(|item| item.id.as_str()).collect(); - assert_eq!(ids, ["item-1", "item-3"]); - - assert!( - agency_tools::search_items(&table, "nonexistent") - .unwrap() - .is_empty() - ); +#[test] +fn projects_round_trip_without_writing() { + nagoya::block_on(async { + let dir = temp_store("projects"); + write_projects( + &dir, + vec![project("proj-b", "Beta", 2), project("proj-a", "Alpha", 1)], + ) + .await; + + let before = store_bytes(&dir); + let table = agency_tools::open_projects(&dir).await.unwrap(); + let projects = agency_tools::list_projects(&table).unwrap(); + + // Ordered by position, not by insertion or id. + assert_eq!(projects.len(), 2); + assert_eq!(projects[0].name, "Alpha"); + assert_eq!(projects[1].name, "Beta"); + // The JSON-encoded column comes back decoded. + assert_eq!(projects[0].dirs, serde_json::json!(["/tmp/alpha"])); + assert_eq!(projects[0].forked_from, serde_json::Value::Null); + + // The read-only path left every byte alone. + assert_eq!(store_bytes(&dir), before); + }); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn provider_sessions_report_ownership_and_explicit_resets() { - let dir = temp_store("sessions"); - write_descriptions( - &dir, - vec![ - KvRow { - key: "session:proj-a".into(), - value: "claude-session".into(), +#[test] +fn items_filter_and_search() { + nagoya::block_on(async { + let dir = temp_store("items"); + write_items( + &dir, + vec![ + item("item-1", "proj-a", "Deploy the staging box", 1), + item("item-2", "proj-a", "Write release notes", 2), + item("item-3", "proj-b", "deploy production", 1), + ], + ) + .await; + write_descriptions( + &dir, + vec![KvRow { + key: "item-context:item-2".into(), + value: "Explain the release outcome".into(), updated_at: "2026-08-09T00:00:00Z".into(), - }, - KvRow { - key: "session:codex:proj-a".into(), - value: String::new(), - updated_at: "2026-08-10T00:00:00Z".into(), - }, - KvRow { - key: "session-run:run-a".into(), - value: "not provider ownership".into(), - updated_at: "2026-08-10T00:00:01Z".into(), - }, - ], - ) - .await; - - let kv = agency_tools::open_kv(&dir).await.unwrap(); - let sessions = agency_tools::list_sessions(&kv, Some("proj-a")).unwrap(); - assert_eq!(sessions.len(), 2); - assert_eq!(sessions[0].agent, "claude"); - assert_eq!(sessions[0].session_id, "claude-session"); - assert_eq!(sessions[1].agent, "codex"); - assert_eq!(sessions[1].session_id, ""); + }], + ) + .await; + + let table = agency_tools::open_items(&dir).await.unwrap(); + let kv = agency_tools::open_kv(&dir).await.unwrap(); + + let all = agency_tools::list_items(&table, None).unwrap(); + assert_eq!(all.len(), 3); + + let only_a = agency_tools::list_items(&table, Some("proj-a")).unwrap(); + assert_eq!(only_a.len(), 2); + assert!(only_a.iter().all(|item| item.project_id == "proj-a")); + + let described = agency_tools::list_items_with_descriptions(&table, &kv, Some("proj-a")) + .expect("descriptions join"); + assert_eq!(described[0].description, ""); + assert_eq!(described[1].description, "Explain the release outcome"); + + // Case-insensitive substring, across projects. + let hits = agency_tools::search_items(&table, "DEPLOY").unwrap(); + let ids: Vec<&str> = hits.iter().map(|item| item.id.as_str()).collect(); + assert_eq!(ids, ["item-1", "item-3"]); + + assert!( + agency_tools::search_items(&table, "nonexistent") + .unwrap() + .is_empty() + ); + }); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn snapshot_session_audit_names_and_deduplicates_recovery_candidates() { - let dir = temp_store("session-audit"); - write_projects(&dir, vec![project("proj-a", "Research", 1)]).await; - let projects = agency_tools::open_projects(&dir).await.unwrap(); - let current = vec![agency_tools::SessionOut { - project_id: "proj-a".into(), - agent: "codex".into(), - session_id: String::new(), - updated_at: "2026-08-10T00:00:00Z".into(), - }]; - let recovered = agency_tools::SessionOut { - project_id: "proj-a".into(), - agent: "codex".into(), - session_id: "019fe585-684c-7240-82b9-7b1a02d25983".into(), - updated_at: "2026-08-09T17:27:05Z".into(), - }; - let snapshots = vec![ - ("snapshot-1".into(), vec![recovered.clone()]), - ("snapshot-2".into(), vec![recovered]), - ]; - - let report = - agency_tools::session_recovery_report(&projects, ¤t, &snapshots, Some("proj-a")) - .unwrap(); +#[test] +fn provider_sessions_report_ownership_and_explicit_resets() { + nagoya::block_on(async { + let dir = temp_store("sessions"); + write_descriptions( + &dir, + vec![ + KvRow { + key: "session:proj-a".into(), + value: "claude-session".into(), + updated_at: "2026-08-09T00:00:00Z".into(), + }, + KvRow { + key: "session:codex:proj-a".into(), + value: String::new(), + updated_at: "2026-08-10T00:00:00Z".into(), + }, + KvRow { + key: "session-run:run-a".into(), + value: "not provider ownership".into(), + updated_at: "2026-08-10T00:00:01Z".into(), + }, + ], + ) + .await; + + let kv = agency_tools::open_kv(&dir).await.unwrap(); + let sessions = agency_tools::list_sessions(&kv, Some("proj-a")).unwrap(); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0].agent, "claude"); + assert_eq!(sessions[0].session_id, "claude-session"); + assert_eq!(sessions[1].agent, "codex"); + assert_eq!(sessions[1].session_id, ""); + }); +} - assert_eq!(report.len(), 1); - assert_eq!(report[0].project_name, "Research"); - assert_eq!(report[0].current_session_id, ""); - assert_eq!(report[0].action, "restore_snapshot_session"); - assert_eq!(report[0].snapshots, ["snapshot-1", "snapshot-2"]); +#[test] +fn snapshot_session_audit_names_and_deduplicates_recovery_candidates() { + nagoya::block_on(async { + let dir = temp_store("session-audit"); + write_projects(&dir, vec![project("proj-a", "Research", 1)]).await; + let projects = agency_tools::open_projects(&dir).await.unwrap(); + let current = vec![agency_tools::SessionOut { + project_id: "proj-a".into(), + agent: "codex".into(), + session_id: String::new(), + updated_at: "2026-08-10T00:00:00Z".into(), + }]; + let recovered = agency_tools::SessionOut { + project_id: "proj-a".into(), + agent: "codex".into(), + session_id: "019fe585-684c-7240-82b9-7b1a02d25983".into(), + updated_at: "2026-08-09T17:27:05Z".into(), + }; + let snapshots = vec![ + ("snapshot-1".into(), vec![recovered.clone()]), + ("snapshot-2".into(), vec![recovered]), + ]; + + let report = + agency_tools::session_recovery_report(&projects, ¤t, &snapshots, Some("proj-a")) + .unwrap(); + + assert_eq!(report.len(), 1); + assert_eq!(report[0].project_name, "Research"); + assert_eq!(report[0].current_session_id, ""); + assert_eq!(report[0].action, "restore_snapshot_session"); + assert_eq!(report[0].snapshots, ["snapshot-1", "snapshot-2"]); + }); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn missing_store_reads_empty_and_creates_nothing() { - let dir = temp_store("missing").join("never-written"); +#[test] +fn missing_store_reads_empty_and_creates_nothing() { + nagoya::block_on(async { + let dir = temp_store("missing").join("never-written"); - let table = agency_tools::open_items(&dir).await.unwrap(); - assert!(agency_tools::list_items(&table, None).unwrap().is_empty()); + let table = agency_tools::open_items(&dir).await.unwrap(); + assert!(agency_tools::list_items(&table, None).unwrap().is_empty()); - // Graceful is not enough — the read must not have conjured a store the - // GUI would later mistake for its own. - assert!(!dir.exists()); + // Graceful is not enough — the read must not have conjured a store the + // GUI would later mistake for its own. + assert!(!dir.exists()); + }); } /// The case the tool exists for: the GUI holds the store open while an agent /// reads it. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn reads_while_a_writer_holds_the_store() { - let dir = temp_store("held"); - - let config = DiskConfig::new_with_table_name( - dir.to_string_lossy().to_string(), - ProjectWorkTable::name_snake_case(), - ProjectWorkTable::version(), - ); - let engine = ProjectPersistenceEngine::new(config).await.unwrap(); - let writer = ProjectWorkTable::load(engine).await.unwrap(); - writer - .insert(project("proj-live", "Live", 1)) - .await - .unwrap(); - writer.wait_for_ops().await.expect("project rows persist"); +#[test] +fn reads_while_a_writer_holds_the_store() { + nagoya::block_on(async { + let dir = temp_store("held"); + + let config = DiskConfig::new_with_table_name( + dir.to_string_lossy().to_string(), + ProjectWorkTable::name_snake_case(), + ProjectWorkTable::version(), + ); + let engine = ProjectPersistenceEngine::new(config).await.unwrap(); + let writer = ProjectWorkTable::load(engine).await.unwrap(); + writer + .insert(project("proj-live", "Live", 1)) + .await + .unwrap(); + writer.wait_for_ops().await.expect("project rows persist"); - // Writer still open, exactly like a running GUI. - let reader = agency_tools::open_projects(&dir).await.unwrap(); - let projects = agency_tools::list_projects(&reader).unwrap(); - assert_eq!(projects.len(), 1); - assert_eq!(projects[0].id, "proj-live"); + // Writer still open, exactly like a running GUI. + let reader = agency_tools::open_projects(&dir).await.unwrap(); + let projects = agency_tools::list_projects(&reader).unwrap(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, "proj-live"); - drop(writer); + drop(writer); + }); } /// The binary end to end: env override in, JSON lines out. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn binary_prints_json_lines() { - let dir = temp_store("binary"); - write_projects(&dir, vec![project("proj-cli", "From the CLI", 1)]).await; - - let output = std::process::Command::new(env!("CARGO_BIN_EXE_agency-tools")) - .env("AZ_DATA_DIR", &dir) - .arg("list-projects") - .output() - .unwrap(); +#[test] +fn binary_prints_json_lines() { + nagoya::block_on(async { + let dir = temp_store("binary"); + write_projects(&dir, vec![project("proj-cli", "From the CLI", 1)]).await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_agency-tools")) + .env("AZ_DATA_DIR", &dir) + .arg("list-projects") + .output() + .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!(lines.len(), 1); - let row: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); - assert_eq!(row["id"], "proj-cli"); - assert_eq!(row["name"], "From the CLI"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 1); + let row: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(row["id"], "proj-cli"); + assert_eq!(row["name"], "From the CLI"); + }); } #[test] diff --git a/crates/wt-migrate/Cargo.toml b/crates/wt-migrate/Cargo.toml index 76a106d6c..ad5e79adb 100644 --- a/crates/wt-migrate/Cargo.toml +++ b/crates/wt-migrate/Cargo.toml @@ -18,14 +18,17 @@ eyre = "0.6" rkyv = { version = "0.8.9", features = ["uuid-1"] } derive_more = { version = "2.0.1", features = ["from", "error", "display", "debug", "into"] } futures = "0.3" +nagoya = "^0.1" uuid = "1" chrono = "0.4" +sha2 = "0.10" # The recovery verbs read an extracted item list. serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "time"] } tempfile = "3" + + [[bin]] name = "wt-migrate" path = "src/main.rs" diff --git a/crates/wt-migrate/src/lib.rs b/crates/wt-migrate/src/lib.rs index b84a38994..8ab16c26b 100644 --- a/crates/wt-migrate/src/lib.rs +++ b/crates/wt-migrate/src/lib.rs @@ -101,83 +101,87 @@ mod profile_repair_tests { } } - #[tokio::test] - async fn message_window_merge_is_bounded_verified_and_idempotent() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("source"); - let target = temp.path().join("target"); - let source_table = open_messages(&source).await; - for row in [ - message("before", "project", "2026-08-09T20:01:59+00:00"), - message("wanted", "project", "2026-08-09T20:02:00+00:00"), - message("other-project", "other", "2026-08-09T20:03:00+00:00"), - message("after", "project", "2026-08-09T20:20:00+00:00"), - ] { - source_table.insert(row).await.unwrap(); - } - source_table.wait_for_ops().await.unwrap(); - source_table.close().await.unwrap(); - - let report = merge_message_window( - &source, - &target, - "project", - "2026-08-09T20:02:00+00:00", - "2026-08-09T20:20:00+00:00", - ) - .await - .unwrap(); - assert_eq!( - report, - MessageMergeReport { - candidates: 1, - inserted: 1, - already_present: 0, + #[test] + fn message_window_merge_is_bounded_verified_and_idempotent() { + nagoya::block_on(async { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let target = temp.path().join("target"); + let source_table = open_messages(&source).await; + for row in [ + message("before", "project", "2026-08-09T20:01:59+00:00"), + message("wanted", "project", "2026-08-09T20:02:00+00:00"), + message("other-project", "other", "2026-08-09T20:03:00+00:00"), + message("after", "project", "2026-08-09T20:20:00+00:00"), + ] { + source_table.insert(row).await.unwrap(); } - ); - let repeated = merge_message_window( - &source, - &target, - "project", - "2026-08-09T20:02:00+00:00", - "2026-08-09T20:20:00+00:00", - ) - .await - .unwrap(); - assert_eq!(repeated.inserted, 0); - assert_eq!(repeated.already_present, 1); - } - - #[tokio::test] - async fn session_restore_refuses_to_replace_a_different_live_pointer() { - let temp = tempfile::tempdir().unwrap(); - let target = temp.path().join("target"); - restore_provider_session(&target, "project", "codex", "recovered") + source_table.wait_for_ops().await.unwrap(); + source_table.close().await.unwrap(); + + let report = merge_message_window( + &source, + &target, + "project", + "2026-08-09T20:02:00+00:00", + "2026-08-09T20:20:00+00:00", + ) .await .unwrap(); - restore_provider_session(&target, "project", "codex", "recovered") + assert_eq!( + report, + MessageMergeReport { + candidates: 1, + inserted: 1, + already_present: 0, + } + ); + let repeated = merge_message_window( + &source, + &target, + "project", + "2026-08-09T20:02:00+00:00", + "2026-08-09T20:20:00+00:00", + ) .await .unwrap(); - let error = restore_provider_session(&target, "project", "codex", "different") - .await - .unwrap_err(); - assert!(error.to_string().contains("another nonempty session")); + assert_eq!(repeated.inserted, 0); + assert_eq!(repeated.already_present, 1); + }); + } - let config = DiskConfig::new_with_table_name( - target.to_string_lossy().into_owned(), - KvWorkTable::name_snake_case(), - KvWorkTable::version(), - ); - let engine = KvPersistenceEngine::new(config).await.unwrap(); - let table = KvWorkTable::load(engine).await.unwrap(); - let row = table - .select_all() - .execute() - .unwrap() - .into_iter() - .find(|row| row.key == "session:codex:project") - .unwrap(); - assert_eq!(row.value, "recovered"); + #[test] + fn session_restore_refuses_to_replace_a_different_live_pointer() { + nagoya::block_on(async { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target"); + restore_provider_session(&target, "project", "codex", "recovered") + .await + .unwrap(); + restore_provider_session(&target, "project", "codex", "recovered") + .await + .unwrap(); + let error = restore_provider_session(&target, "project", "codex", "different") + .await + .unwrap_err(); + assert!(error.to_string().contains("another nonempty session")); + + let config = DiskConfig::new_with_table_name( + target.to_string_lossy().into_owned(), + KvWorkTable::name_snake_case(), + KvWorkTable::version(), + ); + let engine = KvPersistenceEngine::new(config).await.unwrap(); + let table = KvWorkTable::load(engine).await.unwrap(); + let row = table + .select_all() + .execute() + .unwrap() + .into_iter() + .find(|row| row.key == "session:codex:project") + .unwrap(); + assert_eq!(row.value, "recovered"); + }); } } @@ -767,6 +771,629 @@ pub mod app_schema { pub mod usage_session; } +/// One table verified during a v2 page-format conversion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2TableImport { + pub table: String, + pub rows: usize, + pub source_digest: [u8; 32], + pub v3_digest: [u8; 32], +} + +const V2_EXPORT_MAGIC: &[u8; 10] = b"AZWT2ROWS\0"; +const MAX_EXPORTED_ROW_BYTES: usize = 64 * 1024 * 1024; + +struct ExportedRows { + rows: Vec>, + source_digest: [u8; 32], +} + +fn digest_archives(rows: &mut [Vec]) -> [u8; 32] { + use sha2::{Digest as _, Sha256}; + + rows.sort_unstable(); + let mut digest = Sha256::new(); + for row in rows { + digest.update((row.len() as u64).to_le_bytes()); + digest.update(row); + } + digest.finalize().into() +} + +fn read_v2_export(directory: &Path, table: &str) -> eyre::Result { + use std::io::Read as _; + + let path = directory.join(format!("{table}.rows")); + let mut input = std::io::BufReader::new(std::fs::File::open(&path)?); + let mut magic = [0; V2_EXPORT_MAGIC.len()]; + input.read_exact(&mut magic)?; + eyre::ensure!( + &magic == V2_EXPORT_MAGIC, + "{} is not an AgencyZero v2 row export", + path.display() + ); + let mut number = [0; 8]; + input.read_exact(&mut number)?; + let count = usize::try_from(u64::from_le_bytes(number))?; + let mut source_digest = [0; 32]; + input.read_exact(&mut source_digest)?; + + let mut rows = Vec::new(); + rows.try_reserve_exact(count)?; + for ordinal in 0..count { + input.read_exact(&mut number)?; + let len = usize::try_from(u64::from_le_bytes(number))?; + eyre::ensure!( + len <= MAX_EXPORTED_ROW_BYTES, + "{table} row {ordinal} claims {len} bytes" + ); + let mut row = vec![0; len]; + input.read_exact(&mut row)?; + rows.push(row); + } + let mut trailing = [0]; + eyre::ensure!( + input.read(&mut trailing)? == 0, + "{} has trailing bytes", + path.display() + ); + let calculated = digest_archives(&mut rows); + eyre::ensure!( + calculated == source_digest, + "{table} export digest does not match its rows" + ); + Ok(ExportedRows { + rows, + source_digest, + }) +} + +/// Import the neutral output of the separately resolved WorkTable v2 reader. +/// +/// Every v2 archive is checked before decoding. Each decoded row is then +/// re-archived with the current schema, inserted into a new v3 table, drained, +/// cold-opened in strict mode, and compared by count and full-row digest. A +/// successful report therefore proves the primary keys and every row field +/// survived; WorkTable's strict open separately proves the persisted indexes. +/// +/// # Errors +/// The export is incomplete or corrupt, a row no longer matches AgencyZero's +/// schema, an insert fails, or cold verification differs. `target` is staging +/// data and may be deleted by the caller; this function never touches the v2 +/// source store. +pub async fn import_v2_export(export: &Path, target: &Path) -> eyre::Result> { + eyre::ensure!( + !target.exists(), + "target {} already exists", + target.display() + ); + std::fs::create_dir(target)?; + + macro_rules! import { + ($module:ident, $Row:ident, $Engine:ident, $Table:ident) => {{ + let table = app_schema::$module::$Table::name_snake_case(); + let exported = read_v2_export(export, table)?; + let mut decoded = Vec::new(); + decoded.try_reserve_exact(exported.rows.len())?; + let mut canonical = Vec::new(); + canonical.try_reserve_exact(exported.rows.len())?; + for (ordinal, archive) in exported.rows.iter().enumerate() { + let row = + rkyv::from_bytes::(archive) + .map_err(|error| { + eyre::eyre!("v2 {table} row {ordinal} would not decode: {error}") + })?; + canonical.push( + rkyv::to_bytes::(&row) + .map_err(|error| { + eyre::eyre!("v3 {table} row {ordinal} would not archive: {error}") + })? + .to_vec(), + ); + decoded.push(row); + } + let expected_digest = digest_archives(&mut canonical); + let config = DiskConfig::new_with_table_name( + target.to_string_lossy().into_owned(), + table, + app_schema::$module::$Table::version(), + ); + let engine = app_schema::$module::$Engine::new(config).await?; + let fresh = app_schema::$module::$Table::load(engine).await?; + for row in decoded { + fresh + .insert(row) + .await + .map_err(|error| eyre::eyre!("v3 {table} insert failed: {error}"))?; + } + fresh + .wait_for_ops() + .await + .map_err(|error| eyre::eyre!("v3 {table} persistence failed: {error}"))?; + fresh + .close() + .await + .map_err(|error| eyre::eyre!("v3 {table} close failed: {error}"))?; + + let config = DiskConfig::new_with_table_name( + target.to_string_lossy().into_owned(), + table, + app_schema::$module::$Table::version(), + ); + let engine = app_schema::$module::$Engine::new(config).await?; + let verified = app_schema::$module::$Table::load(engine).await?; + let mut verified_archives = Vec::new(); + for row in verified.select_all().execute()? { + verified_archives.push( + rkyv::to_bytes::(&row) + .map_err(|error| { + eyre::eyre!("verified v3 {table} row would not archive: {error}") + })? + .to_vec(), + ); + } + let count = verified_archives.len(); + let verified_digest = digest_archives(&mut verified_archives); + verified + .close() + .await + .map_err(|error| eyre::eyre!("verified v3 {table} close failed: {error}"))?; + eyre::ensure!( + count == exported.rows.len(), + "v3 {table} cold open found {count} of {} rows", + exported.rows.len() + ); + eyre::ensure!( + verified_digest == expected_digest, + "v3 {table} cold-open digest differs from the decoded v2 rows" + ); + V2TableImport { + table: table.to_string(), + rows: count, + source_digest: exported.source_digest, + v3_digest: verified_digest, + } + }}; + } + + Ok(vec![ + import!(kv, KvRow, KvPersistenceEngine, KvWorkTable), + import!( + project, + ProjectRow, + ProjectPersistenceEngine, + ProjectWorkTable + ), + import!( + project_item, + ProjectItemRow, + ProjectItemPersistenceEngine, + ProjectItemWorkTable + ), + import!( + item_completion, + ItemCompletionRow, + ItemCompletionPersistenceEngine, + ItemCompletionWorkTable + ), + import!( + message, + MessageRow, + MessagePersistenceEngine, + MessageWorkTable + ), + import!( + message_chunk, + MessageChunkRow, + MessageChunkPersistenceEngine, + MessageChunkWorkTable + ), + import!( + task_log, + TaskLogRow, + TaskLogPersistenceEngine, + TaskLogWorkTable + ), + import!( + agent_io, + AgentIoRowRow, + AgentIoRowPersistenceEngine, + AgentIoRowWorkTable + ), + import!( + usage_ledger, + UsageLedgerRow, + UsageLedgerPersistenceEngine, + UsageLedgerWorkTable + ), + import!( + usage_cache, + UsageCacheRow, + UsageCachePersistenceEngine, + UsageCacheWorkTable + ), + import!( + usage_session, + UsageSessionRow, + UsageSessionPersistenceEngine, + UsageSessionWorkTable + ), + import!( + approval_rule, + ApprovalRuleRow, + ApprovalRulePersistenceEngine, + ApprovalRuleWorkTable + ), + import!( + pull_request, + PullRequestRow, + PullRequestPersistenceEngine, + PullRequestWorkTable + ), + import!( + question, + QuestionRow, + QuestionPersistenceEngine, + QuestionWorkTable + ), + import!( + question_reply, + QuestionReplyRow, + QuestionReplyPersistenceEngine, + QuestionReplyWorkTable + ), + import!( + reply_checkpoint, + ReplyCheckpointRow, + ReplyCheckpointPersistenceEngine, + ReplyCheckpointWorkTable + ), + import!( + study_event, + StudyEventRow, + StudyEventPersistenceEngine, + StudyEventWorkTable + ), + ]) +} + +#[derive(Debug)] +struct PageMigrationPaths { + stage: std::path::PathBuf, + export: std::path::PathBuf, + backup: std::path::PathBuf, + state: std::path::PathBuf, +} + +impl PageMigrationPaths { + fn for_store(store: &Path) -> Self { + Self { + stage: sibling_with_suffix(store, "v3-migration-stage"), + export: sibling_with_suffix(store, "v2-migration-export"), + backup: sibling_with_suffix(store, "v2-preserved"), + state: sibling_with_suffix(store, "v3-migration-state"), + } + } +} + +fn sibling_with_suffix(path: &Path, suffix: &str) -> std::path::PathBuf { + let mut name = path.file_name().unwrap_or(path.as_os_str()).to_os_string(); + name.push("."); + name.push(suffix); + path.with_file_name(name) +} + +/// Result of the automatic first-load page-format migration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct V2MigrationReport { + pub tables: Vec, +} + +fn sync_parent(path: &Path) -> eyre::Result<()> { + let parent = path + .parent() + .ok_or_else(|| eyre::eyre!("{} has no parent directory", path.display()))?; + std::fs::File::open(parent)?.sync_all()?; + Ok(()) +} + +/// Make an internally-created store tree durable before publishing any state +/// that permits it to replace the source. +/// +/// WorkTable's `close` drains the writer and closes its files, which makes a +/// strict cold reopen meaningful, but closing a file is not an fsync. Sync +/// files before their containing directories, then the stage root before its +/// parent: after the durable `validated` phase exists, crash recovery is +/// allowed to promote this tree and delete the v2 source. +fn sync_store_tree(path: &Path) -> eyre::Result<()> { + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_dir() { + sync_store_tree(&entry.path())?; + } else if file_type.is_file() { + std::fs::File::open(entry.path())?.sync_all()?; + } else { + eyre::bail!( + "staged v3 store contains unsupported filesystem entry {}", + entry.path().display() + ); + } + } + std::fs::File::open(path)?.sync_all()?; + Ok(()) +} + +fn write_migration_phase(path: &Path, phase: &str) -> eyre::Result<()> { + let temporary = sibling_with_suffix(path, "next"); + { + use std::io::Write as _; + let mut file = std::fs::File::create(&temporary)?; + writeln!(file, "{phase}")?; + file.sync_all()?; + } + std::fs::rename(&temporary, path)?; + sync_parent(path) +} + +fn remove_derived(path: &Path) -> eyre::Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => std::fs::remove_dir_all(path)?, + Ok(_) => std::fs::remove_file(path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +/// A file written inside the staged tree, so the promoted directory says what +/// it is rather than being inferred from where it sits. +/// +/// The phase marker records how far the sequence got, never which tree ended up +/// at `store`, and every ambiguity in this state machine came from that. The +/// rollback below can leave the **v2** directory back at `store` with the marker +/// still reading `source-preserved`, which is byte for byte the shape a crash +/// between the stage rename and the final marker leaves behind. One of those +/// wants the backup deleted and the other wants it kept, and from outside the +/// two are indistinguishable. Because this rides inside the tree, the rename +/// that publishes v3 carries it atomically: if it is at `store`, the promotion +/// happened. +const PROMOTED_MARKER: &str = ".agencyzero-v3-promoted"; + +fn write_promotion_marker(tree: &Path) -> eyre::Result<()> { + let path = tree.join(PROMOTED_MARKER); + { + use std::io::Write as _; + let mut file = std::fs::File::create(&path)?; + writeln!(file, "v3")?; + file.sync_all()?; + } + sync_parent(&path) +} + +/// Whether `tree` is the migrated v3 store rather than the preserved v2 source. +fn is_promoted(tree: &Path) -> bool { + tree.join(PROMOTED_MARKER).is_file() +} + +fn finish_page_format_promotion(store: &Path, paths: &PageMigrationPaths) -> eyre::Result<()> { + // The stage rename may have reached disk before the final phase marker. + // In that state the live v3 directory and retained v2 backup are already + // exactly where they belong; only the durable state needs catching up. + // + // `is_promoted` is what makes that safe to act on. The same directory + // shape is also what a rolled-back promotion leaves, and there the tree at + // `store` is the v2 source: deleting the backup then would destroy the only + // copy of the owner's data. + if store.is_dir() && paths.backup.is_dir() && !paths.stage.exists() { + // Refusing is the safe answer, and the only one available: the two + // states differ by which tree is live, and without the sentinel there + // is nothing on disk that says. The alternative is a coin flip whose + // losing side deletes the owner's only copy. + eyre::ensure!( + is_promoted(store), + "refusing to discard the preserved v2 backup: {} is not the promoted v3 store, \ + so this is an interrupted rollback rather than a completed promotion. \ + If {} is the wanted store, move it back over {} by hand; if it is not, \ + the v2 data is the one already live.", + store.display(), + paths.backup.display(), + store.display() + ); + write_migration_phase(&paths.state, "complete")?; + remove_derived(&paths.backup)?; + remove_derived(&paths.export)?; + sync_parent(store)?; + return Ok(()); + } + if store.is_dir() { + eyre::ensure!( + !paths.backup.exists(), + "refusing to overwrite existing v2 backup {}", + paths.backup.display() + ); + std::fs::rename(store, &paths.backup)?; + sync_parent(store)?; + } else { + eyre::ensure!( + paths.backup.is_dir(), + "migration source and preserved backup are both absent" + ); + } + write_migration_phase(&paths.state, "source-preserved")?; + + if paths.stage.is_dir() { + // Written before the rename, not after, so it travels with the tree. + // A marker written afterwards would leave the window this exists to + // close: promoted on disk, and no durable way to know it. + write_promotion_marker(&paths.stage)?; + if let Err(error) = std::fs::rename(&paths.stage, store) { + if !store.exists() { + // The rollback's own failures used to be discarded with + // `let _`, so a rollback that did not happen was reported as + // one that did: the owner was told their v2 data was "retained + // at its durable v2-preserved path" by code that had no idea + // whether it was. Both outcomes are now spelled out, because + // they need different things from whoever reads the error. + let restored = std::fs::rename(&paths.backup, store) + .map_err(eyre::Report::from) + .and_then(|()| sync_parent(store)) + // Back to `validated`: the source is no longer preserved + // elsewhere, and leaving the marker claiming it is would + // send the next boot to finish a promotion that was undone. + .and_then(|()| write_migration_phase(&paths.state, "validated")); + return Err(match restored { + Ok(()) => eyre::eyre!( + "could not promote staged v3 store: {error}. The original v2 store \ + was restored and is live again." + ), + Err(rollback) => eyre::eyre!( + "could not promote staged v3 store: {error}. Restoring the original \ + also failed: {rollback}. The v2 store is intact at {}, and must be \ + moved back to {} by hand.", + paths.backup.display(), + store.display() + ), + }); + } + return Err(eyre::eyre!("could not promote staged v3 store: {error}")); + } + sync_parent(store)?; + } else { + eyre::ensure!( + store.is_dir() && paths.backup.is_dir(), + "migration has neither a staged nor promoted v3 store" + ); + } + write_migration_phase(&paths.state, "complete")?; + remove_derived(&paths.backup)?; + remove_derived(&paths.export)?; + sync_parent(store)?; + Ok(()) +} + +/// Resume an interrupted page-format promotion before any table is opened. +/// +/// Returns `true` when a prior migration is already complete or was completed +/// by this call. An interrupted export/import is discarded because it contains +/// only derived bytes; the untouched v2 source will be exported again. +/// +/// # Errors +/// Durable state and filesystem contents disagree, or a rename/sync fails. +pub fn resume_page_format_migration(store: &Path) -> eyre::Result { + let paths = PageMigrationPaths::for_store(store); + let phase = match std::fs::read_to_string(&paths.state) { + Ok(phase) => phase.trim().to_string(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // No marker does not always mean nothing happened. The marker is + // published by create-temp-then-rename, so a run that died after + // `rename(store -> backup)` but before that rename landed leaves + // the source preserved and no record of it. Read as "nothing to + // do", the app then finds no store, and `migrate_page_format_v2` + // refuses for as long as the backup exists: unbootable, with no + // path out that the app can take on its own. + // + // The backup is the durable fact here, so trust it over the + // missing marker and finish what the interrupted run started. + if !store.exists() && paths.backup.is_dir() { + finish_page_format_promotion(store, &paths)?; + return Ok(true); + } + return Ok(false); + } + Err(error) => return Err(error.into()), + }; + match phase.as_str() { + "exporting" => { + eyre::ensure!( + store.is_dir() && !paths.backup.exists(), + "interrupted export no longer has its sole v2 source" + ); + remove_derived(&paths.stage)?; + remove_derived(&paths.export)?; + std::fs::remove_file(&paths.state)?; + sync_parent(store)?; + Ok(false) + } + "validated" | "source-preserved" => { + finish_page_format_promotion(store, &paths)?; + Ok(true) + } + "complete" => { + eyre::ensure!( + store.is_dir() && !paths.stage.exists(), + "completed migration state does not match the live v3 store" + ); + // No sentinel check here, deliberately. `complete` is written only + // after the v3 tree is live, so the phase is already the proof, and + // it is the one phase that is unambiguous without help. Demanding + // the sentinel as well would refuse to boot every store migrated + // before the sentinel existed: their marker says `complete` and + // their tree, correctly, has no such file. The ambiguity this + // guards against lives in `source-preserved`, where the phase + // cannot distinguish a promotion from a rollback, and that is + // where `finish_page_format_promotion` checks it. + remove_derived(&paths.backup)?; + remove_derived(&paths.export)?; + sync_parent(store)?; + Ok(true) + } + other => Err(eyre::eyre!("unknown v3 migration phase {other:?}")), + } +} + +/// Automatically convert and promote one v2 AgencyZero store. +/// +/// The bundled `reader` is a separately resolved, read-only WorkTable v2 +/// executable. It emits neutral row archives. This process imports those rows +/// into a sibling v3 staging directory, drains and cold-verifies every table, +/// then preserves the original directory before two same-filesystem renames +/// publish v3. On any pre-promotion failure the source is untouched. +/// +/// # Errors +/// The reader is missing/fails, import or cold validation fails, durable state +/// cannot be synced, or promotion cannot be completed or rolled back. +pub fn migrate_page_format_v2(store: &Path, reader: &Path) -> eyre::Result { + eyre::ensure!(store.is_dir(), "v2 store {} is absent", store.display()); + eyre::ensure!(reader.is_file(), "v2 reader {} is absent", reader.display()); + let paths = PageMigrationPaths::for_store(store); + eyre::ensure!( + !paths.backup.exists(), + "refusing to overwrite existing v2 backup {}", + paths.backup.display() + ); + remove_derived(&paths.stage)?; + remove_derived(&paths.export)?; + write_migration_phase(&paths.state, "exporting")?; + + let status = std::process::Command::new(reader) + .arg(store) + .arg(&paths.export) + .status() + .map_err(|error| eyre::eyre!("could not start v2 reader: {error}"))?; + if !status.success() { + remove_derived(&paths.stage)?; + remove_derived(&paths.export)?; + std::fs::remove_file(&paths.state)?; + return Err(eyre::eyre!("v2 reader exited with {status}")); + } + + let tables = match nagoya::block_on(import_v2_export(&paths.export, &paths.stage)) { + Ok(report) => report, + Err(error) => { + remove_derived(&paths.stage)?; + remove_derived(&paths.export)?; + std::fs::remove_file(&paths.state)?; + return Err(error); + } + }; + sync_store_tree(&paths.stage)?; + sync_parent(&paths.stage)?; + write_migration_phase(&paths.state, "validated")?; + finish_page_format_promotion(store, &paths)?; + Ok(V2MigrationReport { tables }) +} + /// Merge one project's bounded message window into an existing store. /// /// Rows retain their original ids and timestamps. Existing ids are skipped, @@ -1226,18 +1853,21 @@ pub async fn recover_task_log_index( let scratch = tempfile::tempdir()?; let scratch_store = scratch.path().join("store"); let scratch_table = scratch_store.join("task_log"); - copy_dir(&source.join("task_log"), &scratch_table).await?; + copy_dir(&source.join("task_log"), &scratch_table)?; // Read the surviving secondary index directly to discover every key, // including projects that may since have been deleted from the project // table. This handle writes nothing because no events are applied. - let mut project_index = - as SpaceIndexOps>::secondary_from_table_files_path( - scratch_table.to_string_lossy().into_owned(), - "project_idx", - TaskLogWorkTable::version(), - ) - .await?; + let mut project_index = as SpaceIndexOps>::secondary_from_table_files_path( + scratch_table.to_string_lossy().into_owned(), + "project_idx", + TaskLogWorkTable::version(), + ) + .await?; let project_ids: BTreeSet = project_index .parse_indexset() .await? @@ -1249,7 +1879,7 @@ pub async fn recover_task_log_index( // Preserve the bad file inside the disposable scratch directory. Recovery // mode permits the now-empty primary index to disagree with project_idx, // but still validates every surviving project-index key and row link. - tokio::fs::rename( + nagoya::io::rename( scratch_table.join("primary.wt.idx"), scratch_table.join("primary.wt.idx.corrupt"), ) @@ -1326,13 +1956,13 @@ pub async fn recover_message_index( target: &Path, ) -> eyre::Result { use app_schema::message::{MessagePersistenceEngine, MessageRow, MessageWorkTable}; + use nagoya::io::Read as _; use std::collections::HashSet; - use tokio::io::AsyncReadExt; type StoredMessage = ::WrappedRow; let table_path = source.join("message"); - let mut primary = as SpaceIndexOps< + let mut primary = as SpaceIndexOps< String, >>::primary_from_table_files_path( table_path.to_string_lossy().into_owned(), @@ -1340,10 +1970,10 @@ pub async fn recover_message_index( ) .await?; let primary_index = primary.parse_indexset().await?; - let mut data_file = tokio::fs::File::open(table_path.join(".wt.data")).await?; + let mut data_file = worktable::fsx::open_read_only(table_path.join(".wt.data")).await?; let mut rows = BTreeMap::new(); for (id, link) in primary_index.iter() { - worktable::data_bucket::seek_by_link(&mut data_file, link).await?; + worktable::data_bucket::seek_by_link::<{ PAGE_SIZE as u32 }>(&mut data_file, link).await?; let mut bytes = vec![0u8; link.length as usize]; data_file.read_exact(&mut bytes).await?; let stored = rkyv::from_bytes::(&bytes) @@ -1534,12 +2164,12 @@ pub async fn salvage_item_index(source: &Path, target: &Path) -> eyre::Result::WrappedRow; let table_path = source.join("project_item"); - let mut primary = as SpaceIndexOps< + let mut primary = as SpaceIndexOps< String, >>::primary_from_table_files_path( table_path.to_string_lossy().into_owned(), @@ -1547,7 +2177,7 @@ pub async fn salvage_item_index(source: &Path, target: &Path) -> eyre::Result eyre::Result(&mut data_file, link) .await .is_err() { @@ -1615,7 +2245,7 @@ pub async fn salvage_item_index(source: &Path, target: &Path) -> eyre::Result::WrappedRow; let table_path = source.join("pull_request"); - let mut primary = as SpaceIndexOps< + let mut primary = as SpaceIndexOps< String, >>::primary_from_table_files_path( table_path.to_string_lossy().into_owned(), @@ -1724,16 +2354,18 @@ async fn rebuild_pull_request_index( ) .await?; let primary_index = primary.parse_indexset().await?; - let mut data_file = tokio::fs::File::open(table_path.join(".wt.data")).await?; + let mut data_file = worktable::fsx::open_read_only(table_path.join(".wt.data")).await?; let mut rows = BTreeMap::new(); let mut skipped = Vec::new(); for (id, link) in primary_index.iter() { - if let Err(error) = worktable::data_bucket::seek_by_link(&mut data_file, link).await { + if let Err(error) = + worktable::data_bucket::seek_by_link::<{ PAGE_SIZE as u32 }>(&mut data_file, link).await + { if skip_corrupt { skipped.push(id.clone()); continue; } - return Err(error); + return Err(error.into()); } let mut bytes = vec![0u8; link.length as usize]; if let Err(error) = data_file.read_exact(&mut bytes).await { @@ -1850,74 +2482,76 @@ mod recovery_tests { PullRequestPersistenceEngine, PullRequestRow, PullRequestWorkTable, }; - #[tokio::test] - async fn rebuild_store_repairs_a_secondary_index_rejected_by_strict_load() { - let root = tempfile::tempdir().expect("temporary rebuild store"); - let source = root.path().join("source"); - let target = root.path().join("target"); - let empty = root.path().join("empty"); - - let open = |dir: &Path| { - let config = DiskConfig::new_with_table_name( - dir.to_string_lossy().into_owned(), - ProjectWorkTable::name_snake_case(), - ProjectWorkTable::version(), - ); - async move { - let engine = ProjectPersistenceEngine::new(config).await?; - ProjectWorkTable::load(engine).await - } - }; + #[test] + fn rebuild_store_repairs_a_secondary_index_rejected_by_strict_load() { + nagoya::block_on(async { + let root = tempfile::tempdir().expect("temporary rebuild store"); + let source = root.path().join("source"); + let target = root.path().join("target"); + let empty = root.path().join("empty"); + + let open = |dir: &Path| { + let config = DiskConfig::new_with_table_name( + dir.to_string_lossy().into_owned(), + ProjectWorkTable::name_snake_case(), + ProjectWorkTable::version(), + ); + async move { + let engine = ProjectPersistenceEngine::new(config).await?; + ProjectWorkTable::load(engine).await + } + }; - let table = open(&source).await.expect("source project table"); - table - .insert(ProjectRow { - id: "project-1".into(), - name: "kept".into(), - status: "active".into(), - position: 1, - dirs: "[]".into(), - pinned: false, - moderator_enabled: false, - forked_from: String::new(), - last_activity_at: "2026-09-04T00:00:00Z".into(), - }) - .await - .expect("project inserts"); - table.close().await.expect("source project closes"); - - let empty_table = open(&empty).await.expect("empty project table"); - empty_table.close().await.expect("empty project closes"); - std::fs::copy( - empty.join("project/status_idx.wt.idx"), - source.join("project/status_idx.wt.idx"), - ) - .expect("replace the secondary index with a valid but stale one"); - - let error = open(&source) - .await - .expect_err("strict load must reject a missing secondary entry"); - assert!(error.to_string().contains("status_idx")); + let table = open(&source).await.expect("source project table"); + table + .insert(ProjectRow { + id: "project-1".into(), + name: "kept".into(), + status: "active".into(), + position: 1, + dirs: "[]".into(), + pinned: false, + moderator_enabled: false, + forked_from: String::new(), + last_activity_at: "2026-09-04T00:00:00Z".into(), + }) + .await + .expect("project inserts"); + table.close().await.expect("source project closes"); + + let empty_table = open(&empty).await.expect("empty project table"); + empty_table.close().await.expect("empty project closes"); + std::fs::copy( + empty.join("project/status_idx.wt.idx"), + source.join("project/status_idx.wt.idx"), + ) + .expect("replace the secondary index with a valid but stale one"); - assert_eq!( - rebuild_store(&source, &target) + let error = open(&source) .await - .expect("recovery rebuild succeeds"), - vec![("project".to_string(), 1)] - ); + .expect_err("strict load must reject a missing secondary entry"); + assert!(error.to_string().contains("status_idx")); + + assert_eq!( + rebuild_store(&source, &target) + .await + .expect("recovery rebuild succeeds"), + vec![("project".to_string(), 1)] + ); - let rebuilt = open(&target) - .await - .expect("rebuilt store passes strict load"); - assert_eq!( - rebuilt - .select_by_status("active".into()) - .execute() - .expect("rebuilt secondary index selects") - .len(), - 1 - ); - rebuilt.close().await.expect("rebuilt project closes"); + let rebuilt = open(&target) + .await + .expect("rebuilt store passes strict load"); + assert_eq!( + rebuilt + .select_by_status("active".into()) + .execute() + .expect("rebuilt secondary index selects") + .len(), + 1 + ); + rebuilt.close().await.expect("rebuilt project closes"); + }); } fn item(id: &str, project_id: &str) -> ProjectItemRow { @@ -1940,62 +2574,64 @@ mod recovery_tests { * fraction while reporting success is worse than failing outright, * because the operator swaps the store on the strength of the report. */ - #[tokio::test] - async fn the_data_file_sweep_recovers_every_row_a_torn_index_hides() { - let root = tempfile::tempdir().expect("temporary recovery store"); - let source = root.path().join("source"); - let target = root.path().join("target"); - let config = DiskConfig::new_with_table_name( - source.to_string_lossy().into_owned(), - ProjectItemWorkTable::name_snake_case(), - ProjectItemWorkTable::version(), - ); - let engine = ProjectItemPersistenceEngine::new(config) - .await - .expect("engine"); - let table = ProjectItemWorkTable::load(engine).await.expect("table"); - for (id, project) in [ - ("item-one", "proj-1"), - ("item-two", "proj-1"), - ("item-three", "proj-2"), - ("item-four", "proj-2"), - ("item-five", "proj-3"), - ] { - table.insert(item(id, project)).await.expect("row inserts"); - } - table.close().await.expect("source closes cleanly"); - - /* - * The index is emptied rather than mangled, which is the shape the - * store this verb was written for actually had: a primary that parses - * and simply does not account for the rows still in `.wt.data`. A - * corrupt index fails to parse and never reaches the sweep at all. - */ - let index = source.join("project_item/primary.wt.idx"); - let empty = tempfile::tempdir().expect("temporary empty store"); - let config = DiskConfig::new_with_table_name( - empty.path().to_string_lossy().into_owned(), - ProjectItemWorkTable::name_snake_case(), - ProjectItemWorkTable::version(), - ); - let engine = ProjectItemPersistenceEngine::new(config) - .await - .expect("empty engine"); - let table = ProjectItemWorkTable::load(engine) - .await - .expect("empty table"); - table.close().await.expect("empty store closes"); - std::fs::copy(empty.path().join("project_item/primary.wt.idx"), &index) - .expect("primary index is replaced with one that knows nothing"); + #[test] + fn the_data_file_sweep_recovers_every_row_a_torn_index_hides() { + nagoya::block_on(async { + let root = tempfile::tempdir().expect("temporary recovery store"); + let source = root.path().join("source"); + let target = root.path().join("target"); + let config = DiskConfig::new_with_table_name( + source.to_string_lossy().into_owned(), + ProjectItemWorkTable::name_snake_case(), + ProjectItemWorkTable::version(), + ); + let engine = ProjectItemPersistenceEngine::new(config) + .await + .expect("engine"); + let table = ProjectItemWorkTable::load(engine).await.expect("table"); + for (id, project) in [ + ("item-one", "proj-1"), + ("item-two", "proj-1"), + ("item-three", "proj-2"), + ("item-four", "proj-2"), + ("item-five", "proj-3"), + ] { + table.insert(item(id, project)).await.expect("row inserts"); + } + table.close().await.expect("source closes cleanly"); - let report = salvage_item_index(&source, &target) - .await - .expect("salvage succeeds"); + /* + * The index is emptied rather than mangled, which is the shape the + * store this verb was written for actually had: a primary that parses + * and simply does not account for the rows still in `.wt.data`. A + * corrupt index fails to parse and never reaches the sweep at all. + */ + let index = source.join("project_item/primary.wt.idx"); + let empty = tempfile::tempdir().expect("temporary empty store"); + let config = DiskConfig::new_with_table_name( + empty.path().to_string_lossy().into_owned(), + ProjectItemWorkTable::name_snake_case(), + ProjectItemWorkTable::version(), + ); + let engine = ProjectItemPersistenceEngine::new(config) + .await + .expect("empty engine"); + let table = ProjectItemWorkTable::load(engine) + .await + .expect("empty table"); + table.close().await.expect("empty store closes"); + std::fs::copy(empty.path().join("project_item/primary.wt.idx"), &index) + .expect("primary index is replaced with one that knows nothing"); - assert_eq!( - report.rows, 5, - "every row in the data file has to come back, not only the last archive" - ); + let report = salvage_item_index(&source, &target) + .await + .expect("salvage succeeds"); + + assert_eq!( + report.rows, 5, + "every row in the data file has to come back, not only the last archive" + ); + }); } fn task(id: &str, project_id: &str) -> TaskLogRow { @@ -2049,365 +2685,376 @@ mod recovery_tests { } } - #[tokio::test] - async fn an_intact_secondary_index_recovers_every_row_from_a_torn_primary() { - let root = tempfile::tempdir().expect("temporary recovery store"); - let source = root.path().join("source"); - let target = root.path().join("target"); - let config = DiskConfig::new_with_table_name( - source.to_string_lossy().into_owned(), - TaskLogWorkTable::name_snake_case(), - TaskLogWorkTable::version(), - ); - let engine = TaskLogPersistenceEngine::new(config).await.expect("engine"); - let table = TaskLogWorkTable::load(engine).await.expect("table"); - table - .insert(task("log-1", "proj-1")) - .await - .expect("first row"); - table - .insert(task("log-2", "proj-1")) - .await - .expect("second row"); - table - .insert(task("log-3", "proj-2")) - .await - .expect("third row"); - table.close().await.expect("source closes cleanly"); + #[test] + fn an_intact_secondary_index_recovers_every_row_from_a_torn_primary() { + nagoya::block_on(async { + let root = tempfile::tempdir().expect("temporary recovery store"); + let source = root.path().join("source"); + let target = root.path().join("target"); + let config = DiskConfig::new_with_table_name( + source.to_string_lossy().into_owned(), + TaskLogWorkTable::name_snake_case(), + TaskLogWorkTable::version(), + ); + let engine = TaskLogPersistenceEngine::new(config).await.expect("engine"); + let table = TaskLogWorkTable::load(engine).await.expect("table"); + table + .insert(task("log-1", "proj-1")) + .await + .expect("first row"); + table + .insert(task("log-2", "proj-1")) + .await + .expect("second row"); + table + .insert(task("log-3", "proj-2")) + .await + .expect("third row"); + table.close().await.expect("source closes cleanly"); - std::fs::write(source.join("task_log/primary.wt.idx"), b"torn primary") - .expect("primary index is made unreadable"); + std::fs::write(source.join("task_log/primary.wt.idx"), b"torn primary") + .expect("primary index is made unreadable"); - let report = recover_task_log_index(&source, &target) - .await - .expect("secondary-index recovery succeeds"); - assert_eq!( - report, - TaskLogRecoveryReport { - rows: 3, - projects: 2, - } - ); + let report = recover_task_log_index(&source, &target) + .await + .expect("secondary-index recovery succeeds"); + assert_eq!( + report, + TaskLogRecoveryReport { + rows: 3, + projects: 2, + } + ); - let config = DiskConfig::new_with_table_name( - target.to_string_lossy().into_owned(), - TaskLogWorkTable::name_snake_case(), - TaskLogWorkTable::version(), - ); - let engine = TaskLogPersistenceEngine::new(config) - .await - .expect("rebuilt engine"); - let rebuilt = TaskLogWorkTable::load(engine).await.expect("rebuilt table"); - let mut ids: Vec = rebuilt - .select_all() - .execute() - .expect("rebuilt rows") - .into_iter() - .map(|row| row.id) - .collect(); - ids.sort(); - assert_eq!(ids, vec!["log-1", "log-2", "log-3"]); - rebuilt.close().await.expect("rebuilt table closes"); + let config = DiskConfig::new_with_table_name( + target.to_string_lossy().into_owned(), + TaskLogWorkTable::name_snake_case(), + TaskLogWorkTable::version(), + ); + let engine = TaskLogPersistenceEngine::new(config) + .await + .expect("rebuilt engine"); + let rebuilt = TaskLogWorkTable::load(engine).await.expect("rebuilt table"); + let mut ids: Vec = rebuilt + .select_all() + .execute() + .expect("rebuilt rows") + .into_iter() + .map(|row| row.id) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["log-1", "log-2", "log-3"]); + rebuilt.close().await.expect("rebuilt table closes"); + }); } - #[tokio::test] - async fn task_log_recovery_refuses_a_corrupt_row_reached_through_the_secondary_index() { - let root = tempfile::tempdir().expect("temporary recovery store"); - let source = root.path().join("source"); - let target = root.path().join("target"); - let config = DiskConfig::new_with_table_name( - source.to_string_lossy().into_owned(), - TaskLogWorkTable::name_snake_case(), - TaskLogWorkTable::version(), - ); - let engine = TaskLogPersistenceEngine::new(config).await.expect("engine"); - let table = TaskLogWorkTable::load(engine).await.expect("table"); - let id = "log-corrupt".to_string(); - let primary_key = table.insert(task(&id, "proj-1")).await.expect("row"); - let link = table - .0 - .primary_index - .pk_map - .get_value(&primary_key) - .expect("primary link") - .0; - table.close().await.expect("source closes cleanly"); - - let data_path = source.join("task_log/.wt.data"); - let page_id: u32 = link.page_id.into(); - let byte_offset = u64::from(page_id) * PAGE_SIZE as u64 - + GENERAL_HEADER_SIZE as u64 - + u64::from(link.offset); - { - use std::io::{Seek, SeekFrom, Write}; - - let mut file = std::fs::OpenOptions::new() - .write(true) - .open(data_path) - .expect("data file"); - file.seek(SeekFrom::Start(byte_offset)).expect("row offset"); - file.write_all(&vec![0; link.length as usize]) - .expect("corrupt row bytes"); - file.sync_all().expect("corruption reaches disk"); - } - std::fs::write(source.join("task_log/primary.wt.idx"), b"torn primary") - .expect("primary index is made unreadable"); + #[test] + fn task_log_recovery_refuses_a_corrupt_v3_data_page() { + nagoya::block_on(async { + let root = tempfile::tempdir().expect("temporary recovery store"); + let source = root.path().join("source"); + let target = root.path().join("target"); + let config = DiskConfig::new_with_table_name( + source.to_string_lossy().into_owned(), + TaskLogWorkTable::name_snake_case(), + TaskLogWorkTable::version(), + ); + let engine = TaskLogPersistenceEngine::new(config).await.expect("engine"); + let table = TaskLogWorkTable::load(engine).await.expect("table"); + let id = "log-corrupt".to_string(); + let primary_key = table.insert(task(&id, "proj-1")).await.expect("row"); + let link = table + .0 + .primary_index + .pk_map + .get_value(&primary_key) + .expect("primary link") + .0; + table.close().await.expect("source closes cleanly"); + + let data_path = source.join("task_log/.wt.data"); + let page_id: u32 = link.page_id.into(); + let byte_offset = u64::from(page_id) * PAGE_SIZE as u64 + + GENERAL_HEADER_SIZE as u64 + + u64::from(link.offset); + { + use std::io::{Seek, SeekFrom, Write}; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(data_path) + .expect("data file"); + file.seek(SeekFrom::Start(byte_offset)).expect("row offset"); + file.write_all(&vec![0; link.length as usize]) + .expect("corrupt row bytes"); + file.sync_all().expect("corruption reaches disk"); + } + std::fs::write(source.join("task_log/primary.wt.idx"), b"torn primary") + .expect("primary index is made unreadable"); - let error = recover_task_log_index(&source, &target) - .await - .expect_err("recovery must reject a corrupt row reached through project_idx"); - let reason = format!("{error:#}"); - assert!( - reason.contains("project_idx") - && (reason.contains("invalid row") || reason.contains("key does not match")), - "unexpected recovery refusal: {reason}" - ); + let error = recover_task_log_index(&source, &target) + .await + .expect_err("recovery must reject a corrupt row reached through project_idx"); + let reason = format!("{error:#}"); + assert!( + reason.contains("v3 data page checksum"), + "unexpected recovery refusal: {reason}" + ); + }); } - #[tokio::test] - async fn an_intact_primary_recovers_every_message_from_a_torn_secondary() { - let root = tempfile::tempdir().expect("temporary recovery store"); - let source = root.path().join("source"); - let target = root.path().join("target"); - let config = DiskConfig::new_with_table_name( - source.to_string_lossy().into_owned(), - MessageWorkTable::name_snake_case(), - MessageWorkTable::version(), - ); - let engine = MessagePersistenceEngine::new(config).await.expect("engine"); - let table = MessageWorkTable::load(engine).await.expect("table"); - table - .insert(message("msg-1", "proj-1")) - .await - .expect("first row"); - table - .insert(message("msg-2", "proj-1")) - .await - .expect("second row"); - table - .insert(message("msg-3", "proj-2")) - .await - .expect("third row"); - table.close().await.expect("source closes cleanly"); + #[test] + fn an_intact_primary_recovers_every_message_from_a_torn_secondary() { + nagoya::block_on(async { + let root = tempfile::tempdir().expect("temporary recovery store"); + let source = root.path().join("source"); + let target = root.path().join("target"); + let config = DiskConfig::new_with_table_name( + source.to_string_lossy().into_owned(), + MessageWorkTable::name_snake_case(), + MessageWorkTable::version(), + ); + let engine = MessagePersistenceEngine::new(config).await.expect("engine"); + let table = MessageWorkTable::load(engine).await.expect("table"); + table + .insert(message("msg-1", "proj-1")) + .await + .expect("first row"); + table + .insert(message("msg-2", "proj-1")) + .await + .expect("second row"); + table + .insert(message("msg-3", "proj-2")) + .await + .expect("third row"); + table.close().await.expect("source closes cleanly"); - std::fs::write(source.join("message/project_idx.wt.idx"), b"torn secondary") - .expect("secondary index is made unreadable"); + std::fs::write(source.join("message/project_idx.wt.idx"), b"torn secondary") + .expect("secondary index is made unreadable"); - let report = recover_message_index(&source, &target) - .await - .expect("primary-index recovery succeeds"); - assert_eq!( - report, - MessageRecoveryReport { - rows: 3, - projects: 2, - } - ); + let report = recover_message_index(&source, &target) + .await + .expect("primary-index recovery succeeds"); + assert_eq!( + report, + MessageRecoveryReport { + rows: 3, + projects: 2, + } + ); - let config = DiskConfig::new_with_table_name( - target.to_string_lossy().into_owned(), - MessageWorkTable::name_snake_case(), - MessageWorkTable::version(), - ); - let engine = MessagePersistenceEngine::new(config) - .await - .expect("rebuilt engine"); - let rebuilt = MessageWorkTable::load(engine).await.expect("rebuilt table"); - let mut ids: Vec = rebuilt - .select_all() - .execute() - .expect("rebuilt rows") - .into_iter() - .map(|row| row.id) - .collect(); - ids.sort(); - assert_eq!(ids, vec!["msg-1", "msg-2", "msg-3"]); - assert_eq!( - rebuilt - .select_by_project_id("proj-1".into()) + let config = DiskConfig::new_with_table_name( + target.to_string_lossy().into_owned(), + MessageWorkTable::name_snake_case(), + MessageWorkTable::version(), + ); + let engine = MessagePersistenceEngine::new(config) + .await + .expect("rebuilt engine"); + let rebuilt = MessageWorkTable::load(engine).await.expect("rebuilt table"); + let mut ids: Vec = rebuilt + .select_all() .execute() - .expect("rebuilt secondary index") - .len(), - 2 - ); - rebuilt.close().await.expect("rebuilt table closes"); + .expect("rebuilt rows") + .into_iter() + .map(|row| row.id) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["msg-1", "msg-2", "msg-3"]); + assert_eq!( + rebuilt + .select_by_project_id("proj-1".into()) + .execute() + .expect("rebuilt secondary index") + .len(), + 2 + ); + rebuilt.close().await.expect("rebuilt table closes"); + }); } - #[tokio::test] - async fn an_intact_primary_recovers_every_pull_request_from_a_torn_secondary() { - let root = tempfile::tempdir().expect("temporary recovery store"); - let source = root.path().join("source"); - let target = root.path().join("target"); - let config = DiskConfig::new_with_table_name( - source.to_string_lossy().into_owned(), - PullRequestWorkTable::name_snake_case(), - PullRequestWorkTable::version(), - ); - let engine = PullRequestPersistenceEngine::new(config) - .await - .expect("engine"); - let table = PullRequestWorkTable::load(engine).await.expect("table"); - table - .insert(pull_request("pr-1", "proj-1")) - .await - .expect("first row"); - table - .insert(pull_request("pr-2", "proj-1")) - .await - .expect("second row"); - table - .insert(pull_request("pr-3", "proj-2")) - .await - .expect("third row"); - table.close().await.expect("source closes cleanly"); + #[test] + fn an_intact_primary_recovers_every_pull_request_from_a_torn_secondary() { + nagoya::block_on(async { + let root = tempfile::tempdir().expect("temporary recovery store"); + let source = root.path().join("source"); + let target = root.path().join("target"); + let config = DiskConfig::new_with_table_name( + source.to_string_lossy().into_owned(), + PullRequestWorkTable::name_snake_case(), + PullRequestWorkTable::version(), + ); + let engine = PullRequestPersistenceEngine::new(config) + .await + .expect("engine"); + let table = PullRequestWorkTable::load(engine).await.expect("table"); + table + .insert(pull_request("pr-1", "proj-1")) + .await + .expect("first row"); + table + .insert(pull_request("pr-2", "proj-1")) + .await + .expect("second row"); + table + .insert(pull_request("pr-3", "proj-2")) + .await + .expect("third row"); + table.close().await.expect("source closes cleanly"); - std::fs::write( - source.join("pull_request/pr_project_idx.wt.idx"), - b"torn secondary", - ) - .expect("secondary index is made unreadable"); + std::fs::write( + source.join("pull_request/pr_project_idx.wt.idx"), + b"torn secondary", + ) + .expect("secondary index is made unreadable"); - let report = recover_pull_request_index(&source, &target) - .await - .expect("primary-index recovery succeeds"); - assert_eq!( - report, - PullRequestRecoveryReport { - rows: 3, - projects: 2, - } - ); + let report = recover_pull_request_index(&source, &target) + .await + .expect("primary-index recovery succeeds"); + assert_eq!( + report, + PullRequestRecoveryReport { + rows: 3, + projects: 2, + } + ); - let config = DiskConfig::new_with_table_name( - target.to_string_lossy().into_owned(), - PullRequestWorkTable::name_snake_case(), - PullRequestWorkTable::version(), - ); - let engine = PullRequestPersistenceEngine::new(config) - .await - .expect("rebuilt engine"); - let rebuilt = PullRequestWorkTable::load(engine) - .await - .expect("rebuilt table"); - let mut ids: Vec = rebuilt - .select_all() - .execute() - .expect("rebuilt rows") - .into_iter() - .map(|row| row.id) - .collect(); - ids.sort(); - assert_eq!(ids, vec!["pr-1", "pr-2", "pr-3"]); - assert_eq!( - rebuilt - .select_by_project_id("proj-1".into()) + let config = DiskConfig::new_with_table_name( + target.to_string_lossy().into_owned(), + PullRequestWorkTable::name_snake_case(), + PullRequestWorkTable::version(), + ); + let engine = PullRequestPersistenceEngine::new(config) + .await + .expect("rebuilt engine"); + let rebuilt = PullRequestWorkTable::load(engine) + .await + .expect("rebuilt table"); + let mut ids: Vec = rebuilt + .select_all() .execute() - .expect("rebuilt secondary index") - .len(), - 2 - ); - rebuilt.close().await.expect("rebuilt table closes"); + .expect("rebuilt rows") + .into_iter() + .map(|row| row.id) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["pr-1", "pr-2", "pr-3"]); + assert_eq!( + rebuilt + .select_by_project_id("proj-1".into()) + .execute() + .expect("rebuilt secondary index") + .len(), + 2 + ); + rebuilt.close().await.expect("rebuilt table closes"); + }); } - #[tokio::test] - async fn pull_request_salvage_reports_and_omits_a_corrupt_row() { - let root = tempfile::tempdir().expect("temporary recovery store"); - let source = root.path().join("source"); - let strict_target = root.path().join("strict-target"); - let salvage_target = root.path().join("salvage-target"); - let config = DiskConfig::new_with_table_name( - source.to_string_lossy().into_owned(), - PullRequestWorkTable::name_snake_case(), - PullRequestWorkTable::version(), - ); - let engine = PullRequestPersistenceEngine::new(config) - .await - .expect("engine"); - let table = PullRequestWorkTable::load(engine).await.expect("table"); - table - .insert(pull_request("pr-good-1", "proj-1")) - .await - .expect("first row"); - table - .insert(pull_request("pr-corrupt", "proj-1")) - .await - .expect("corrupt row"); - table - .insert(pull_request("pr-good-2", "proj-2")) - .await - .expect("third row"); - table.close().await.expect("source closes cleanly"); - - let table_path = source.join("pull_request"); - let mut primary = as SpaceIndexOps< - String, - >>::primary_from_table_files_path( - table_path.to_string_lossy().into_owned(), - PullRequestWorkTable::version(), - ) - .await - .expect("primary index"); - let primary_index = primary.parse_indexset().await.expect("primary rows"); - let corrupt_link = primary_index - .iter() - .find_map(|(id, link)| (id == "pr-corrupt").then_some(link)) - .expect("corrupt row link"); - drop(primary); - - let data_path = source.join("pull_request/.wt.data"); - let page_id: u32 = corrupt_link.page_id.into(); - let byte_offset = u64::from(page_id) * PAGE_SIZE as u64 - + GENERAL_HEADER_SIZE as u64 - + u64::from(corrupt_link.offset); - { - use std::io::{Seek, SeekFrom, Write}; - - let mut file = std::fs::OpenOptions::new() - .write(true) - .open(data_path) - .expect("data file"); - file.seek(SeekFrom::Start(byte_offset)).expect("row offset"); - file.write_all(&vec![0; corrupt_link.length as usize]) - .expect("corrupt row bytes"); - file.sync_all().expect("corruption reaches disk"); - } - - let _ = recover_pull_request_index(&source, &strict_target) - .await - .expect_err("strict recovery refuses the corrupt row"); - let report = salvage_pull_request_index(&source, &salvage_target) + #[test] + fn pull_request_salvage_reports_and_omits_a_corrupt_row() { + nagoya::block_on(async { + let root = tempfile::tempdir().expect("temporary recovery store"); + let source = root.path().join("source"); + let strict_target = root.path().join("strict-target"); + let salvage_target = root.path().join("salvage-target"); + let config = DiskConfig::new_with_table_name( + source.to_string_lossy().into_owned(), + PullRequestWorkTable::name_snake_case(), + PullRequestWorkTable::version(), + ); + let engine = PullRequestPersistenceEngine::new(config) + .await + .expect("engine"); + let table = PullRequestWorkTable::load(engine).await.expect("table"); + table + .insert(pull_request("pr-good-1", "proj-1")) + .await + .expect("first row"); + table + .insert(pull_request("pr-corrupt", "proj-1")) + .await + .expect("corrupt row"); + table + .insert(pull_request("pr-good-2", "proj-2")) + .await + .expect("third row"); + table.close().await.expect("source closes cleanly"); + + let table_path = source.join("pull_request"); + let mut primary = as SpaceIndexOps>::primary_from_table_files_path( + table_path.to_string_lossy().into_owned(), + PullRequestWorkTable::version(), + ) .await - .expect("salvage keeps valid rows"); - assert_eq!( - report, - PullRequestSalvageReport { - rows: 2, - projects: 2, - skipped: vec!["pr-corrupt".into()], + .expect("primary index"); + let primary_index = primary.parse_indexset().await.expect("primary rows"); + let corrupt_link = primary_index + .iter() + .find_map(|(id, link)| (id == "pr-corrupt").then_some(link)) + .expect("corrupt row link"); + drop(primary); + + let data_path = source.join("pull_request/.wt.data"); + let page_id: u32 = corrupt_link.page_id.into(); + let byte_offset = u64::from(page_id) * PAGE_SIZE as u64 + + GENERAL_HEADER_SIZE as u64 + + u64::from(corrupt_link.offset); + { + use std::io::{Seek, SeekFrom, Write}; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(data_path) + .expect("data file"); + file.seek(SeekFrom::Start(byte_offset)).expect("row offset"); + file.write_all(&vec![0; corrupt_link.length as usize]) + .expect("corrupt row bytes"); + file.sync_all().expect("corruption reaches disk"); } - ); - let config = DiskConfig::new_with_table_name( - salvage_target.to_string_lossy().into_owned(), - PullRequestWorkTable::name_snake_case(), - PullRequestWorkTable::version(), - ); - let engine = PullRequestPersistenceEngine::new(config) - .await - .expect("rebuilt engine"); - let rebuilt = PullRequestWorkTable::load(engine) - .await - .expect("rebuilt table"); - let mut ids: Vec = rebuilt - .select_all() - .execute() - .expect("rebuilt rows") - .into_iter() - .map(|row| row.id) - .collect(); - ids.sort(); - assert_eq!(ids, vec!["pr-good-1", "pr-good-2"]); - rebuilt.close().await.expect("rebuilt table closes"); + let _ = recover_pull_request_index(&source, &strict_target) + .await + .expect_err("strict recovery refuses the corrupt row"); + let report = salvage_pull_request_index(&source, &salvage_target) + .await + .expect("salvage keeps valid rows"); + assert_eq!( + report, + PullRequestSalvageReport { + rows: 2, + projects: 2, + skipped: vec!["pr-corrupt".into()], + } + ); + + let config = DiskConfig::new_with_table_name( + salvage_target.to_string_lossy().into_owned(), + PullRequestWorkTable::name_snake_case(), + PullRequestWorkTable::version(), + ); + let engine = PullRequestPersistenceEngine::new(config) + .await + .expect("rebuilt engine"); + let rebuilt = PullRequestWorkTable::load(engine) + .await + .expect("rebuilt table"); + let mut ids: Vec = rebuilt + .select_all() + .execute() + .expect("rebuilt rows") + .into_iter() + .map(|row| row.id) + .collect(); + ids.sort(); + assert_eq!(ids, vec!["pr-good-1", "pr-good-2"]); + rebuilt.close().await.expect("rebuilt table closes"); + }); } } @@ -2428,7 +3075,7 @@ pub async fn carry_forward( stored: &str, current: &str, ) -> eyre::Result { - tokio::fs::create_dir_all(target).await?; + nagoya::io::create_dir_all(target).await?; let safe = unchanged(stored, current); let mut report = Report::default(); @@ -2447,10 +3094,10 @@ pub async fn carry_forward( * the same loss, one branch over: an unreadable `agent_io_row` * sorts first and would cost `message`, `project` and the rest. */ - match copy_dir(&from, &target.join(&table)).await { + match copy_dir(&from, &target.join(&table)) { Ok(()) => report.copied.push(table), Err(error) => { - let _ = tokio::fs::remove_dir_all(target.join(&table)).await; + let _ = nagoya::io::remove_dir_all(target.join(&table)).await; report .failed .push((table.clone(), format!("could not copy: {error}"))); @@ -2487,7 +3134,7 @@ pub async fn carry_forward( report.migrated.push(table); } Err(error) => { - let _ = tokio::fs::remove_dir_all(target.join(&table)).await; + let _ = nagoya::io::remove_dir_all(target.join(&table)).await; report .failed .push((table.clone(), format!("could not scrub: {error}"))); @@ -2533,7 +3180,7 @@ pub async fn carry_forward( * unreadable in both shapes is lost, and it is counted. */ Err(error) => { - let _ = tokio::fs::remove_dir_all(target.join(&table)).await; + let _ = nagoya::io::remove_dir_all(target.join(&table)).await; match salvage_items(source, target).await { Ok((salvaged, _, unreadable)) if salvaged > 0 => { if unreadable > 0 { @@ -2550,7 +3197,7 @@ pub async fn carry_forward( } salvage => { if let Err(salvage_error) = salvage { - let _ = tokio::fs::remove_dir_all(target.join(&table)).await; + let _ = nagoya::io::remove_dir_all(target.join(&table)).await; report.failed.push(( table.clone(), format!("{error}; salvage also failed: {salvage_error}"), @@ -2572,16 +3219,16 @@ pub async fn carry_forward( } /// Copy a directory, contents and all. -async fn copy_dir(from: &Path, to: &Path) -> eyre::Result<()> { - tokio::fs::create_dir_all(to).await?; - let mut entries = tokio::fs::read_dir(from).await?; - while let Some(entry) = entries.next_entry().await? { +fn copy_dir(from: &Path, to: &Path) -> eyre::Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; let source = entry.path(); let target = to.join(entry.file_name()); - if entry.file_type().await?.is_dir() { - Box::pin(copy_dir(&source, &target)).await?; + if entry.file_type()?.is_dir() { + copy_dir(&source, &target)?; } else { - tokio::fs::copy(&source, &target).await?; + std::fs::copy(&source, &target)?; } } Ok(()) @@ -2738,13 +3385,50 @@ mod scrub_tests { * keeps every row that is shaped like an item — including task-manager * rows and unfamiliar statuses — and drops only what cannot be one. */ - #[tokio::test] - async fn shifted_debris_is_dropped_and_real_rows_survive() { - let dir = std::env::temp_dir().join(format!("wt-migrate-scrub-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("tmp dir"); + #[test] + fn shifted_debris_is_dropped_and_real_rows_survive() { + nagoya::block_on(async { + let dir = std::env::temp_dir().join(format!("wt-migrate-scrub-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmp dir"); + + { + let config = DiskConfig::new_with_table_name( + dir.to_string_lossy().into_owned(), + ProjectItemWorkTable::name_snake_case(), + ProjectItemWorkTable::version(), + ); + let engine = ProjectItemPersistenceEngine::new(config) + .await + .expect("engine"); + let table = ProjectItemWorkTable::load(engine).await.expect("table"); + + table + .insert(item("item-1", "proj-846b")) + .await + .expect("good row"); + table + .insert(item("item-2", "home-task-manager")) + .await + .expect("tm row"); + let mut odd = item("item-3", "proj-846b"); + odd.status = "someday-maybe".into(); + table.insert(odd).await.expect("odd status row"); + // The real debris shapes, verbatim from the incident. + table + .insert(item("proj-6cf80cb0", "Recover the item list")) + .await + .expect("shifted row"); + table + .insert(item("ment)", "item-03fd09c6")) + .await + .expect("worse row"); + table.wait_for_ops().await.expect("items persist"); + } + + let dropped = scrub_items(&dir).await.expect("scrub"); + assert_eq!(dropped, 2, "exactly the two debris rows go"); - { let config = DiskConfig::new_with_table_name( dir.to_string_lossy().into_owned(), ProjectItemWorkTable::name_snake_case(), @@ -2754,52 +3438,17 @@ mod scrub_tests { .await .expect("engine"); let table = ProjectItemWorkTable::load(engine).await.expect("table"); - - table - .insert(item("item-1", "proj-846b")) - .await - .expect("good row"); - table - .insert(item("item-2", "home-task-manager")) - .await - .expect("tm row"); - let mut odd = item("item-3", "proj-846b"); - odd.status = "someday-maybe".into(); - table.insert(odd).await.expect("odd status row"); - // The real debris shapes, verbatim from the incident. - table - .insert(item("proj-6cf80cb0", "Recover the item list")) - .await - .expect("shifted row"); - table - .insert(item("ment)", "item-03fd09c6")) - .await - .expect("worse row"); - table.wait_for_ops().await.expect("items persist"); - } - - let dropped = scrub_items(&dir).await.expect("scrub"); - assert_eq!(dropped, 2, "exactly the two debris rows go"); - - let config = DiskConfig::new_with_table_name( - dir.to_string_lossy().into_owned(), - ProjectItemWorkTable::name_snake_case(), - ProjectItemWorkTable::version(), - ); - let engine = ProjectItemPersistenceEngine::new(config) - .await - .expect("engine"); - let table = ProjectItemWorkTable::load(engine).await.expect("table"); - let mut kept: Vec = table - .select_all() - .execute() - .expect("rows") - .into_iter() - .map(|row| row.id) - .collect(); - kept.sort(); - assert_eq!(kept, vec!["item-1", "item-2", "item-3"]); - let _ = std::fs::remove_dir_all(&dir); + let mut kept: Vec = table + .select_all() + .execute() + .expect("rows") + .into_iter() + .map(|row| row.id) + .collect(); + kept.sort(); + assert_eq!(kept, vec!["item-1", "item-2", "item-3"]); + let _ = std::fs::remove_dir_all(&dir); + }); } /// `db`, `db.next-` and `db.pre-migration-` are three stores. @@ -2845,4 +3494,151 @@ mod scrub_tests { super::lock_store(&store).expect("the OS releases the lock with its owner"); let _ = std::fs::remove_dir_all(&dir); } + + /// A scratch profile directory, removed by the caller. + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "wt-migrate-{name}-{}-{}", + std::process::id(), + uuid::Uuid::now_v7() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("the scratch profile is created"); + dir + } + + /// A directory standing in for a store, holding one identifying file. + fn tree_with(path: &std::path::Path, contents: &str) { + std::fs::create_dir_all(path).expect("the tree is created"); + std::fs::write(path.join("which"), contents).expect("the tree is identified"); + } + + fn which(path: &std::path::Path) -> String { + std::fs::read_to_string(path.join("which")).unwrap_or_else(|_| "".into()) + } + + /// The shape a failed rollback leaves is the shape a completed promotion + /// leaves, and the old fast path could not tell them apart. It took the v2 + /// data sitting at `store` for the promoted v3 tree and deleted the backup, + /// which is the owner's only other copy. + #[test] + fn a_rolled_back_promotion_is_not_mistaken_for_a_completed_one() { + let dir = scratch("rollback-shape"); + let store = dir.join("db"); + let paths = PageMigrationPaths::for_store(&store); + + // Exactly what the rollback branch leaves behind: v2 restored to the + // live path, the backup still there, no stage, marker unmoved. + tree_with(&store, "v2"); + tree_with(&paths.backup, "v2"); + write_migration_phase(&paths.state, "source-preserved").expect("the marker is written"); + + let error = finish_page_format_promotion(&store, &paths) + .expect_err("an unpromoted store must not have its backup deleted"); + assert!( + error.to_string().contains("not the promoted v3 store"), + "the refusal must say why: {error}" + ); + assert!(paths.backup.is_dir(), "the only v2 copy must survive"); + assert_eq!(which(&store), "v2"); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The same shape, but the tree really was promoted. The marker travels + /// inside it, so this one is safe to finish and the backup is reclaimed. + #[test] + fn a_promoted_store_finishes_and_releases_its_backup() { + let dir = scratch("promoted-shape"); + let store = dir.join("db"); + let paths = PageMigrationPaths::for_store(&store); + + tree_with(&store, "v3"); + write_promotion_marker(&store).expect("the promoted tree is marked"); + tree_with(&paths.backup, "v2"); + write_migration_phase(&paths.state, "source-preserved").expect("the marker is written"); + + finish_page_format_promotion(&store, &paths).expect("a promoted store completes"); + assert!(!paths.backup.exists(), "the backup is reclaimed"); + assert_eq!(which(&store), "v3"); + assert_eq!( + std::fs::read_to_string(&paths.state) + .expect("the phase is readable") + .trim(), + "complete" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Dying between `rename(store -> backup)` and the marker's own rename left + /// no marker, `store` absent and `backup` holding the data. That read as + /// "nothing to do", and `migrate_page_format_v2` then refused forever + /// because the backup existed: an unbootable profile with no way out. + #[test] + fn a_preserved_source_with_no_marker_is_recovered_rather_than_ignored() { + let dir = scratch("no-marker"); + let store = dir.join("db"); + let paths = PageMigrationPaths::for_store(&store); + + tree_with(&paths.backup, "v2"); + tree_with(&paths.stage, "v3"); + assert!(!paths.state.exists(), "the marker never landed"); + + let resumed = resume_page_format_migration(&store) + .expect("an interrupted promotion is recoverable without its marker"); + assert!(resumed, "the recovery is reported as work done"); + assert!(store.is_dir(), "the profile boots again"); + assert_eq!(which(&store), "v3", "the staged v3 tree is promoted"); + assert!(is_promoted(&store), "and says so durably"); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// Every store migrated before the sentinel existed carries `complete` and + /// no sentinel, so requiring one there refuses to open the owner's data. + /// + /// This is not hypothetical: it panicked the app on boot against the QA + /// profile fixture, which is exactly such a store, and the whole local gate + /// was green at the time. `complete` is written only once the v3 tree is + /// live, so the phase is already proof and needs no corroboration. + #[test] + fn a_store_completed_before_the_sentinel_still_opens() { + let dir = scratch("legacy-complete"); + let store = dir.join("db"); + let paths = PageMigrationPaths::for_store(&store); + + tree_with(&store, "v3"); + assert!( + !is_promoted(&store), + "a pre-sentinel store has no such file" + ); + write_migration_phase(&paths.state, "complete").expect("the marker is written"); + + assert!( + resume_page_format_migration(&store) + .expect("a store completed before the sentinel must still open"), + "the completed migration is reported as settled" + ); + assert_eq!(which(&store), "v3", "the owner's data is untouched"); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// An ordinary store that predates this marker entirely must be left alone: + /// no marker file, nothing in flight, nothing to recover. + #[test] + fn a_store_with_no_migration_in_flight_is_untouched() { + let dir = scratch("no-migration"); + let store = dir.join("db"); + tree_with(&store, "v3"); + + assert!( + !resume_page_format_migration(&store).expect("a settled store resumes cleanly"), + "there is no migration to resume" + ); + assert_eq!(which(&store), "v3"); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/wt-migrate/src/main.rs b/crates/wt-migrate/src/main.rs index 18dc63550..2a1a66bc3 100644 --- a/crates/wt-migrate/src/main.rs +++ b/crates/wt-migrate/src/main.rs @@ -17,6 +17,47 @@ use std::process::ExitCode; fn main() -> ExitCode { let mut args: Vec = std::env::args().skip(1).collect(); + if args.first().map(String::as_str) == Some("migrate-v2-store") { + args.remove(0); + let [store, reader] = args.as_slice() else { + eprintln!("usage: wt-migrate migrate-v2-store "); + return ExitCode::from(2); + }; + let store = PathBuf::from(store); + let reader = PathBuf::from(reader); + let _lock = match wt_migrate::lock_store(&store) { + Ok(lock) => lock, + Err(message) => { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + }; + match wt_migrate::resume_page_format_migration(&store) { + Ok(true) => { + println!("v3 migration already complete; skipped"); + return ExitCode::SUCCESS; + } + Ok(false) => {} + Err(error) => { + eprintln!("could not resume v3 migration: {error:#}"); + return ExitCode::FAILURE; + } + } + return match wt_migrate::migrate_page_format_v2(&store, &reader) { + Ok(report) => { + for table in report.tables { + println!("verified {}: {} row(s)", table.table, table.rows); + } + println!("v3 promotion committed; displaced v2 staging removed"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("v2 migration failed: {error:#}"); + ExitCode::FAILURE + } + }; + } + if args.first().map(String::as_str) == Some("merge-message-window") { args.remove(0); let [source, target, project, after, before] = args.as_slice() else { @@ -41,13 +82,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::merge_message_window( + return match nagoya::block_on(wt_migrate::merge_message_window( &source, &target, project, after, before, )) { Ok(report) => { @@ -68,7 +103,7 @@ fn main() -> ExitCode { args.remove(0); let [target, project, agent] = args.as_slice() else { eprintln!( - "usage: wt-migrate clear-fresh-session " + "usage: wt-migrate clear-fresh-session " ); return ExitCode::from(2); }; @@ -80,13 +115,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::clear_fresh_session(&target, project, agent)) { + return match nagoya::block_on(wt_migrate::clear_fresh_session(&target, project, agent)) { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("could not clear the pending reset: {error:#}"); @@ -113,13 +142,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::restore_provider_session_forced( + return match nagoya::block_on(wt_migrate::restore_provider_session_forced( &target, project, agent, session, force, )) { Ok(()) => { @@ -166,13 +189,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::salvage_pull_request_index(&source, &target)) { + return match nagoya::block_on(wt_migrate::salvage_pull_request_index(&source, &target)) { Ok(report) => { println!( "salvaged {} pull-request row(s) across {} project key(s) into {}; skipped {} corrupt row(s): {}", @@ -229,19 +246,7 @@ fn main() -> ExitCode { return ExitCode::from(2); } }; - let runtime = match tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - { - Ok(runtime) => runtime, - Err(error) => { - eprintln!("could not start a runtime: {error}"); - return ExitCode::FAILURE; - } - }; - return match runtime.block_on(wt_migrate::restore_items_from_json(&target, &json)) { + return match nagoya::block_on(wt_migrate::restore_items_from_json(&target, &json)) { Ok((inserted, skipped)) => { println!("restored {inserted} item(s), {skipped} already present"); ExitCode::SUCCESS @@ -278,19 +283,7 @@ fn main() -> ExitCode { eprintln!("could not create {}: {error}", target.display()); return ExitCode::FAILURE; } - let runtime = match tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - { - Ok(runtime) => runtime, - Err(error) => { - eprintln!("could not start a runtime: {error}"); - return ExitCode::FAILURE; - } - }; - return match runtime.block_on(wt_migrate::salvage_item_index(&source, &target)) { + return match nagoya::block_on(wt_migrate::salvage_item_index(&source, &target)) { Ok(report) => { println!( "recovered {} item row(s) across {} project(s) into {}", @@ -350,13 +343,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::recover_pull_request_index(&source, &target)) { + return match nagoya::block_on(wt_migrate::recover_pull_request_index(&source, &target)) { Ok(report) => { println!( "recovered {} pull-request row(s) across {} project key(s) into {}", @@ -406,13 +393,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::recover_message_index(&source, &target)) { + return match nagoya::block_on(wt_migrate::recover_message_index(&source, &target)) { Ok(report) => { println!( "recovered {} message row(s) across {} project key(s) into {}", @@ -468,13 +449,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::recover_task_log_index(&source, &target)) { + return match nagoya::block_on(wt_migrate::recover_task_log_index(&source, &target)) { Ok(report) => { println!( "recovered {} task-log row(s) across {} project key(s) into {}", @@ -546,13 +521,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::salvage_items(&source, &target)) { + return match nagoya::block_on(wt_migrate::salvage_items(&source, &target)) { Ok((salvaged, skipped, unreadable)) => { println!( "salvaged {salvaged} row(s), {skipped} already present, {unreadable} unreadable in both shapes" @@ -605,13 +574,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::rebuild_store(&source, &target)) { + return match nagoya::block_on(wt_migrate::rebuild_store(&source, &target)) { Ok(report) => { for (table, rows) in report { println!("rebuilt {table}: {rows} row(s)"); @@ -675,13 +638,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - .expect("runtime"); - return match runtime.block_on(wt_migrate::rebuild_task_log(&source, &target)) { + return match nagoya::block_on(wt_migrate::rebuild_task_log(&source, &target)) { Ok((rebuilt, dropped)) => { println!("rebuilt {rebuilt} row(s), dropped {dropped} debris row(s)"); ExitCode::SUCCESS @@ -760,20 +717,7 @@ fn main() -> ExitCode { } }; - let runtime = match tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_io() - .enable_time() - .build() - { - Ok(runtime) => runtime, - Err(error) => { - eprintln!("could not start a runtime: {error}"); - return ExitCode::FAILURE; - } - }; - - match runtime.block_on(wt_migrate::carry_forward( + match nagoya::block_on(wt_migrate::carry_forward( &source, &target, &stored, diff --git a/crates/wt-migrate/v2-reader/Cargo.toml b/crates/wt-migrate/v2-reader/Cargo.toml new file mode 100644 index 000000000..72213283c --- /dev/null +++ b/crates/wt-migrate/v2-reader/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "agencyzero-wt-v2-reader" +description = "Private reader for AgencyZero's final WorkTable v2 page format" +version = "0.1.0" +edition = "2024" +publish = false + +# Resolve the v2 reader independently from the parent WorkTable 1.9 graph. +[workspace] + +[dependencies] +# This private compatibility boundary exists to read one historical on-disk +# format. It must not drift to WorkTable 1.9, whose reader correctly refuses +# v2 pages. beta19 codegen is compatible with beta18.1 DSL; the beta19 DSL +# added FxHash before beta19 codegen had matching generator arms. +worktable = "=1.0.0-beta.19" +worktable_dsl = "=1.0.0-beta.18.1" +eyre = "0.6" +rkyv = { version = "0.8.9", features = ["uuid-1"] } +derive_more = { version = "2.0.1", features = ["from", "error", "display", "debug", "into"] } +futures = "0.3" +uuid = "1" +chrono = "0.4" +sha2 = "0.10" +# The final v2 generated load path uses Tokio's concrete file/runtime APIs. +# This dependency is isolated here; the beta runtime and migration coordinator +# use Nagoya. +tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread", "sync", "time"] } diff --git a/crates/wt-migrate/v2-reader/build.rs b/crates/wt-migrate/v2-reader/build.rs new file mode 100644 index 000000000..228ba6760 --- /dev/null +++ b/crates/wt-migrate/v2-reader/build.rs @@ -0,0 +1,101 @@ +//! Copy the GUI's schema into `OUT_DIR` with every `queries` block removed. +//! +//! The sidecar must see the same *columns* as the GUI, because those decide the +//! on-disk layout it reads. It has no use for the queries: it calls only +//! `name_snake_case`, `version`, `load` and `select_all`. +//! +//! Sharing the files verbatim coupled the two anyway, and the coupling broke +//! CI. This crate 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. When the GUI adopted 1.10's `update_in_place`, beta.18.1's +//! parser reached a token that did not exist when it was written: +//! +//! error: Unexpected token `update_in_place`; +//! expected one of `update`, `delete`, `in_place` +//! +//! A `#[cfg]` on the block does not help, because the macro parses its own body +//! and rejects attributes there. Stripping the block before it is ever parsed +//! does, and it holds for whatever query syntax 1.11 introduces next. + +use std::path::{Path, PathBuf}; + +const SCHEMA: &str = "../../../apps/gui/src/db/schema"; + +fn main() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(SCHEMA); + let out = + PathBuf::from(std::env::var_os("OUT_DIR").expect("cargo sets OUT_DIR")).join("schema"); + std::fs::create_dir_all(&out).expect("the schema output directory is created"); + + println!("cargo:rerun-if-changed={}", source.display()); + for entry in std::fs::read_dir(&source).expect("the GUI schema directory is readable") { + let entry = entry.expect("the schema entry is readable"); + let path = entry.path(); + if path.extension().is_none_or(|extension| extension != "rs") { + continue; + } + println!("cargo:rerun-if-changed={}", path.display()); + let text = std::fs::read_to_string(&path).expect("the schema file is readable"); + let name = path.file_name().expect("the schema file has a name"); + let body = demote_inner_docs(&strip_queries(&text)); + std::fs::write(out.join(name), body).expect("the stripped schema is written"); + } +} + +/// Turn each `//!` into `//`, because these files are `include!`d into a module +/// rather than being one, and an inner doc comment is only legal at the top of +/// the thing it documents. The text is worth keeping: it is where the schema +/// explains why its columns are what they are, which is the part this crate +/// most depends on. +fn demote_inner_docs(text: &str) -> String { + text.lines() + .map(|line| match line.trim_start().strip_prefix("//!") { + Some(rest) => { + let indent = &line[..line.len() - line.trim_start().len()]; + format!("{indent}//{rest}") + } + None => line.to_string(), + }) + .collect::>() + .join("\n") +} + +/// Remove the `queries: { ... }` block from one `worktable!` invocation. +/// +/// Brace-counting rather than a parser: the input is a macro body this crate +/// cannot parse by definition, and the block is well formed by construction +/// because the GUI compiles it. A file without one is returned unchanged. +fn strip_queries(text: &str) -> String { + let Some(start) = text.find("queries:") else { + return text.to_string(); + }; + let bytes = text.as_bytes(); + let Some(open) = text[start..].find('{').map(|offset| start + offset) else { + return text.to_string(); + }; + let mut depth = 0usize; + let mut end = None; + for (index, byte) in bytes.iter().enumerate().skip(open) { + match byte { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + end = Some(index + 1); + break; + } + } + _ => {} + } + } + let Some(mut end) = end else { + return text.to_string(); + }; + // Take the separating comma with it, so what is left still parses. + if bytes.get(end) == Some(&b',') { + end += 1; + } + let mut stripped = text[..start].to_string(); + stripped.push_str(&text[end..]); + stripped +} diff --git a/crates/wt-migrate/v2-reader/src/lib.rs b/crates/wt-migrate/v2-reader/src/lib.rs new file mode 100644 index 000000000..6dbf252ce --- /dev/null +++ b/crates/wt-migrate/v2-reader/src/lib.rs @@ -0,0 +1,163 @@ +//! Read-only bridge from AgencyZero's final WorkTable v2 store. +//! +//! Only archived rows cross this crate boundary. The WorkTable 1.9 migration +//! coordinator decodes them against the current schema and creates v3 pages. + +use std::path::Path; + +use sha2::{Digest as _, Sha256}; +use worktable::PersistedWorkTable; +use worktable::persistence::ReadOnlyPersistenceEngine; +use worktable::prelude::{DiskConfig, SelectQueryExecutor}; + +// Generated by `build.rs` from the GUI's schema with the `queries` blocks +// stripped: this crate is pinned to the v2-era DSL, which cannot parse the +// query syntax the GUI has since adopted. Columns, and so the on-disk layout, +// are the GUI's verbatim. +#[path = "."] +mod schema { + macro_rules! table { + ($module:ident) => { + pub mod $module { + include!(concat!( + env!("OUT_DIR"), + "/schema/", + stringify!($module), + ".rs" + )); + } + }; + } + + table!(agent_io); + table!(approval_rule); + table!(item_completion); + table!(kv); + table!(message); + table!(message_chunk); + table!(project); + table!(project_item); + table!(pull_request); + table!(question); + table!(question_reply); + table!(reply_checkpoint); + table!(study_event); + table!(task_log); + table!(usage_cache); + table!(usage_ledger); + table!(usage_session); +} + +/// One table's version-neutral row archives. +pub struct TableExport { + pub name: &'static str, + pub rows: Vec>, + pub digest: [u8; 32], +} + +/// Every AgencyZero table, including empty and not-yet-created tables. +pub struct StoreExport { + pub tables: Vec, +} + +fn digest_rows(rows: &mut [Vec]) -> [u8; 32] { + rows.sort_unstable(); + let mut digest = Sha256::new(); + for row in rows { + digest.update((row.len() as u64).to_le_bytes()); + digest.update(row); + } + digest.finalize().into() +} + +macro_rules! export_table { + ($source:expr, $module:ident, $Table:ident) => {{ + use schema::$module::$Table; + + let name = $Table::name_snake_case(); + let table_path = $source.join(name); + let mut rows = Vec::new(); + if table_path.is_dir() { + let config = DiskConfig::new_with_table_name( + $source.to_string_lossy().into_owned(), + name, + $Table::version(), + ); + let engine = ReadOnlyPersistenceEngine::create(config) + .await + .map_err(|error| eyre::eyre!("v2 {name} would not open: {error}"))?; + let table = $Table::load(engine) + .await + .map_err(|error| eyre::eyre!("v2 {name} would not load: {error}"))?; + for row in table + .select_all() + .execute() + .map_err(|error| eyre::eyre!("v2 {name} would not scan: {error}"))? + { + rows.push( + rkyv::to_bytes::(&row) + .map_err(|error| eyre::eyre!("v2 {name} row would not archive: {error}"))? + .to_vec(), + ); + } + } + let digest = digest_rows(&mut rows); + TableExport { name, rows, digest } + }}; +} + +/// Read all 17 tables through the final v2 engine without modifying them. +/// +/// # Errors +/// A present table cannot be opened or scanned against AgencyZero's v2 row +/// schema, or a row cannot be archived. +pub async fn export(source: &Path) -> eyre::Result { + Ok(StoreExport { + tables: vec![ + export_table!(source, kv, KvWorkTable), + export_table!(source, project, ProjectWorkTable), + export_table!(source, project_item, ProjectItemWorkTable), + export_table!(source, item_completion, ItemCompletionWorkTable), + export_table!(source, message, MessageWorkTable), + export_table!(source, message_chunk, MessageChunkWorkTable), + export_table!(source, task_log, TaskLogWorkTable), + export_table!(source, agent_io, AgentIoRowWorkTable), + export_table!(source, usage_ledger, UsageLedgerWorkTable), + export_table!(source, usage_cache, UsageCacheWorkTable), + export_table!(source, usage_session, UsageSessionWorkTable), + export_table!(source, approval_rule, ApprovalRuleWorkTable), + export_table!(source, pull_request, PullRequestWorkTable), + export_table!(source, question, QuestionWorkTable), + export_table!(source, question_reply, QuestionReplyWorkTable), + export_table!(source, reply_checkpoint, ReplyCheckpointWorkTable), + export_table!(source, study_event, StudyEventWorkTable), + ], + }) +} + +/// Write a neutral, checksummed archive set for the WorkTable 1.9 importer. +/// +/// # Errors +/// The destination already exists, the source cannot be exported, or an +/// archive file cannot be written and synced. +pub async fn export_to(source: &Path, destination: &Path) -> eyre::Result<()> { + use std::io::Write as _; + + std::fs::create_dir(destination)?; + let exported = export(source).await?; + for table in exported.tables { + let path = destination.join(format!("{}.rows", table.name)); + let mut output = std::io::BufWriter::new(std::fs::File::create(path)?); + output.write_all(b"AZWT2ROWS\0")?; + output.write_all(&(table.rows.len() as u64).to_le_bytes())?; + output.write_all(&table.digest)?; + for row in table.rows { + output.write_all(&(row.len() as u64).to_le_bytes())?; + output.write_all(&row)?; + } + output.flush()?; + output.get_ref().sync_all()?; + } + std::fs::File::open(destination)?.sync_all()?; + Ok(()) +} diff --git a/crates/wt-migrate/v2-reader/src/main.rs b/crates/wt-migrate/v2-reader/src/main.rs new file mode 100644 index 000000000..e78dd4537 --- /dev/null +++ b/crates/wt-migrate/v2-reader/src/main.rs @@ -0,0 +1,14 @@ +use std::path::Path; + +#[tokio::main] +async fn main() -> eyre::Result<()> { + let mut args = std::env::args_os().skip(1); + let source = args.next().ok_or_else(|| eyre::eyre!("missing source"))?; + let destination = args + .next() + .ok_or_else(|| eyre::eyre!("missing destination"))?; + if args.next().is_some() { + eyre::bail!("usage: agencyzero-wt-v2-reader "); + } + agencyzero_wt_v2_reader::export_to(Path::new(&source), Path::new(&destination)).await +} diff --git a/docs/QA-button-audit-runbook.md b/docs/QA-button-audit-runbook.md index 846ff81b9..e10ae387b 100644 --- a/docs/QA-button-audit-runbook.md +++ b/docs/QA-button-audit-runbook.md @@ -151,7 +151,7 @@ its claimed failure. Never activate these unattended: -- a native dialog the harness cannot close; +- a native OS file dialog (see below); - a control that opens a browser, URL or another application; - Application Restart or Restart AgencyProxy, whose success can terminate the audit's cleanup and continuation path; @@ -162,6 +162,13 @@ List each one in `ps-qa.ron` under `manual_controls`. `inventory` counts them without activating them; `cover` also prints the named manual worklist. They stay outside automated pass/fail totals and are verified manually per release. +### Native OS file dialogs + +See [`debugging.md`](debugging.md#native-os-file-dialogs). The short form for +this harness: do not activate `manual_controls` that open a system file +panel; type into the in-app path field and assert the in-app Remove control. +An owner pick is evidence for the picker, not for that typed-path check. + ### Rare local authenticated-Send check Run this on a release candidate before its delivery push, not in CI and not on diff --git a/docs/debugging.md b/docs/debugging.md index d3f25c391..0e82844e8 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -69,6 +69,49 @@ Use a tight loop while repairing a bug: 3. Reproduce once and inspect the new log/control evidence. 4. Repeat. Run the full delivery gate once, before delivery—not after every edit. +## Native OS file dialogs + +Blitz control and `ps-qa` see the in-app semantic tree. They do not see a +macOS open/save panel. Several live buttons open one: `Add dir`, `Choose +folder`, `Attach files`, Settings `Choose…`, backup pickers. The labels live +in `ps-qa.ron` under `manual_controls` so inventory can count them without +activating them. Re-derive that list with +`grep -n '\.dialog()' apps/gui/src/*.rs` if the count changes. + +That is not a defect in those buttons. It is a process boundary. The owner +can finish the panel; an agent or unattended harness cannot, and the session +then looks wedged. + +### How to tell you hit one + +- The last click was one of those labels and nothing in the tree changed. +- `ps-qa find` still shows the same window; there is no new Cancel/Open node. +- The owner sees a system file panel in front of AgencyZero. + +Do not click around hoping a semantic Cancel exists. Ask the owner to +dismiss or complete the panel. + +### How to debug a picker flow + +1. Confirm which control opens the panel (`grep dialog apps/gui/src`). +2. Ask the owner to click it and select (or cancel). +3. Inspect the tree afterwards: the in-app row, chip, or path field is the + evidence. `ps-qa find` on the chosen path or the in-app Remove control. +4. Logs: `choose_project_directory` / `choose_attachments` IPC around the + pick, then `add_dir` if a directory was attached. + +The path the picker returns is whatever the OS resolved. The typed-path +field stores what was typed. Those can differ. A successful owner pick does +not prove the typed-path check, and a passing typed-path check does not +prove the picker. + +### What the automated suite covers instead + +Directory add/remove checks type into the in-app path field and assert the +in-app Remove control on that row. They never click the picker. That is +why `Add dir` is both a typed-path entry point and a listed manual picker +control: one label, two mechanisms. + ## Old Tauri: Wry/WebKit Run the isolated Dev identity directly from source: diff --git a/docs/driving-the-app.md b/docs/driving-the-app.md index 27670bf69..00e95777d 100644 --- a/docs/driving-the-app.md +++ b/docs/driving-the-app.md @@ -144,6 +144,54 @@ restart angel re-executing the binary after a rebuild (see `$TMPDIR/tauri-blitz-agent/.json`; ps-qa validates its PID during discovery and can pin a known instance with `--descriptor`. +### The descriptor is a socket you can talk to, not just a file ps-qa reads + +This is the capability agents keep missing, and missing it costs hours. The +`address` field in that descriptor is a live unix socket speaking **MCP over a +length-prefixed frame**, and anything ps-qa can do you can do directly, plus +whatever ps-qa has no subcommand for. Read the tree, click a control, capture a +node's pixels, quit the app - without a ps-qa release in between. + +Do not infer what the running app is doing by reading source and guessing. Ask +it. + +Framing, which is the only part that is not obvious: 4-byte big-endian length, +then a **1-byte tag** (`0x00`), then the JSON. Newline-delimited JSON gets you +`frame size too big`, which reads like a protocol mismatch and is really a +missing header. + +```python +import socket, json, struct +sock = json.load(open(descriptor_path))["address"].removeprefix("unix://") +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect(sock) +def send(o): + b = json.dumps(o).encode(); s.sendall(struct.pack(">I", len(b) + 1) + b"\x00" + b) +def recv(): + h = b"" + while len(h) < 4: h += s.recv(4 - len(h)) + n = struct.unpack(">I", h)[0]; buf = b"" + while len(buf) < n: buf += s.recv(n - len(buf)) + return json.loads(buf[1:]) # drop the tag byte + +send({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": 1, "clientInfo": {"name": "probe", "version": "1"}}}) +recv() +send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) +print(recv()) # blitz.agent.control, blitz.diagnostics +``` + +`blitz.agent.control` takes one of `act, click, focus, hover, input, inspect, +key, pointer, wheel, quit, relaunch`. `blitz.diagnostics` captures pixels and +metrics. Read each tool's `inputSchema` from `tools/list` rather than guessing +argument names; the schemas carry the reasoning, including why `focus` exists +separately from `click` (focusing a delete button by clicking it performs the +action before the key under test is delivered). + +**There is no JS eval.** The socket exposes the semantic tree and real input, +not the app's internals. A question like "what did this Tauri command return" +is answered by the app log or a unit test, not here. For `execute`/element +queries you need the separate debug driver (`TAURI_BLITZ_DRIVER`, below). + For a disposable QA or rescue process, pass `--blitz-control`. Running the binary directly is also useful because it is the only way to see `log-phase-times` output, which goes to stdout and is discarded by a Finder diff --git a/docs/store-recovery.md b/docs/store-recovery.md index b4c3113a4..c3f8dab90 100644 --- a/docs/store-recovery.md +++ b/docs/store-recovery.md @@ -4,6 +4,37 @@ What protects the WorkTable store, what to do when a launch fails anyway, and how the pieces earned their existence on 2026-08-01, when a botched migration plus a second writer turned every launch into a silent bus error. +## The WorkTable 1.9 page-format boundary + +WorkTable 1.9 and DataBucket 0.7 write page format v3. Existing AgencyZero +stores use v2. The new reader deliberately refuses those pages; this boundary +is independent of AgencyZero's `SCHEMA_FINGERPRINT`, so an unchanged table +schema does not make the files compatible. + +The first WorkTable 1.9 launch converts the profile automatically, before any +application table opens. Stable and Experimental run the same code; only the +resolved store path differs. A bundled private reader, resolved independently +against the final WorkTable v2 release, scans all 17 tables read-only and emits +checksummed row archives. The current `wt-migrate` code decodes those archives +through the current AgencyZero schema and writes a separate v3 staging store. + +Every staged table is drained, cold-opened under WorkTable's strict checks, and +compared by row count and full-row digest. That proves every primary-keyed row +and field survived, while strict open proves each persisted index agrees. Only +then is the original directory renamed to temporary `db.v2-preserved` and +staging renamed to `db`. Once the v3 promotion marker is durably committed, +the displaced v2 directory is deleted. A failed promotion restores or retains +v2; a successful production migration does not leave a stale database copy. + +Promotion has a durable `db.v3-migration-state` phase marker. A crash during +export/import discards only derived staging and retries from the untouched v2 +source. A crash between the two same-filesystem renames resumes promotion on +the next launch. `complete` plus a v3 live store makes every later launch skip +the converter and removes a temporary v2 directory left by a crash after the +commit. Any failure stops startup instead of opening partial v3 data; +`AZ_NO_DB_MIGRATION=1` remains the explicit way to leave v2 untouched and run +that session on scratch. + ## The engine bug at the bottom of it The August 4 message failure isolated a second loaded-index defect: diff --git a/ps-qa.ron b/ps-qa.ron index e5c7b416b..24d333cf9 100644 --- a/ps-qa.ron +++ b/ps-qa.ron @@ -127,6 +127,7 @@ AppProfile( // disconnected-agent gate; an owner launches and verifies every entry // point into a real provider turn locally. (label: "Send", command: "authenticated provider turn (local manual)"), + (label: "Review project items and mark proposed deletions", command: "authenticated cleanup provider turn (local manual)"), (label: "Run ", command: "authenticated item run (local manual)"), (label: "Reply to the question for ", command: "authenticated item reply (local manual)"), (label: "Refresh provider usage", command: "authenticated provider usage (local manual)"), diff --git a/scripts/local-delivery.sh b/scripts/local-delivery.sh index 0282ff5f5..e204a44ff 100755 --- a/scripts/local-delivery.sh +++ b/scripts/local-delivery.sh @@ -199,6 +199,11 @@ if [ "$mode" != "quick" ]; then ) fi +if [ "$mode" != "verify" ]; then + echo "==> WorkTable v2 migration reader" + "$repo_root/scripts/stage-wt-v2-reader-sidecar.sh" +fi + case "$mode" in verify) echo "==> verified" @@ -236,6 +241,8 @@ case "$mode" in # crash and cuts off the store mid-write. Ask it to quit first. quit_running_bundle "$bundle" cp "$repo_root/target/release/az-gui" "$bundle/Contents/MacOS/az-gui" + cp "$repo_root/apps/gui/binaries/agencyzero-wt-v2-reader-$rust_target" \ + "$bundle/Contents/MacOS/agencyzero-wt-v2-reader" # Carry the version across too. # # `Info.plist` is written by the bundler, which `quick` does not run, so the diff --git a/scripts/qa-full-local.sh b/scripts/qa-full-local.sh index 92332ff95..056daa673 100755 --- a/scripts/qa-full-local.sh +++ b/scripts/qa-full-local.sh @@ -15,13 +15,11 @@ readonly PROFILE="${1:-$ROOT/target/qa-profile-full}" readonly APP="$ROOT/target/release/bundle/macos/AgencyZero Experimental.app/Contents/MacOS/az-gui" readonly QA_HOME="${PROFILE}-home" readonly QA_WORKSPACE="${PROFILE}-workspace" -readonly DESCRIPTOR_PATH="$ROOT/target/qa-full-control.json" +readonly DESCRIPTOR_DIR="${TMPDIR:-/tmp}/tauri-blitz-agent" "$ROOT/scripts/qa-profile-restore.sh" "$PROFILE" -rm -f "$DESCRIPTOR_PATH" HOME="$QA_HOME" AZ_DATA_DIR="$PROFILE" AZ_QA_WORKSPACE_ROOT="$QA_WORKSPACE" \ - TAURI_BLITZ_DRIVER_DESCRIPTOR="$DESCRIPTOR_PATH" \ "$APP" --blitz-control > "$ROOT/target/qa-full-app.log" 2>&1 & app_pid=$! cleanup() { @@ -31,8 +29,17 @@ cleanup() { trap cleanup EXIT attempt=0 +descriptor_path="" while [ "$attempt" -lt 200 ]; do - if ps-qa --descriptor "$DESCRIPTOR_PATH" find Home --role button --limit 1 2>/dev/null | \ + for candidate in "$DESCRIPTOR_DIR"/*.json; do + [ -f "$candidate" ] || continue + if grep -Eq "\"pid\"[[:space:]]*:[[:space:]]*$app_pid([,}])" "$candidate"; then + descriptor_path="$candidate" + break + fi + done + if [ -n "$descriptor_path" ] && \ + ps-qa --descriptor "$descriptor_path" find Home --role button --limit 1 2>/dev/null | \ grep -Eq '^matched:[[:space:]]*[1-9]'; then break fi @@ -40,11 +47,16 @@ while [ "$attempt" -lt 200 ]; do attempt=$((attempt + 1)) done -if ! ps-qa --descriptor "$DESCRIPTOR_PATH" find Home --role button --limit 1 | \ +if [ -z "$descriptor_path" ]; then + echo "the QA app did not publish a control descriptor within 10 seconds" >&2 + exit 1 +fi + +if ! ps-qa --descriptor "$descriptor_path" find Home --role button --limit 1 | \ grep -Eq '^matched:[[:space:]]*[1-9]'; then echo "the QA app did not expose Home within 10 seconds" >&2 exit 1 fi -QA_DESCRIPTOR="$DESCRIPTOR_PATH" QA_LOG="$ROOT/target/qa-full.txt" \ +QA_DESCRIPTOR="$descriptor_path" QA_LOG="$ROOT/target/qa-full.txt" \ "$ROOT/scripts/qa-run-groups.sh" diff --git a/scripts/stage-wt-v2-reader-sidecar.sh b/scripts/stage-wt-v2-reader-sidecar.sh new file mode 100755 index 000000000..ec70b7945 --- /dev/null +++ b/scripts/stage-wt-v2-reader-sidecar.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh +set -eu + +repo_root=$(git rev-parse --show-toplevel) +manifest="$repo_root/crates/wt-migrate/v2-reader/Cargo.toml" +reader_root="$repo_root/crates/wt-migrate/v2-reader" +host_target=$(rustc -vV | sed -n 's/^host: //p') +if [ -z "$host_target" ]; then + echo "could not determine the Rust host target" >&2 + exit 1 +fi + +build_target="${CARGO_TARGET_DIR:-$repo_root/target/wt-v2-reader-build}" +# This is an independently resolved historical reader. Cargo creates a lockfile +# beside standalone binary manifests; the repository intentionally keeps none. +trap 'rm -f "$reader_root/Cargo.lock"' EXIT +CARGO_TARGET_DIR="$build_target" cargo build \ + --release \ + --manifest-path "$manifest" \ + --bin agencyzero-wt-v2-reader + +sidecar_dir="$repo_root/apps/gui/binaries" +sidecar="$sidecar_dir/agencyzero-wt-v2-reader-$host_target" +mkdir -p "$sidecar_dir" +cp "$build_target/release/agencyzero-wt-v2-reader" "$sidecar" +chmod 755 "$sidecar" +file "$sidecar" diff --git a/tests/data/qa-profile.tar.zst b/tests/data/qa-profile.tar.zst index 413cb7205..f60f96821 100644 Binary files a/tests/data/qa-profile.tar.zst and b/tests/data/qa-profile.tar.zst differ diff --git a/tests/ps-qa/02-hover.ron b/tests/ps-qa/02-hover.ron index fcec9ea88..8e9c7265b 100644 --- a/tests/ps-qa/02-hover.ron +++ b/tests/ps-qa/02-hover.ron @@ -59,7 +59,8 @@ what: "the project-row editor restores its fixture title for later outcomes", open: Some("theta theta north indi"), hover: Some("Change the status of qa audit project item"), - click: Some("button:Edit qa audit project item"), + prepare: Some("button:Edit qa audit project item"), + click: None, type_into: Some("textbox:Edit qa audit project item"), text: Some("kappa eta north cobalt epsilon north delta east eta zeta beta"), key: Some("Enter"), diff --git a/tests/ps-qa/04-items.ron b/tests/ps-qa/04-items.ron index 795f58802..251699297 100644 --- a/tests/ps-qa/04-items.ron +++ b/tests/ps-qa/04-items.ron @@ -24,8 +24,21 @@ open: Some("theta theta north indi"), hover: None, click: None, - scroll_over: Some("kappa eta north cobalt epsilon north delta east eta zeta beta"), - scroll_ticks: 4, + // The first row of the first page, and it has to be all three of those. + // + // `scroll` sends its wheel events to the first node matching this name. + // The old target was a row whose title is also a project tab's name, + // and the tab strip sorts ahead of the list, so the events went to a + // bar that does not scroll. A row further down the list is not mounted + // yet, so it matches nothing at all. The row's status button matches + // but is a 30px control, and the wheel lands on the button rather than + // the pane behind it. Each of those left the list unmoved at 12 rows, + // which reads as broken pagination rather than a fixture that missed. + // + // This title belongs to no tab, so the listitem is the first match: a + // full-width row inside the scroller, present on first paint. + scroll_over: Some("theta theta epsilon cobalt cobalt zeta alpha sigma lambda kappa n"), + scroll_ticks: 8, scroll_delta: -300.0, subject: "@project-item", expect: Grows, @@ -36,7 +49,8 @@ what: "editing an item description enables its Save action", open: Some("theta theta north indi"), hover: Some("Change the status of kappa eta north cobalt epsilon north delta east eta zeta beta"), - click: Some("Edit the description for kappa eta north cobalt epsilon north delta east eta zeta beta"), + prepare: Some("Edit the description for kappa eta north cobalt epsilon north delta east eta zeta beta"), + click: None, type_into: Some("Description / sub-items"), text: Some("qa audit description"), subject: "Save description", @@ -59,9 +73,9 @@ group: "items", what: "a newly created item paints in the rendered list", open: Some("theta theta north indi"), - prepare: None, + prepare: Some("New item"), hover: None, - click: Some("New item"), + click: None, type_into: Some("New item"), text: Some("qa audit first new item"), key: Some("Enter"), diff --git a/tests/ps-qa/05-chrome.ron b/tests/ps-qa/05-chrome.ron index bf49096f6..bda1ee121 100644 --- a/tests/ps-qa/05-chrome.ron +++ b/tests/ps-qa/05-chrome.ron @@ -11,7 +11,7 @@ click: Some("Settings"), outcome_timeout_ms: 600, stable_for_ms: 150, - subject: "heading:Appearance", + subject: "heading:Settings", expect: PaintsNamed, ), ( diff --git a/tests/ps-qa/05-session.ron b/tests/ps-qa/05-session.ron index d6b020b88..e18f057c6 100644 --- a/tests/ps-qa/05-session.ron +++ b/tests/ps-qa/05-session.ron @@ -38,6 +38,7 @@ open: Some("theta theta north indi"), hover: None, click: None, + reveal_before_capture: Some("textbox:Resume a session by id"), subject: "textbox:Resume a session by id", expect: PaintsNamed, ), @@ -55,6 +56,7 @@ key_on: Some("textbox:Resume a session by id"), subject: "textbox:Resume a session by id", expect: NameChanges, + covers: ["textbox:Resume a session by id*"], settle_after_ms: 1800, ), ( diff --git a/tests/ps-qa/05-tasklog.ron b/tests/ps-qa/05-tasklog.ron index 2ed7a5e4e..c6bff0649 100644 --- a/tests/ps-qa/05-tasklog.ron +++ b/tests/ps-qa/05-tasklog.ron @@ -8,6 +8,13 @@ group: "agent-io", what: "the agent I/O viewport expands without remounting the panel", open: Some("theta theta north indi"), + // The section is collapsed on a fresh profile, and its inner controls + // do not exist until it is opened. `reveal_before_capture` only + // scrolls, so without this the check looks for a button that is not + // in the tree yet. `prepare_unless` keeps it idempotent: already + // expanded, and there is nothing to click. + prepare: Some("Expand Agent I/O"), + prepare_unless: Some("Collapse Agent I/O"), reveal_before_capture: Some("Agent I/O"), hover: None, click: Some("button:Agent I/O: Expand"), @@ -19,6 +26,13 @@ group: "agent-io", what: "the expanded agent I/O viewport returns to its compact height", open: Some("theta theta north indi"), + // The section is collapsed on a fresh profile, and its inner controls + // do not exist until it is opened. `reveal_before_capture` only + // scrolls, so without this the check looks for a button that is not + // in the tree yet. `prepare_unless` keeps it idempotent: already + // expanded, and there is nothing to click. + prepare: Some("Expand Agent I/O"), + prepare_unless: Some("Collapse Agent I/O"), reveal_before_capture: Some("Agent I/O"), hover: None, click: Some("button:Agent I/O: Shrink"), @@ -30,6 +44,13 @@ group: "agent-io", what: "copying every agent I/O entry reports clipboard success", open: Some("theta theta north indi"), + // The section is collapsed on a fresh profile, and its inner controls + // do not exist until it is opened. `reveal_before_capture` only + // scrolls, so without this the check looks for a button that is not + // in the tree yet. `prepare_unless` keeps it idempotent: already + // expanded, and there is nothing to click. + prepare: Some("Expand Agent I/O"), + prepare_unless: Some("Collapse Agent I/O"), reveal_before_capture: Some("Agent I/O"), hover: None, click: Some("button:Copy all"), diff --git a/tests/ps-qa/06-rename.ron b/tests/ps-qa/06-rename.ron index 9c071729f..e6a9f9411 100644 --- a/tests/ps-qa/06-rename.ron +++ b/tests/ps-qa/06-rename.ron @@ -46,7 +46,8 @@ what: "an empty replacement closes the exact editor without changing the project name", open: Some("Home"), hover: None, - click: Some("button:Rename project theta theta north indi"), + prepare: Some("button:Rename project theta theta north indi"), + click: None, type_into: Some("textbox:Rename project theta theta north indi"), text: Some(" "), key: Some("Enter"), @@ -59,12 +60,14 @@ what: "Enter closes the editor after a non-empty replacement is submitted", open: Some("Home"), hover: None, - click: Some("button:Rename project theta theta north indi"), + prepare: Some("button:Rename project theta theta north indi"), + click: None, type_into: Some("textbox:Rename project theta theta north indi"), text: Some("Renamed by ps-qa"), key: Some("Enter"), subject: "textbox:Rename project theta theta north indi", expect: Vanishes, + covers: ["button:Rename project ", "textbox:Rename project "], ), ( id: "rename-home-search-follows-new-name", @@ -96,7 +99,8 @@ what: "the exact renamed project is restored for every later outcome", open: Some("Home"), hover: None, - click: Some("button:Rename project Renamed by ps-qa"), + prepare: Some("button:Rename project Renamed by ps-qa"), + click: None, type_into: Some("textbox:Rename project Renamed by ps-qa"), text: Some("theta theta north indi"), key: Some("Enter"), diff --git a/tests/ps-qa/06-toggles.ron b/tests/ps-qa/06-toggles.ron index dda305965..0eb2827b6 100644 --- a/tests/ps-qa/06-toggles.ron +++ b/tests/ps-qa/06-toggles.ron @@ -1,5 +1,5 @@ -// Standard value-bearing controls are verified through their semantic value, -// not their unchanged geometry. The disposable QA profile absorbs the writes. +// Checkboxes expose a value; switches expose their selected state, rather than +// changing geometry. The disposable QA profile absorbs the writes. [ ( id: "toggles-moderator", @@ -17,8 +17,8 @@ what: "raw Agent I/O persistence changes", open: Some("theta theta north indi"), hover: None, - click: Some("Keep this project's raw exchange in the database"), - subject: "Keep this project's raw exchange in the database", + click: Some("checkbox:Keep across restarts"), + subject: "checkbox:Keep across restarts", expect: ValueChanges, ), ( @@ -27,8 +27,8 @@ what: "raw Agent I/O persistence can be restored on the same checkbox", open: Some("theta theta north indi"), hover: None, - click: Some("Keep this project's raw exchange in the database"), - subject: "Keep this project's raw exchange in the database", + click: Some("checkbox:Keep across restarts"), + subject: "checkbox:Keep across restarts", expect: ValueChanges, ), ( @@ -39,7 +39,7 @@ hover: None, click: Some("Knowledge checkpoints for this project"), subject: "Knowledge checkpoints for this project", - expect: ValueChanges, + expect: SelectionChanges, ), ( id: "toggles-checkpoints-restores", @@ -49,7 +49,7 @@ hover: None, click: Some("Knowledge checkpoints for this project"), subject: "Knowledge checkpoints for this project", - expect: ValueChanges, + expect: SelectionChanges, ), ( id: "toggles-profiling", @@ -57,9 +57,9 @@ what: "deep profiling permission changes", open: Some("Settings"), hover: None, - click: Some("Allow deep intrusive profiling"), - subject: "Allow deep intrusive profiling", - expect: ValueChanges, + click: Some("#settings-deep-profiling"), + subject: "#settings-deep-profiling", + expect: SelectionChanges, ), ( id: "toggles-update-checks", @@ -68,8 +68,8 @@ open: Some("Settings"), hover: None, click: Some("Check for updates at launch"), - subject: "Check for updates at launch", - expect: ValueChanges, + subject: "#settings-update-checks-at-launch", + expect: SelectionChanges, ), ( id: "toggles-prompt-syntax", @@ -78,8 +78,8 @@ open: Some("Settings"), hover: None, click: Some("Inject AgencyZero and Prompt Syntax per turn"), - subject: "Inject AgencyZero and Prompt Syntax per turn", - expect: ValueChanges, + subject: "#settings-per-turn-injection", + expect: SelectionChanges, ), ( id: "toggles-agent-settings-updates", @@ -87,9 +87,9 @@ what: "agent permission to update application settings changes on its exact switch", open: Some("Settings"), hover: None, - click: Some("#settings-agent-settings-updates"), + click: Some("Allow agents to update app settings"), subject: "#settings-agent-settings-updates", - expect: ValueChanges, + expect: SelectionChanges, ), ( id: "toggles-profiling-restores", @@ -97,9 +97,9 @@ what: "deep profiling permission returns to the fixture value", open: Some("Settings"), hover: None, - click: Some("Allow deep intrusive profiling"), - subject: "Allow deep intrusive profiling", - expect: ValueChanges, + click: Some("#settings-deep-profiling"), + subject: "#settings-deep-profiling", + expect: SelectionChanges, ), ( id: "toggles-update-checks-restores", @@ -108,8 +108,8 @@ open: Some("Settings"), hover: None, click: Some("Check for updates at launch"), - subject: "Check for updates at launch", - expect: ValueChanges, + subject: "#settings-update-checks-at-launch", + expect: SelectionChanges, ), ( id: "toggles-prompt-syntax-restores", @@ -118,8 +118,8 @@ open: Some("Settings"), hover: None, click: Some("Inject AgencyZero and Prompt Syntax per turn"), - subject: "Inject AgencyZero and Prompt Syntax per turn", - expect: ValueChanges, + subject: "#settings-per-turn-injection", + expect: SelectionChanges, ), ( id: "toggles-agent-settings-updates-restores", @@ -127,8 +127,8 @@ what: "agent settings permission returns to the fixture value", open: Some("Settings"), hover: None, - click: Some("#settings-agent-settings-updates"), + click: Some("Allow agents to update app settings"), subject: "#settings-agent-settings-updates", - expect: ValueChanges, + expect: SelectionChanges, ), ] diff --git a/tests/ps-qa/06-verbosity.ron b/tests/ps-qa/06-verbosity.ron index d5ca37c20..72d903214 100644 --- a/tests/ps-qa/06-verbosity.ron +++ b/tests/ps-qa/06-verbosity.ron @@ -13,9 +13,9 @@ type_into: None, text: None, key: Some("Right"), - key_on: Some("Response verbosity for this project"), + key_on: Some("slider:Response verbosity for this project"), compare: None, - subject: "Response verbosity for this project", + subject: "slider:Response verbosity for this project", expect: ValueChanges, ), ( @@ -28,9 +28,9 @@ type_into: None, text: None, key: Some("Left"), - key_on: Some("Response verbosity for this project"), + key_on: Some("slider:Response verbosity for this project"), compare: None, - subject: "Response verbosity for this project", + subject: "slider:Response verbosity for this project", expect: ValueChanges, ), ] diff --git a/tests/ps-qa/09-settings.ron b/tests/ps-qa/09-settings.ron index 5ffe0c1dc..6f3538aef 100644 --- a/tests/ps-qa/09-settings.ron +++ b/tests/ps-qa/09-settings.ron @@ -83,7 +83,7 @@ prepare: Some("Agent-finished retention"), hover: None, click: Some("menuitem:2 turns"), - subject: "Agent-finished retention", + subject: "#settings-agent-finished-retention--trigger", expect: NameChanges, covers: ["button:Agent-finished retention:"], ), @@ -107,7 +107,7 @@ prepare: Some("Agent restart authority"), hover: None, click: Some("menuitem:Restart only"), - subject: "Agent restart authority", + subject: "#settings-agent-restart-authority--trigger", expect: NameChanges, ), ( @@ -120,8 +120,8 @@ // The fixture starts at the maximum. Move toward a value that can // change rather than asking a correct slider to exceed its range. key: Some("Left"), - key_on: Some("Response verbosity for this project"), - subject: "Response verbosity for this project", + key_on: Some("slider:Response verbosity for this project"), + subject: "slider:Response verbosity for this project", expect: ValueChanges, ), ( @@ -133,7 +133,7 @@ click: None, type_into: Some("PR review prompt"), text: Some("QA review prompt persistence check"), - subject: "PR review prompt", + subject: "#settings-review-prompt", expect: ValueChanges, ), ( diff --git a/tests/ps-qa/09z-settings-coverage.ron b/tests/ps-qa/09z-settings-coverage.ron index 5b52c7a76..c49ee511c 100644 --- a/tests/ps-qa/09z-settings-coverage.ron +++ b/tests/ps-qa/09z-settings-coverage.ron @@ -115,7 +115,9 @@ open: Some("theta theta north indi"), hover: None, click: Some("Remove /tmp/qa-project-dir"), - subject: "/tmp/qa-project-dir", + // Typed path plus this in-app X. The native folder picker is + // unreachable to ps-qa; see QA-button-audit-runbook.md. + subject: "Remove /tmp/qa-project-dir", expect: Vanishes, ), ( @@ -127,7 +129,7 @@ click: None, key: Some("Right"), key_on: Some("Projected turn warning threshold"), - subject: "Projected turn warning threshold", + subject: "#settings-cost-warning-threshold", expect: ValueChanges, ), ( @@ -137,8 +139,8 @@ open: Some("Settings"), hover: None, click: Some("Show projected-cost warnings"), - subject: "Show projected-cost warnings", - expect: ValueChanges, + subject: "#settings-cost-warnings-enabled", + expect: SelectionChanges, ), ( id: "settings-update-check-completes", @@ -179,8 +181,8 @@ open: Some("Settings"), hover: None, click: Some("PS deployment study"), - subject: "PS deployment study", - expect: ValueChanges, + subject: "#settings-study-enabled", + expect: SelectionChanges, ), ( id: "settings-study-delete-enabled-after-stop", @@ -301,7 +303,7 @@ prepare: Some("Completed items"), hover: None, click: Some("menuitem:Delete"), - subject: "Completed items", + subject: "#settings-completed-items--trigger", expect: NameChanges, ), ( @@ -312,7 +314,7 @@ prepare: Some("Agent-finished retention"), hover: None, click: Some("menuitem:1 turn"), - subject: "Agent-finished retention", + subject: "#settings-agent-finished-retention--trigger", expect: NameChanges, ), ( @@ -323,7 +325,7 @@ prepare: Some("Agent restart authority"), hover: None, click: Some("menuitem:Restart & update"), - subject: "Agent restart authority", + subject: "#settings-agent-restart-authority--trigger", expect: NameChanges, covers: ["#settings-agent-restart-authority--trigger"], ), @@ -334,6 +336,7 @@ open: Some("Settings"), hover: None, click: None, + reveal_before_capture: Some("button:Task manager effort: low"), subject: "button:Task manager effort: low", expect: PaintsNamed, ), @@ -378,7 +381,7 @@ click: None, type_into: Some("PR review prompt"), text: Some("QA restoration sentinel"), - subject: "PR review prompt", + subject: "#settings-review-prompt", expect: ValueChanges, ), ( @@ -390,7 +393,7 @@ click: None, type_into: Some("PR review prompt"), text: Some(""), - subject: "PR review prompt", + subject: "#settings-review-prompt", expect: ValueChanges, ), ( @@ -402,7 +405,7 @@ click: None, key: Some("Left"), key_on: Some("Projected turn warning threshold"), - subject: "Projected turn warning threshold", + subject: "#settings-cost-warning-threshold", expect: ValueChanges, ), ( @@ -412,8 +415,8 @@ open: Some("Settings"), hover: None, click: Some("Show projected-cost warnings"), - subject: "Show projected-cost warnings", - expect: ValueChanges, + subject: "#settings-cost-warnings-enabled", + expect: SelectionChanges, ), ( id: "settings-study-restores", @@ -422,8 +425,8 @@ open: Some("Settings"), hover: None, click: Some("PS deployment study"), - subject: "PS deployment study", - expect: ValueChanges, + subject: "#settings-study-enabled", + expect: SelectionChanges, ), ( id: "settings-context-detail-restores", @@ -444,8 +447,8 @@ hover: None, click: None, key: Some("Right"), - key_on: Some("Response verbosity for this project"), - subject: "Response verbosity for this project", + key_on: Some("slider:Response verbosity for this project"), + subject: "slider:Response verbosity for this project", expect: ValueChanges, ), ] diff --git a/tests/ps-qa/11-composer.ron b/tests/ps-qa/11-composer.ron index 924fe264e..164718d90 100644 --- a/tests/ps-qa/11-composer.ron +++ b/tests/ps-qa/11-composer.ron @@ -1,6 +1,16 @@ // Composer controls are per-tab state. Menus must render their choices, while // toggles must change either their semantic value or their inverse label. [ + ( + id: "composer-agent-setup-opens-settings", + group: "composer", + what: "the blocked project composer links directly to agent settings", + open: Some("theta theta north indi"), + hover: None, + click: Some("Open Settings"), + subject: "textbox:Search settings", + expect: PaintsNamed, + ), ( id: "composer-body-is-flat", group: "composer", @@ -90,6 +100,7 @@ click: Some("menuitem:Bypass"), subject: "Permission", expect: NameChanges, + covers: ["button:Permission:*"], ), ( id: "composer-permission-hover-leaves-nothing", @@ -201,6 +212,7 @@ click: Some("menuitem:Claude · Claude Opus 5"), subject: "button:Model:", expect: NameChanges, + covers: ["button:Model:*"], ), ( id: "composer-model-restores", diff --git a/tests/ps-qa/12-home.ron b/tests/ps-qa/12-home.ron index 8d3adbfc6..94c9dc43e 100644 --- a/tests/ps-qa/12-home.ron +++ b/tests/ps-qa/12-home.ron @@ -2,6 +2,16 @@ // to one painted node id; ValueChanges follows that same id after activation. // Search runs last because it intentionally filters every fixture row away. [ + ( + id: "home-task-manager-opens-settings", + group: "home", + what: "the unavailable task manager links directly to the settings that make it usable", + open: Some("Home"), + hover: None, + click: Some("#home-task-manager-open-settings"), + subject: "textbox:Search settings", + expect: PaintsNamed, + ), ( id: "home-task-manager-prompt-disabled-without-agent", group: "home", @@ -120,6 +130,7 @@ click: Some("Open project "), subject: "button:Send", expect: Paints, + covers: ["button:Open project "], ), ( id: "home-recent-project-opens", @@ -130,6 +141,7 @@ click: Some("Open recent project "), subject: "button:Send", expect: Paints, + covers: ["button:Open recent project "], ), ( id: "home-item-opens", @@ -140,6 +152,7 @@ click: Some("Open item "), subject: "button:Send", expect: Paints, + covers: ["button:Open item "], ), ( id: "home-close-draft", @@ -216,6 +229,7 @@ click: Some("Collapse project "), subject: "Expand project ", expect: Paints, + covers: ["button:Collapse project "], ), ( id: "home-expand-project", diff --git a/tests/ps-qa/13-theme.ron b/tests/ps-qa/13-theme.ron index a3234e8ce..ceea5358c 100644 --- a/tests/ps-qa/13-theme.ron +++ b/tests/ps-qa/13-theme.ron @@ -362,7 +362,7 @@ prepare: Some("Theme color #AE3270"), hover: None, click: Some("Theme color #AE7032"), - covers: ["radio:Theme color ", "#settings-theme-surface-flower-petal-30"], + covers: ["radio:Theme color ", "#settings-theme-surface--wheel-flower-petal-30"], subject: "Theme color #AE7032", expect: SelectionChanges, ), @@ -378,7 +378,7 @@ key: Some("Home"), key_on: Some("Glass blur"), compare: None, - subject: "Glass blur", + subject: "#settings-theme-glass-blur", expect: ValueChanges, ), ( @@ -390,7 +390,7 @@ click: None, key: Some("End"), key_on: Some("Glass blur"), - subject: "Glass blur", + subject: "#settings-theme-glass-blur", expect: ValueChanges, ), ( @@ -405,7 +405,7 @@ key: Some("Right"), key_on: Some("Glass refraction"), compare: None, - subject: "Glass refraction", + subject: "#settings-theme-glass-refraction", expect: ValueChanges, ), ( @@ -420,7 +420,7 @@ key: Some("Right"), key_on: Some("Glass depth"), compare: None, - subject: "Glass depth", + subject: "#settings-theme-glass-depth", expect: ValueChanges, ), ( @@ -435,7 +435,7 @@ key: Some("Right"), key_on: Some("Glass opacity"), compare: None, - subject: "Glass opacity", + subject: "#settings-theme-glass-opacity", expect: ValueChanges, ), ( @@ -450,7 +450,7 @@ key: Some("Right"), key_on: Some("Glass scrim"), compare: None, - subject: "Glass scrim", + subject: "#settings-theme-glass-scrim", expect: ValueChanges, ), ( @@ -498,7 +498,7 @@ prepare_key: Some("End"), hover: None, click: Some("Reset to default"), - subject: "Glass opacity", + subject: "#settings-theme-glass-opacity", expect: ValueChanges, ), ( @@ -509,7 +509,7 @@ hover: None, click: Some("switch:Glass"), subject: "switch:Glass", - expect: ValueChanges, + expect: SelectionChanges, ), ( id: "theme-glass-restores", @@ -519,19 +519,21 @@ hover: None, click: Some("switch:Glass"), subject: "switch:Glass", - expect: ValueChanges, + expect: SelectionChanges, ), ( id: "switch-search-restores", group: "switch", what: "the Switch contract leaves Settings search as it found it", open: Some("Settings"), + setup_type_into: Some("Search settings"), + setup_text: Some("Appearance"), hover: None, click: None, type_into: Some("Search settings"), text: Some(""), - subject: "heading:Settings", - expect: PaintsNamed, + subject: "textbox:Search settings", + expect: ValueChanges, ), ( id: "theme-search-restores", diff --git a/tests/ps-qa/14-textarea.ron b/tests/ps-qa/14-textarea.ron index 1ba3a3ebd..de0f8bced 100644 --- a/tests/ps-qa/14-textarea.ron +++ b/tests/ps-qa/14-textarea.ron @@ -11,6 +11,7 @@ open: Some("theta theta north indi"), hover: None, click: None, + reveal_before_capture: Some("textbox:Notes kept across compaction"), // `role:name`, not the name alone: the panel labels its heading and its // field alike, so a name-only match is answered by the heading. subject: "textbox:Notes kept across compaction",