Skip to content

Commit 1ba67d1

Browse files
authored
fix(runtime): prevent polling and stale worktree cache buildup (#71)
## Summary - stop injecting `CHOKIDAR_USEPOLLING=true` and `WATCHPACK_POLLING=true` into every generated service while preserving explicit user-authored values - make implicit `hack down` recover a uniquely owned same-checkout runtime after a linked-worktree branch rename, and fail safely when ownership is ambiguous - add confirmation-gated `hack down --prune-caches` cleanup for exact Compose-owned disposable cache volumes - support any framework or language through the top-level Compose volume label `hack.cache.disposable: "true"`; retain the narrow built-in `.next` destination rule for existing Next caches - document the root cause, contracts, safety boundaries, and intentionally omitted noisy doctor checks ## Root cause Hack's Compose generator unconditionally forced Chokidar and Watchpack polling on every discovered service, including non-Next services. Separately, linked-worktree branch renames changed the inferred Compose project name, so an implicit `hack down` could succeed against an empty target while leaving the checkout's real stopped runtime and cache volumes behind. Ordinary Compose down correctly preserves named volumes, but Hack had no project-scoped lifecycle for disposable branch build caches. The observed OrbStack/Next incident combined those Hack behaviors with project-authored amd64 emulation and large persistent `.next` volumes. This PR fixes only the behavior Hack owns; it does not rewrite project platform or healthcheck configuration. ## Safety boundaries - watcher defaults are runtime-agnostic and use native container-runtime file notifications; explicit service-level polling remains supported - branch retargeting requires the exact canonical checkout path and Compose project family - zero owned runtimes keep the inferred target; one is retargeted; multiple fail and require explicit `--branch` - cache cleanup is local-only, opt-in, previewed/confirmation-gated, and requires `--yes` for JSON/noninteractive use - candidates must be named volumes observed on exact target containers with matching Compose project/service labels - `docker volume inspect` must independently prove the exact Compose project and logical-volume labels - a cache must either be mounted exclusively at `.next` destinations or explicitly carry `hack.cache.disposable=true` - unlabeled `.turbo`, Postgres, Redis, application-data, bind, foreign-project, and sibling-checkout volumes are preserved - deletion uses exact `docker volume rm <name>` calls only; there is no `down -v`, force removal, name matching, or broad prune Already-orphaned volumes with no surviving container are deliberately left alone because Hack cannot prove their former checkout and mount destination safely. ## Verification - 70 focused tests / 304 assertions passed across Compose generation, linked-worktree targeting, down orchestration, disposable-volume verification, lifecycle startup regression coverage, generated agent guidance, and CLI-reference drift - real Docker-tier E2E creates a Compose-labeled `.turbo` cache plus an adjacent durable volume, writes data to both, runs `hack down --prune-caches --yes --json`, proves the cache is removed, and mounts the durable volume read-only to prove its data survived - `bun run typecheck` - `bun run check` - `bun x ultracite check` (543 files after the E2E addition) - `bun run privacy:check` - `bun index.ts setup sync --all-scopes --check` - `git diff --check` - hosted secret scan, runtime-image builds, macOS test/build job, and Docker E2E job pass Local Docker execution was intentionally skipped because OrbStack and the user's projects remain stopped; the local E2E isolation canary passed and the Docker scenario executed successfully in isolated hosted CI. No Event Agent, MSP, sick.email, OrbStack, Docker Desktop, or machine-wide service was started locally. The prior real OrbStack A/B evidence supports native watching, while the exact cleanup workflow now has real standard-Docker coverage. ## Release decision Yes: this is a user-visible runtime correctness fix and the Conventional Commit / PR title intentionally carries a `fix` release signal. This repository does not currently use a separate changeset artifact for this flow.
1 parent 543bad7 commit 1ba67d1

26 files changed

Lines changed: 2009 additions & 31 deletions

.claude/settings.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@
1616
"hack": {
1717
"type": "stdio",
1818
"command": "hack",
19-
"args": [
20-
"mcp",
21-
"serve"
22-
]
19+
"args": ["mcp", "serve"]
2320
}
2421
}
2522
}

.codex/skills/hack-cli/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ Use `hack` as the primary interface for local-first development.
1212

1313
## Integration freshness
1414

15-
- These instructions were generated by hack CLI v3.4.1; treat cached rules from another version as potentially stale.
15+
- These instructions were generated by hack CLI v3.5.0; treat cached rules from another version as potentially stale.
1616
- At session start, audit project and global integrations with `hack setup sync --all-scopes --check`.
1717
- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.
1818
- Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command.
19-
- Content revision: `902b04fe1469` (version alone is not a freshness guarantee).
19+
- Content revision: `1319d93c89c3` (version alone is not a freshness guarantee).
2020

2121
## Product boundary
2222

@@ -81,6 +81,7 @@ Use `hack` as the primary interface for local-first development.
8181
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
8282
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
8383
- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch <name>` to make the target explicit.
84+
- Implicit `hack down` retargets a uniquely owned same-checkout runtime after a Git branch rename, including Created and stopped containers; when multiple runtimes belong to the checkout, pass `--branch <name>` explicitly.
8485
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.
8586

8687
## Advanced networking (extra_hosts + local proxies/tunnels)
@@ -131,6 +132,7 @@ Use `hack` as the primary interface for local-first development.
131132
- Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup.
132133
- Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.
133134
- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees.
135+
- `hack down --prune-caches` can remove only confirmed Compose-owned named volumes mounted exclusively at `.next` destinations or explicitly labeled `hack.cache.disposable=true`; it is confirmation-gated, requires `--yes` for JSON/scripted runs, and never performs broad volume pruning.
134136

135137
## Workspaces (mux-managed, tmux-first by default)
136138

.cursor/mcp.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@
22
"mcpServers": {
33
"hack": {
44
"command": "hack",
5-
"args": [
6-
"mcp",
7-
"serve"
8-
]
5+
"args": ["mcp", "serve"]
96
}
107
}
118
}

.cursor/rules/hack.mdc

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
77

88
## Integration freshness
99

10-
- These instructions were generated by hack CLI v3.4.1; treat cached rules from another version as potentially stale.
10+
- These instructions were generated by hack CLI v3.5.0; treat cached rules from another version as potentially stale.
1111
- At session start, audit project and global integrations with `hack setup sync --all-scopes --check`.
1212
- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.
1313
- Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command.
14-
- Content revision: `902b04fe1469` (version alone is not a freshness guarantee).
14+
- Content revision: `1319d93c89c3` (version alone is not a freshness guarantee).
1515

1616
## Product boundary
1717

@@ -44,6 +44,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
4444
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
4545
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
4646
- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch <name>` to make the target explicit.
47+
- Implicit `hack down` retargets a uniquely owned same-checkout runtime after a Git branch rename, including Created and stopped containers; when multiple runtimes belong to the checkout, pass `--branch <name>` explicitly.
4748
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.
4849

4950
## Standard workflow
@@ -78,6 +79,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is
7879
- Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup.
7980
- Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.
8081
- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees.
82+
- `hack down --prune-caches` can remove only confirmed Compose-owned named volumes mounted exclusively at `.next` destinations or explicitly labeled `hack.cache.disposable=true`; it is confirmation-gated, requires `--yes` for JSON/scripted runs, and never performs broad volume pruning.
8183

8284
## Host-side env helpers
8385

AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,11 @@ Most formatting and common issues are automatically fixed by Biome. Run `bun x u
210210
Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces).
211211

212212
Integration freshness:
213-
- These instructions were generated by hack CLI v3.4.1; treat cached rules from another version as potentially stale.
213+
- These instructions were generated by hack CLI v3.5.0; treat cached rules from another version as potentially stale.
214214
- At session start, audit project and global integrations with `hack setup sync --all-scopes --check`.
215215
- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.
216216
- Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command.
217-
- Content revision: `902b04fe1469` (version alone is not a freshness guarantee).
217+
- Content revision: `1319d93c89c3` (version alone is not a freshness guarantee).
218218

219219
Product boundary:
220220
- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon.
@@ -271,6 +271,7 @@ Linked git worktrees:
271271
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
272272
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
273273
- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch <name>` to make the target explicit.
274+
- Implicit `hack down` retargets a uniquely owned same-checkout runtime after a Git branch rename, including Created and stopped containers; when multiple runtimes belong to the checkout, pass `--branch <name>` explicitly.
274275
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.
275276

276277
Advanced networking (extra_hosts + local proxies/tunnels):
@@ -316,6 +317,7 @@ Lifecycle + startup:
316317
- Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup.
317318
- Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.
318319
- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees.
320+
- `hack down --prune-caches` can remove only confirmed Compose-owned named volumes mounted exclusively at `.next` destinations or explicitly labeled `hack.cache.disposable=true`; it is confirmation-gated, requires `--yes` for JSON/scripted runs, and never performs broad volume pruning.
319321

320322
Workspaces (mux-managed, tmux-first by default):
321323
- Picker: `hack session` for persistent project workspaces.

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,11 @@ This project uses Obsidian for project context, specs, research, and progress tr
8080
Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, and persistent project workspaces).
8181

8282
Integration freshness:
83-
- These instructions were generated by hack CLI v3.4.1; treat cached rules from another version as potentially stale.
83+
- These instructions were generated by hack CLI v3.5.0; treat cached rules from another version as potentially stale.
8484
- At session start, audit project and global integrations with `hack setup sync --all-scopes --check`.
8585
- If anything is stale, missing, or deprecated, run `hack setup sync --all-scopes`, then reload the agent session so cached instructions are replaced.
8686
- Never copy or hand-edit generated Hack rules to refresh them; update the CLI and run the sync command.
87-
- Content revision: `902b04fe1469` (version alone is not a freshness guarantee).
87+
- Content revision: `1319d93c89c3` (version alone is not a freshness guarantee).
8888

8989
Product boundary:
9090
- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon.
@@ -141,6 +141,7 @@ Linked git worktrees:
141141
- Secret key inherits from the primary checkout automatically through the shared git common dir; set `HACK_ENV_SECRET_KEY` for CI or detached environments.
142142
- `hack up` in a linked worktree defaults to a branch instance named after the worktree's git branch; a detached linked worktree requires an explicit `--branch`, unless config `worktree.auto_branch=false` explicitly opts into the base instance.
143143
- Before `hack up` or `hack restart` auto-targets a new branch instance, Hack warns when the same worktree already owns a non-terminal instance; pass `--branch <name>` to make the target explicit.
144+
- Implicit `hack down` retargets a uniquely owned same-checkout runtime after a Git branch rename, including Created and stopped containers; when multiple runtimes belong to the checkout, pass `--branch <name>` explicitly.
144145
- `hack doctor` flags divergent secret keys and dev_host collisions across checkouts.
145146

146147
Advanced networking (extra_hosts + local proxies/tunnels):
@@ -186,6 +187,7 @@ Lifecycle + startup:
186187
- Target only affected services with `hack up <service...> --detach`, `hack restart <service...>`, or `hack env apply --service <service>`; scoped operations skip project lifecycle hooks and implicit dependency startup.
187188
- Use `hack env explain <KEY> --env <overlay> --service <service> --target <host|compose>` for redacted source, precedence, availability, and delivery diagnostics.
188189
- Dependency installer services are detected generically by command or `hack.dependencies.bootstrap=true`; registry env references are preflighted before container mutation. Optional `hack.dependencies.cache-volume`, `hack.dependencies.lockfiles`, and `hack.dependencies.runtime-files` labels enable lockfile/runtime-keyed volumes shared across compatible worktrees.
190+
- `hack down --prune-caches` can remove only confirmed Compose-owned named volumes mounted exclusively at `.next` destinations or explicitly labeled `hack.cache.disposable=true`; it is confirmation-gated, requires `--yes` for JSON/scripted runs, and never performs broad volume pruning.
189191

190192
Workspaces (mux-managed, tmux-first by default):
191193
- Picker: `hack session` for persistent project workspaces.

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ This section is easy to find, but it does not lead the product story.
4848

4949
- New to `hack`: [Core docs](core.md)
5050
- Setting up with a coding agent: [Agent-first setup](guides/agent-first-setup.md)
51+
- Reviewing watcher, worktree-down, or disposable cache behavior:
52+
[Development runtime safety](guides/development-runtime-safety.md)
5153
- Looking for command or API details: [Extensions & reference](reference.md), including the generated [CLI reference](reference/cli.md)
5254
- Need unsupported experimental remote workflows: [Beta workflows](beta.md)
5355
- Need the bucket definitions: [Docs information architecture](docs-ia.md)

docs/cli.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,13 @@ same worktree. If the worktree's current branch would auto-target a different Co
166166
prints a warning naming both the existing and new targets. Pass `--branch <name>` to make the target
167167
explicit.
168168

169+
`hack down` applies a stricter ownership rule. When a linked worktree's Git branch was renamed and
170+
that exact checkout owns one differently named Compose runtime, implicit down targets the existing
171+
runtime instead of succeeding against an empty newly derived name. Running, Created, and stopped
172+
containers all count as ownership evidence. If the checkout owns multiple runtimes, implicit down
173+
fails and lists them; pass `--branch <name>` to select one. Exact checkout paths and Compose project
174+
families are required, so Hack never retargets to a sibling checkout.
175+
169176
A detached linked worktree has no branch name to derive, so these commands fail instead of silently
170177
targeting the base instance. Pass `--branch <name>` to select an isolated instance, or set
171178
`worktree.auto_branch` to `false` only when intentionally opting into the base instance.
@@ -177,6 +184,53 @@ Opt out:
177184

178185
The primary checkout is unchanged: no `--branch` means the base instance.
179186

187+
### Disposable cache volumes
188+
189+
Compose preserves named volumes on ordinary `down`, which is correct for application data but can
190+
leave branch-specific build caches behind. Use the explicit cleanup path when those caches are
191+
disposable:
192+
193+
```bash
194+
hack down --prune-caches
195+
# scripted:
196+
hack down --prune-caches --yes --json
197+
```
198+
199+
Hack recognizes named volumes mounted only at `.next` destinations as disposable Next build
200+
caches. For any other framework, library, or language, mark the top-level Compose volume explicitly:
201+
202+
```yaml
203+
services:
204+
web:
205+
volumes:
206+
- turbo-cache:/app/.turbo
207+
208+
volumes:
209+
turbo-cache:
210+
labels:
211+
hack.cache.disposable: "true"
212+
```
213+
214+
The label applies to the exact volume, so the same contract works for Rust `target`, Gradle, Go,
215+
Python, or other generated caches without relying on names or destination guesses.
216+
217+
Hack snapshots mounts before stopping the target and considers a volume removable only when all of
218+
these are true:
219+
220+
- the container belongs to the exact targeted Compose project and checkout;
221+
- the mount type is a named volume;
222+
- `docker volume inspect` independently reports both the exact
223+
`com.docker.compose.project` label and a `com.docker.compose.volume` label;
224+
- every observed destination's final path segment is `.next`, or the volume itself has
225+
`hack.cache.disposable=true`.
226+
227+
Interactive cleanup shows the exact volumes, services, and destinations and defaults to “no.”
228+
`--json` and other scripted use must add `--yes`; otherwise the command fails before down with
229+
`E_INTERACTIVE_REQUIRED`. Postgres, Redis, dependency, application-data, bind, external, unlabeled
230+
non-Next, and sibling-checkout volumes do not satisfy the removal contract unless a project author
231+
deliberately marks that exact Compose volume disposable. Hack deliberately does not run broad
232+
`docker volume prune` or `docker compose down -v`.
233+
180234
### Runtime host metadata
181235

182236
Containers started by `hack up`, `hack restart`, and `hack run` receive the effective instance and

0 commit comments

Comments
 (0)