mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +00:00
## Why PR #6330 split agent harness/runtime detection into a cheap (cache-only) path and a forced (spawning) path. Two regressions followed, both surfacing as every harness showing "(not installed)" / "CLI missing" across the agent create/edit picker, Agents > Agent defaults, and Settings > Agents — blocking agent create/edit until the user clicked Install in Settings > Agents. ## Root cause One underlying bug, two victims: - **Boot false-negative.** The resolve cache is in-memory, so it starts cold on every launch. `resolve_command_cached` (the cheap path) consulted only the Buzz-managed shim dirs plus that cold cache, and `buzz_managed_command_path`'s allowlist structurally excludes `buzz-agent`. The bundled sidecar could therefore never resolve on the cheap path until a forced pass warmed the cache, so cheap-path surfaces rendered all-missing at boot. App setup never warms the cache. - **"Check again" hang.** `run_in_login_shell` used an untimeouted `Command::output()`; a wedged login shell froze the whole forced pipeline, leaving "Check again" spinning forever. ## What - `resolve_command_cached` now also calls `resolve_workspace_command`, resolving the bundled sidecar via a filesystem stat (no spawn) — the same class of work the managed-shim check already performs. `buzz-agent` can no longer report missing, even inside the boot warm window. - New `discovery/bounded_command.rs` runs any discovery child under a hard wall-clock deadline, polling with `try_wait` rather than blocking on `wait()`. Stdout and stderr are piped to two drain threads whose buffers share an aggregate `CAPTURE_LIMIT`; a breach fails closed (kill the tree, return `None`), so a noisy or hostile probe can force neither unbounded memory nor disk fill. Tree teardown runs on every exit path — timeout, error, cap breach, *and* success — because a login-shell rc file or auth CLI can legitimately background a descendant that would otherwise outlive discovery. Ownership is deliberately asymmetric: - **Unix:** the child leads its own process group (`process_group(0)`); teardown is `SIGTERM` → bounded grace → `SIGKILL` on the group. A descendant that leaves the group (`setsid`/`setpgid`) while holding a pipe is not owned and may survive one probe, but can never hang or unbound the helper: the Unix drains read nonblocking and end on `WouldBlock` once teardown sets the stop flag, so the join returns promptly without waiting on an escaped writer's EOF. - **Windows:** the child is spawned `CREATE_SUSPENDED`, assigned to a kill-on-close Job Object while frozen, then resumed. The job owns the root before any descendant can exist and is created without breakaway, so no writer can escape — a hard whole-tree guarantee, and closing the job reaps the tree even after the root has exited. Any failure to create, assign, or resume is fail-closed: the child is terminated and reaped and the spawn returns `None` (discovery treats it as command-not-found) rather than running unowned. - Each login-shell candidate is bounded by a 10s timeout via that helper, falling through to the next candidate on timeout instead of aborting the resolve. The login-shell path cache is generation-aware: a probe that loses to a concurrent refresh or lands mid-refresh returns the authoritative cached value (or re-probes under the new generation) rather than its own rejected local result, so a losing thread can never settle the UI with a PATH-missing catalog while the cache holds a fresh success. - Warm the ACP runtime catalog once at `AppShell` mount and gate the cheap-path surfaces on that pass. A module-level boot-warm state (`idle` → `pending` → `settled`/`failed`, deduped per launch) lets `useAcpRuntimesQuery` present a cold catalog as *loading* while the first forced pass runs and as a *retryable error* (carrying the probe's real reason) if it fails, instead of blessing "every harness not installed" as authoritative. A non-empty catalog always wins, so a revalidation or later failure never blanks a good list; the gate only overlays once the warm has started, so onboarding (which renders before the warm) is unaffected. Deduping per launch also fixes the previous per-remount re-fire. ## Verification Unix teardown and the drain contract are runtime-proven by `#[ignore]`-free tests that record a backgrounded descendant's real PID and assert the helper returns promptly on both the success and timeout paths without blocking on that writer. The generation-aware login-shell cache is covered by deterministic tests through a `cfg(test)` injectable probe seam that assert the function's return value under both concurrent-refresh interleavings — the losing caller returns the peer's committed success, and a mid-probe refresh forces a re-probe to the fresh value. The Windows ownership contract has no CI lane, so `bounded_command.rs` carries two `#[ignore]`-gated tests (spawn/assign race, looped; and the timeout path) for a sanctioned run on a Windows host. The boot-warm gate is covered by unit tests for the pure overlay and the `startBootWarm` failure → retry → settle lifecycle. Origin: [Buzz thread](buzz://message?channel=5ef5d5bb-643f-4b87-bbf4-e8b64585ffeb&id=a4b1c4485de4d35cff0f914d4f4211c796f44f670e76de2ef9431f7e882c906e) Fixes #6872 Related #6662 --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
import * as React from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { startBootWarm } from "@/features/agents/acpRuntimesQuery";
|
|
import { setDesktopAppBadge } from "@/features/notifications/lib/desktop";
|
|
import { useForegroundQueryRefresh } from "@/features/workflows/hooks";
|
|
import { relayClient } from "@/shared/api/relayClient";
|
|
import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers";
|
|
|
|
type AppShellLifecycleEffectsOptions = {
|
|
desktopBadgeEnabled: boolean;
|
|
homeBadgeCountExcludingHighPriority: number;
|
|
topLevelUnreadChannelIds: ReadonlySet<string>;
|
|
unreadChannelNotificationCount: number;
|
|
};
|
|
|
|
export function useAppShellLifecycleEffects({
|
|
desktopBadgeEnabled,
|
|
homeBadgeCountExcludingHighPriority,
|
|
topLevelUnreadChannelIds,
|
|
unreadChannelNotificationCount,
|
|
}: AppShellLifecycleEffectsOptions) {
|
|
// Event-driven reconnect: network online / focus / visibility short-circuit
|
|
// the backoff timer when the relay session is degraded (CMD+R gap G1).
|
|
useRelayResumeTriggers();
|
|
useForegroundQueryRefresh();
|
|
|
|
// Warm the ACP runtime catalog once at app launch. The shared runtime-catalog
|
|
// cache is in-memory only, so it starts cold every boot; the cheap discovery
|
|
// path reports every harness as "(not installed)" until a forced pass warms
|
|
// it. The create/edit picker and Agents > Agent defaults surfaces read that
|
|
// cheap path, so without this warm they render all-missing (and block agent
|
|
// save) until the user visits Settings > Agents — the accidental workaround.
|
|
// `startBootWarm` drives the module-level boot-warm gate (once per launch, so
|
|
// this remounting effect never re-fires the probe) which makes those cheap
|
|
// surfaces show loading/retryable-error instead of blessing the cold catalog,
|
|
// and swallows the probe's own errors so a failure leaves the last good
|
|
// catalog in place without an unhandled rejection.
|
|
const queryClient = useQueryClient();
|
|
React.useEffect(() => {
|
|
void startBootWarm(queryClient);
|
|
}, [queryClient]);
|
|
|
|
// Prevent webview file:/// navigation on file drop outside the composer.
|
|
// Scoped to file drags only (text drag-and-drop into inputs still works).
|
|
// Composer's onDrop fires first (React synthetic before window bubble).
|
|
React.useEffect(() => {
|
|
function preventNavigation(e: DragEvent) {
|
|
if (e.dataTransfer?.types.includes("Files")) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
window.addEventListener("dragover", preventNavigation);
|
|
window.addEventListener("drop", preventNavigation);
|
|
return () => {
|
|
window.removeEventListener("dragover", preventNavigation);
|
|
window.removeEventListener("drop", preventNavigation);
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
let isCancelled = false;
|
|
void relayClient.preconnect().catch((error) => {
|
|
if (!isCancelled) {
|
|
console.error("Failed to preconnect to relay", error);
|
|
}
|
|
});
|
|
return () => {
|
|
isCancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!desktopBadgeEnabled) {
|
|
return;
|
|
}
|
|
|
|
const count =
|
|
unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority;
|
|
void setDesktopAppBadge(
|
|
count
|
|
? { kind: "count", count }
|
|
: { kind: topLevelUnreadChannelIds.size ? "dot" : "none" },
|
|
);
|
|
}, [
|
|
desktopBadgeEnabled,
|
|
homeBadgeCountExcludingHighPriority,
|
|
topLevelUnreadChannelIds,
|
|
unreadChannelNotificationCount,
|
|
]);
|
|
}
|