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 @@ -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
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} 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:
Expand Down
21 changes: 18 additions & 3 deletions internal/hookd/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions internal/hookd/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 + `"}`
Expand Down
8 changes: 0 additions & 8 deletions internal/hookd/deployment_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/hookd/deployment_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 0 additions & 8 deletions internal/hookd/deployment_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/hookd/deployment_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions internal/hookd/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading