Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions captain_hook/desktop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion internal/hookd/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions internal/hookd/toolrelease.go
Original file line number Diff line number Diff line change
@@ -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())
}
68 changes: 68 additions & 0 deletions internal/hookd/toolrelease_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading