From c2a4c90a168197ed27c9abf0c71802ecbc7a4f24 Mon Sep 17 00:00:00 2001 From: Yasyf Mohamedali Date: Sun, 4 Oct 2026 22:38:15 -0700 Subject: [PATCH] =?UTF-8?q?hookd:=20=F0=9F=90=9B=20Give=20package-install'?= =?UTF-8?q?s=20tool-env=20download=20its=20own=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package-install resolved the capt-hook tool env under the same 3-minute context as the rest of the install, so on a slow link the 89 MiB claude-agent-sdk wheel outlasted it and the context SIGKILLed `uv tool install`, leaving the host on its previous build. The tool env now runs first under its own 15-minute deadline, and the 3-minute lifecycle budget starts only once it is in place, still bounding quiesce, supersede, activation, and the broker ping. --- CHANGELOG.md | 6 ++++ captain_hook/desktop/cli.py | 4 +-- internal/hookd/command.go | 21 +++++++++++-- internal/hookd/command_test.go | 39 ++++++++++++++++++++++++ internal/hookd/deployment_darwin.go | 8 ----- internal/hookd/deployment_darwin_test.go | 4 +-- internal/hookd/deployment_linux.go | 8 ----- internal/hookd/deployment_linux_test.go | 4 +-- internal/hookd/manager.go | 11 +++++++ 9 files changed, 80 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5cec045..1172ba1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 install, the deploy, and the host ping. Brew's output and `package-install`'s stderr appear live, and each failure written to the update log is also printed. The final error gives the update log's path. The background updater still captures output. +- **`capt-hookd package-install` converges on a slow link.** Installing the capt-hook + tool env shared the command's 3-minute lifecycle budget, so on a slow connection the + 89 MiB `claude-agent-sdk` wheel download outlasted it, the context SIGKILLed + `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. - **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 de732708..436ce592 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} and restarting the host. " - "This can take up to about 3 minutes while uv installs the tool env...", + f"Landing Captain Hook {cellar}: 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 b5e9f45c..0de8d4e2 100644 --- a/internal/hookd/command.go +++ b/internal/hookd/command.go @@ -27,6 +27,11 @@ const ( // them. A budget that cannot hold that drain fails on exactly the machine // the command exists to repair. packageLifecycleTimeout = 3 * time.Minute + + // packageToolEnvTimeout bounds installing this build's capt-hook tool env + // ahead of the install lifecycle. Nothing is disrupted yet, and its wheels + // outlast packageLifecycleTimeout on a slow link. + packageToolEnvTimeout = 15 * time.Minute ) // Main executes one capt-hookd client or host command and returns its exit code. @@ -346,15 +351,25 @@ func packageInstallCommand(args []string, stderr io.Writer) int { if len(args) != 0 { return 2 } - ctx, cancel := context.WithTimeout(context.Background(), packageLifecycleTimeout) - defer cancel() - if err := applyPackagedApplication(ctx); err != nil { + err := installPackage(installProductToolEnv, applyPackagedApplication, packageToolEnvTimeout, packageLifecycleTimeout) + if err != nil { fmt.Fprintln(stderr, err) return 1 } return 0 } +func installPackage(toolEnv, apply func(context.Context) error, toolEnvTimeout, lifecycleTimeout time.Duration) error { + toolEnvCtx, cancel := context.WithTimeout(context.Background(), toolEnvTimeout) + defer cancel() + if err := toolEnv(toolEnvCtx); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), lifecycleTimeout) + defer cancel() + return apply(ctx) +} + func packageUninstallCommand(args []string, stderr io.Writer) int { if len(args) != 0 { return 2 diff --git a/internal/hookd/command_test.go b/internal/hookd/command_test.go index c09d598e..f4fd7584 100644 --- a/internal/hookd/command_test.go +++ b/internal/hookd/command_test.go @@ -349,6 +349,45 @@ func TestVersionReportsExactSchemaAndBuild(t *testing.T) { } } +func TestPackageInstallStartsTheLifecycleBudgetAfterASlowToolEnv(t *testing.T) { + t.Parallel() + const toolEnvTimeout, lifecycleTimeout = 10 * time.Second, 100 * time.Millisecond + var lifecycleBudget time.Duration + err := installPackage( + func(ctx context.Context) error { + select { + case <-ctx.Done(): + return fmt.Errorf("the tool env was cancelled: %v", ctx.Err()) + case <-time.After(3 * lifecycleTimeout): + return nil + } + }, + func(ctx context.Context) error { + deadline, ok := ctx.Deadline() + if !ok { + return errors.New("the lifecycle ran without a deadline") + } + lifecycleBudget = time.Until(deadline) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(toolEnvTimeout): + return errors.New("the lifecycle outlived its budget") + } + }, + toolEnvTimeout, lifecycleTimeout, + ) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("installPackage = %v, want the lifecycle budget to expire", err) + } + if lifecycleBudget <= 0 || lifecycleBudget > lifecycleTimeout { + t.Fatalf( + "lifecycle started with %v left, want a fresh %v budget after the tool env", + lifecycleBudget, lifecycleTimeout, + ) + } +} + func TestRunSpellsTheRequestFromThePayload(t *testing.T) { cwd := "/payload/cwd" payload := `{"session_id":"abc","cwd":"` + cwd + `"}` diff --git a/internal/hookd/deployment_darwin.go b/internal/hookd/deployment_darwin.go index a47ca96c..dd3571c3 100644 --- a/internal/hookd/deployment_darwin.go +++ b/internal/hookd/deployment_darwin.go @@ -11,7 +11,6 @@ import ( "time" "github.com/yasyf/daemonkit" - "github.com/yasyf/daemonkit/artifact" "github.com/yasyf/daemonkit/deploy" "github.com/yasyf/daemonkit/launchd" ) @@ -141,13 +140,6 @@ func openDeployment(appPath string) (*deploy.Deployment, error) { } func applyPackagedApplication(ctx context.Context) error { - store, err := artifact.DefaultStore() - if err != nil { - return err - } - if _, err := store.Resolve(ctx, productToolDescriptor()); err != nil { - return fmt.Errorf("captain package: install the capt-hook %s tool env: %w", Build, err) - } source, err := packagedApplicationPath() if err != nil { return err diff --git a/internal/hookd/deployment_darwin_test.go b/internal/hookd/deployment_darwin_test.go index 0b1741e2..3bec2031 100644 --- a/internal/hookd/deployment_darwin_test.go +++ b/internal/hookd/deployment_darwin_test.go @@ -67,9 +67,9 @@ func TestPackageInstallAbortsBeforeTheAppWhenTheToolEnvFails(t *testing.T) { t.Setenv("HOME", home) t.Setenv("DAEMONKIT_HOME", home) t.Setenv("PATH", t.TempDir()) - err = applyPackagedApplication(t.Context()) + err = installPackage(installProductToolEnv, applyPackagedApplication, packageToolEnvTimeout, packageLifecycleTimeout) if err == nil || !strings.Contains(err.Error(), "install the capt-hook "+Build+" tool env") { - t.Fatalf("applyPackagedApplication without uv = %v, want the tool env failure", err) + t.Fatalf("installPackage without uv = %v, want the tool env failure", err) } if _, err := os.Lstat(filepath.Join(home, "Applications")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("package-install touched ~/Applications after the tool env failed: %v", err) diff --git a/internal/hookd/deployment_linux.go b/internal/hookd/deployment_linux.go index 18b849c2..729ac3a9 100644 --- a/internal/hookd/deployment_linux.go +++ b/internal/hookd/deployment_linux.go @@ -10,7 +10,6 @@ import ( "path/filepath" "github.com/yasyf/daemonkit" - "github.com/yasyf/daemonkit/artifact" "github.com/yasyf/daemonkit/durable" "github.com/yasyf/daemonkit/supervise" ) @@ -61,13 +60,6 @@ func (p installedPaths) lock(ctx context.Context, name string) (*durable.Lock, e } func applyPackagedApplication(ctx context.Context) error { - store, err := artifact.DefaultStore() - if err != nil { - return err - } - if _, err := store.Resolve(ctx, productToolDescriptor()); err != nil { - return fmt.Errorf("captain package: install the capt-hook %s tool env: %w", Build, err) - } source, err := canonicalExecutable() if err != nil { return err diff --git a/internal/hookd/deployment_linux_test.go b/internal/hookd/deployment_linux_test.go index 229f3b49..8dde7317 100644 --- a/internal/hookd/deployment_linux_test.go +++ b/internal/hookd/deployment_linux_test.go @@ -13,9 +13,9 @@ func TestPackageInstallAbortsBeforeTheHostWhenTheToolEnvFails(t *testing.T) { t.Setenv("HOME", home) t.Setenv("DAEMONKIT_HOME", home) t.Setenv("PATH", t.TempDir()) - err := applyPackagedApplication(t.Context()) + err := installPackage(installProductToolEnv, applyPackagedApplication, packageToolEnvTimeout, packageLifecycleTimeout) if err == nil || !strings.Contains(err.Error(), "install the capt-hook "+Build+" tool env") { - t.Fatalf("applyPackagedApplication without uv = %v, want the tool env failure", err) + t.Fatalf("installPackage without uv = %v, want the tool env failure", err) } if _, err := os.Lstat(filepath.Join(home, ".local")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("package-install touched ~/.local after the tool env failed: %v", err) diff --git a/internal/hookd/manager.go b/internal/hookd/manager.go index d9d56d73..1ce0dd55 100644 --- a/internal/hookd/manager.go +++ b/internal/hookd/manager.go @@ -1021,6 +1021,17 @@ func productToolDescriptor() *artifact.Descriptor { } } +func installProductToolEnv(ctx context.Context) error { + store, err := artifact.DefaultStore() + if err != nil { + return err + } + if _, err := store.Resolve(ctx, productToolDescriptor()); err != nil { + return fmt.Errorf("captain package: install the capt-hook %s tool env: %w", Build, err) + } + return nil +} + func installedPython() (string, error) { store, err := artifact.DefaultStore() if err != nil {