From 5e0d765fd5b2e2b462891db9c06ecc3b3191794d Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Mon, 5 Oct 2026 00:26:14 -0700 Subject: [PATCH] =?UTF-8?q?hookd:=20=F0=9F=90=9B=20Wait=20for=20uv=20to=20?= =?UTF-8?q?resolve=20the=20release=20before=20installing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 +++ captain_hook/desktop/cli.py | 4 +- internal/hookd/command.go | 5 ++- internal/hookd/toolrelease.go | 62 +++++++++++++++++++++++++++ internal/hookd/toolrelease_test.go | 68 ++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 internal/hookd/toolrelease.go create mode 100644 internal/hookd/toolrelease_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1172ba1b..75aa50ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `uv tool install`, and the host stayed on its previous build. The tool env now gets its own 15-minute budget, and the 3-minute budget starts once it is in place, still bounding quiesce, supersede, activation, and the broker ping. +- **Self-updates wait until uv can resolve the new version.** PyPI index lag made the + 12.88.0 update fail with `No solution found` after publishing succeeded. + `capt-hookd package-install` now probes uv every 5 seconds for up to 10 minutes before + installing, honoring uv's configured indexes and local wheelhouses. A tool env already + installed for this build skips the wait, and `capt-hook helper install` names it in its + progress line. - **The session guard lets agents quit apps that host no session.** `osascript` that quits a named application was blocked for every app, including Slack. The guard now blocks only terminals, editors, Orca, Captain Hook, tmux, and any app whose process diff --git a/captain_hook/desktop/cli.py b/captain_hook/desktop/cli.py index 436ce592..5c4bb707 100644 --- a/captain_hook/desktop/cli.py +++ b/captain_hook/desktop/cli.py @@ -36,8 +36,8 @@ def install() -> None: brew(["reinstall", "--formula", FORMULA], stream=True) cellar = cellar_version() click.echo( - f"Landing Captain Hook {cellar}: up to 15 minutes while uv installs the tool env, " - "then up to 3 minutes to restart the host...", + f"Landing Captain Hook {cellar}: up to 10 minutes while the package index catches up, " + "up to 15 minutes while uv installs the tool env, then up to 3 minutes to restart the host...", err=True, ) if (host := deploy(cellar, stream=True)) is None: diff --git a/internal/hookd/command.go b/internal/hookd/command.go index 0de8d4e2..cd77e939 100644 --- a/internal/hookd/command.go +++ b/internal/hookd/command.go @@ -351,7 +351,10 @@ func packageInstallCommand(args []string, stderr io.Writer) int { if len(args) != 0 { return 2 } - err := installPackage(installProductToolEnv, applyPackagedApplication, packageToolEnvTimeout, packageLifecycleTimeout) + err := awaitProductRelease() + if err == nil { + err = installPackage(installProductToolEnv, applyPackagedApplication, packageToolEnvTimeout, packageLifecycleTimeout) + } if err != nil { fmt.Fprintln(stderr, err) return 1 diff --git a/internal/hookd/toolrelease.go b/internal/hookd/toolrelease.go new file mode 100644 index 00000000..f3224c61 --- /dev/null +++ b/internal/hookd/toolrelease.go @@ -0,0 +1,62 @@ +package hookd + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +const ( + productDist = "capt-hook" + releaseWaitTimeout = 10 * time.Minute + releasePollEvery = 5 * time.Second +) + +func awaitProductRelease() error { + if _, err := installedPython(); err == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), releaseWaitTimeout) + defer cancel() + if err := awaitResolvable(ctx, "uv", productDist, Build); err != nil { + return fmt.Errorf("captain package: wait for capt-hook %s to resolve: %w", Build, err) + } + return nil +} + +// awaitResolvable holds until uv resolves dist==version from the sources it +// installs from: PyPI lists a fresh upload minutes after publish succeeds, and +// until then uv tool install fails with "No solution found". +func awaitResolvable(ctx context.Context, uv, dist, version string) error { + poll := time.NewTicker(releasePollEvery) + defer poll.Stop() + reason := "no probe finished" + for ctx.Err() == nil { + cmd := exec.CommandContext( + ctx, uv, "pip", "compile", "--no-deps", "--refresh-package", dist, "--quiet", "--no-header", "-", + ) + cmd.Stdin = strings.NewReader(dist + "==" + version + "\n") + var stderr bytes.Buffer + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + return nil + } + if ctx.Err() != nil { + break + } + if exit := (*exec.ExitError)(nil); !errors.As(err, &exit) { + return err + } + reason = strings.TrimSpace(stderr.String()) + select { + case <-ctx.Done(): + case <-poll.C: + } + } + return fmt.Errorf("uv cannot resolve %s==%s: %s: %w", dist, version, reason, ctx.Err()) +} diff --git a/internal/hookd/toolrelease_test.go b/internal/hookd/toolrelease_test.go new file mode 100644 index 00000000..143568fd --- /dev/null +++ b/internal/hookd/toolrelease_test.go @@ -0,0 +1,68 @@ +package hookd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func fakeUV(t *testing.T, failures int) (uv, requests string) { + t.Helper() + dir := t.TempDir() + requests = filepath.Join(dir, "requests") + uv = filepath.Join(dir, "uv") + script := fmt.Sprintf(`#!/bin/sh +cat >> %[1]q +echo "$@" >> %[1]q +if [ "$(grep -c '^pip compile' %[1]q)" -le %[2]d ]; then + echo "No solution found when resolving dependencies" >&2 + exit 1 +fi +`, requests, failures) + if err := os.WriteFile(uv, []byte(script), 0o700); err != nil { //nolint:gosec // a fake uv must be executable + t.Fatal(err) + } + return uv, requests +} + +func TestAwaitResolvableHoldsUntilUVResolvesTheVersion(t *testing.T) { + t.Parallel() + uv, requests := fakeUV(t, 1) + if err := awaitResolvable(t.Context(), uv, productDist, "12.88.0"); err != nil { + t.Fatalf("awaitResolvable = %v, want nil once uv resolves 12.88.0", err) + } + got, err := os.ReadFile(requests) + if err != nil { + t.Fatal(err) + } + probe := "capt-hook==12.88.0\npip compile --no-deps --refresh-package capt-hook --quiet --no-header -\n" + if string(got) != probe+probe { + t.Fatalf("uv saw %q, want two resolution probes", got) + } +} + +func TestAwaitResolvableGivesUpAtItsDeadlineNamingTheVersion(t *testing.T) { + t.Parallel() + uv, _ := fakeUV(t, 9) + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + err := awaitResolvable(ctx, uv, productDist, "12.88.0") + if err == nil || !strings.Contains(err.Error(), "uv cannot resolve capt-hook==12.88.0: No solution found") { + t.Fatalf("awaitResolvable = %v, want the unresolved version named", err) + } +} + +func TestAwaitResolvableFailsAtOnceWithoutUV(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + start := time.Now() + err := awaitResolvable(ctx, filepath.Join(t.TempDir(), "uv"), productDist, "12.88.0") + if err == nil || time.Since(start) > releasePollEvery { + t.Fatalf("awaitResolvable without uv = %v after %v, want an immediate failure", err, time.Since(start)) + } +}