Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ebbdb9c
test(agents): fix slow onboarding inventory tests
roodboi Oct 19, 2026
e43075c
fix(release): lift the prerelease installer archive member limit safely
roodboi Oct 19, 2026
254a7bf
test(runtime-core): cover job journal durability and cancellation neg…
roodboi Oct 19, 2026
56e20fe
test(e2e): qualify the P01, P02 and P07 pattern fixtures
roodboi Oct 19, 2026
d0eb3a6
feat(setup): preview and roll back migration to the skills-only plugin
roodboi Oct 19, 2026
119fa6a
fix(runtime): normalize image references at plan admission
roodboi Oct 19, 2026
7fd4887
feat(lifecycle): ownership-safe shared host helper contract and fixtures
roodboi Oct 19, 2026
38ea93b
feat(runtime-core): compact retired environment leases after confirme…
roodboi Oct 19, 2026
c4b7521
feat(runtime): quarantine a corrupted disposable build cache without …
roodboi Oct 19, 2026
0983d7a
fix(secrets): converge concurrent key provisioning and add sentinel s…
roodboi Oct 19, 2026
a6c15bd
test(e2e): read only the P02 checkout probe markers
roodboi Oct 19, 2026
5d0f337
fix(lifecycle): keep shared helper ownership exact across time zones …
roodboi Oct 19, 2026
d056fc8
fix(lifecycle): confirm helper port absence with a bind probe on ever…
roodboi Oct 19, 2026
d5d96ed
fix(runtime): test cache quarantine orchestration and add explicit re…
roodboi Oct 19, 2026
536b348
test(native): answer image admission in the branch startup tripwire
roodboi Oct 19, 2026
11436cd
fix(setup): rename setup migrate rollback flag to --rollback-manifest
roodboi Oct 19, 2026
7b17177
fix(runtime): give detached Compose image build/pull its own budget
roodboi Oct 19, 2026
f8cbd52
test: make process-timing tests tolerate host load
roodboi Oct 19, 2026
907726e
fix(runtime-core): compact relay-owned graph lease history at restore
roodboi Oct 19, 2026
73b0990
fix(lifecycle): count an unreaped zombie helper as stopped
roodboi Oct 19, 2026
e2dab3a
docs(lifecycle): require one shared stateDir and name per helper port
roodboi Oct 19, 2026
f26634e
train: merge #358
roodboi Oct 20, 2026
cc1c2e5
train: merge #343
roodboi Oct 20, 2026
8d6c642
train: merge #339
roodboi Oct 20, 2026
41ca2ba
train: merge #338
roodboi Oct 20, 2026
55c13d1
train: merge #332
roodboi Oct 20, 2026
765f1cd
train: merge #333
roodboi Oct 20, 2026
fe65b30
train: merge #336
roodboi Oct 20, 2026
939b745
train: merge #345
roodboi Oct 20, 2026
b670ce6
train: merge #347
roodboi Oct 20, 2026
cb07c4a
train: merge #348
roodboi Oct 20, 2026
c87db3f
train: merge #349
roodboi Oct 20, 2026
6143e17
train: merge #354
roodboi Oct 20, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -402,4 +402,4 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run process and terminal regressions
run: bun test tests/daemon-command.test.ts tests/daemon-orphan.test.ts tests/host-exec-lifetime.test.ts tests/host-exec-tty.test.ts tests/shell-observation.test.ts
run: bun test tests/daemon-command.test.ts tests/daemon-orphan.test.ts tests/host-exec-lifetime.test.ts tests/host-exec-tty.test.ts tests/shell-observation.test.ts tests/shared-host-lifecycle.test.ts
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ Projects can declare shareable env overlays and safely inject secrets into compo
At runtime, hack resolves the selected overlay and injects the effective env directly into compose and
host-side command flows. For service-scoped values, hack still generates
`.hack/.internal/compose.env.override.yml` so compose services get the right per-service environment
without requiring `.hack/.env` to exist.
without requiring `.hack/.env` to exist. That file holds resolved plaintext, so it is written like
`.hack/.env`: atomically, mode `0600`, and never through a symlink.

See `docs/env.md` for the current env model and migration notes.

Expand Down
55 changes: 39 additions & 16 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ require installation. Repair only affected targets within the authorized scope;
Hack-owned integrations; add `--install-missing` to create missing ones (see
[integration setup](integrations.md#refresh-only-hack-setup-sync)). Read updated guidance after
repair; restart only if the client cannot reload changed hooks or skills.
To move from standalone skill and rule files to the `hack` plugin, preview with
`hack setup migrate`, then run `hack setup migrate --apply` after the plugin loads; apply writes a
backup manifest that `hack setup migrate --rollback-manifest <manifest>` restores (see
[migrate standalone files](integrations.md#migrate-standalone-files-to-the-plugin)).
Ordinary commands, `hack update`, and `hack doctor --fix` never render, repair, remove, or otherwise
mutate integration files. CLI upgrades do not trigger integration updates.

Expand Down Expand Up @@ -209,27 +213,45 @@ Hack scans `.npmrc`, `.yarnrc.yml`, and `bunfig.toml` for `${VAR}` credential re
with `E_ENV_KEY_MISSING` when the selected Hack overlay cannot supply them. Values are never
printed.

Detached Compose startup has a 90-second budget by default. For a slow first
install or container launch, set the host CLI environment explicitly:
Detached Compose startup runs in two phases, each with its own budget:

| Phase | Compose invocation | Default | Variable | Range (ms) |
| --- | --- | --- | --- | --- |
| Image build/pull and container creation | `up --no-start` | 30 minutes | `HACK_COMPOSE_IMAGE_TIMEOUT_MS` | 1,000–14,400,000 |
| Start and dependency wait | `up -d --no-build` | 90 seconds | `HACK_COMPOSE_STARTUP_TIMEOUT_MS` | 1,000–3,600,000 |

A first-time build or a large image pull therefore does not consume the start
budget. Raise the budget for the phase that was hit, on the host CLI invocation
rather than in a container's environment:

```sh
# Slow first build or pull of a large image
HACK_COMPOSE_IMAGE_TIMEOUT_MS=3600000 hack up --detach
# Slow container start or a dependency that is slow to become healthy
HACK_COMPOSE_STARTUP_TIMEOUT_MS=300000 hack up --detach
HACK_COMPOSE_STARTUP_TIMEOUT_MS=300000 hack restart
```

The value must be an integer from 1,000 to 3,600,000 milliseconds; detached startup
and restart reject invalid values before project preparation or teardown. Full and service-scoped
startup/restart use the same setting. The startup phase notice and
`E_STARTUP_TIMEOUT` message report the effective budget. It covers each detached
Compose invocation, including image/container launch and dependency waiting, not
lifecycle hooks or an end-to-end application readiness deadline. A restart repair
attempt has its own invocation budget and is reported separately. Foreground
`up` stays unbounded; `down` retains its separate 90-second limit.

On timeout, Hack terminates the owned Compose process group. Containers may still
be running: inspect `hack ps` and `hack logs` before retrying. Raising the budget
does not fix a failed installer or prove readiness. Set this on the host invocation,
not merely in a container's environment.
Detached startup and restart reject invalid values before project preparation or
teardown. Full and service-scoped startup/restart use the same settings. The
startup notice reports both effective budgets. On expiry, `E_STARTUP_TIMEOUT`
names the phase that was hit, its budget, and the variable that raises it; with
`--json`, `error.detail` carries `composePhase` (`images` or `start`),
`budgetMs`, and `budgetEnv`. The start budget covers container start and Compose
`depends_on` waiting, not lifecycle hooks or an end-to-end application readiness
deadline. A restart repair attempt has its own budgets and is reported
separately. Foreground `up` stays a single unbounded Compose invocation; `down`
retains its separate 90-second limit.

On timeout, Hack terminates the owned Compose process group of that phase. After an
image-phase timeout no container was started by that attempt, but containers may
have been created or recreated. After a start-phase timeout containers may still be
running. Inspect `hack ps` and `hack logs` before retrying. Raising a budget does not
fix a failed installer or prove readiness.

Services with `pull_policy: always` still pull again in the start phase because
Compose applies that policy on every `up`; such pulls count against the start
budget.

To share dependency data only across worktrees with the same lockfile/runtime fingerprint, label
the bootstrap service with a logical top-level Compose volume:
Expand Down Expand Up @@ -292,7 +314,8 @@ be mistaken for a ready one. No project lifecycle hook or unrelated dependency i
A failed installer leaves existing consumer containers untouched; JSON lifecycle commands return
`E_DEPENDENCY_BOOTSTRAP_FAILED`. Each initializer in detached scoped `up` and targeted `restart` uses the configured
`HACK_COMPOSE_STARTUP_TIMEOUT_MS` budget and reports `E_STARTUP_TIMEOUT` on expiry.
The subsequent Compose launch has its own equal budget. Consumer `run` and foreground
That budget also covers any installer image pull or build, because `run` acquires
the image itself. The subsequent Compose launch has its own phased budgets. Consumer `run` and foreground
scoped `up` retain a separate ten-minute initializer deadline.

## Branch instances and linked worktrees
Expand Down
6 changes: 5 additions & 1 deletion docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ Write-path guarantees in a linked worktree (first secret added from any checkout
- otherwise an existing shared key (git common dir) is reused
- otherwise the primary checkout's key is adopted by copying it to the shared location, so sibling checkouts converge on one key
- only then is a new key generated, and it is written to the shared location — never silently to the checkout
- concurrent first use from several checkouts converges on one key: when no key exists, creation runs under a cooperative lock at `<git common dir>/.hack.secret.key.lock`, and a key file is published only when complete, never empty or partial (Hack v4 writers do not take the lock)
- if the shared location cannot be resolved (git unavailable) or written, Hack falls back to a checkout-local key and prints a loud divergence warning
- `hack doctor` flags divergent `.hack.secret.key` contents across checkouts with the exact paths

Expand Down Expand Up @@ -652,7 +653,10 @@ Some older surfaces are still compatibility-oriented:
`<store>.lock` so concurrent writers, including separate processes, do not lose
updates. A store path that is a symlink is refused for writes. Explicit key
provisioning also uses an atomic mode-`0600` replacement and refuses symlinked
key paths or immediate parent directories. This protects individual key writes;
key paths or immediate parent directories. A key generated on first use is
published only when no key file exists, so concurrent first users adopt the key
published first instead of replacing it under a store already encrypted with
it. This protects individual key writes;
it does not make key replacement and store re-encryption a crash-safe rotation
transaction. Keep the previous key until recovery has been verified. The v1 store
envelope and legacy `v1:` values authenticate no associated data: they are not
Expand Down
5 changes: 3 additions & 2 deletions docs/guides/candidate-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ from the retained manager, before publishing a version or changing selection. Th
older manager itself refuses the newer layout as foreign files.

To update a reviewed predecessor manager, including the managers shipped with
`5.0.0-next.1` and `5.0.0-next.2`, review the newer installer from the tag you are
upgrading to, then run that saved file explicitly. For example, before upgrading a
`5.0.0-next.1`, `5.0.0-next.2` and `5.0.0-next.3` through `5.0.0-next.5`, review
the newer installer from the tag you are upgrading to, then run that saved file
explicitly. For example, before upgrading a
`5.0.0-next.2` channel to `5.0.0-next.3`:

```sh
Expand Down
143 changes: 140 additions & 3 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,17 +517,38 @@ OrbStack, registry credentials, or a running guest:

```sh
./hack-native --candidate-root /absolute/private/candidate-home runtime fetch-image \
--reference 'namespace/repository@sha256:REVIEWED_MANIFEST_DIGEST' \
--reference '[namespace/]repository@sha256:REVIEWED_MANIFEST_DIGEST' \
--archive /absolute/private/new-image.tar
```

For an existing mutable image declaration, resolve it explicitly first:

```sh
./hack-native --candidate-root /absolute/private/candidate-home runtime resolve-image \
--reference 'namespace/repository:tag' --json
--reference '[namespace/]repository:tag' --json
```

Image references are normalized as the Docker CLI does before any request:
`alpine` is `docker.io/library/alpine:latest`, `alpine:3.21` is
`docker.io/library/alpine:3.21`, and `user/img` is `docker.io/user/img:latest`.
`index.docker.io` becomes `docker.io`. Tags, digests, other registries and
ports are kept as written; only public Docker Hub `namespace/repository`
references are acquired. Repository names must be lowercase, and a normalized
name is limited to 255 bytes. Uppercase digests and malformed registry ports are
refused. Check a reference without network, credentials or candidate state:

```sh
./hack-native --candidate-root /absolute/private/candidate-home runtime admit-image \
--reference 'alpine:3.21' --json
```

It returns the canonical `reference` and a `kind` of `docker_hub` or
`image_id`; any other registry refuses with `registry_image`. Native foreground
`hack up` runs this admission for every authored service image before startup
hooks, the VM or any provider starts. Compose plan review applies the same
syntax rules, keeps the authored image text in the reviewed plan, and does not
change existing plan identities.

Omitting the tag selects `latest`. Resolution returns `pinned_reference`,
`source_digest`, `manifest_digest`, `image_id` and `platform`; it downloads only
bounded metadata and verifies one unambiguous Linux ARM64 image configuration.
Expand Down Expand Up @@ -777,7 +798,8 @@ restored one-off remain open. An uncertain shutdown retains its mapping and evid
for recovery; do not delete managed state to retry.

After starting an owned native pool, `runtime ensure-image --reference REF --json`
resolves a public Docker Hub tag or accepts an explicit digest, reuses the verified
resolves a public Docker Hub tag (including shorthand such as `alpine:3.21`) or
accepts an explicit digest, reuses the verified
archive cache, and imports through the existing content-ID-checked loader. Tags
are resolved on each call; a warm explicit digest needs no registry request.
The cache is limited to 16 archives and 2 GiB; an incomplete or conflicting entry
Expand Down Expand Up @@ -1516,6 +1538,121 @@ the dead predecessor publication. That selection belongs to the interrupted
operation; replacing a selection from an older restored generation still requires
independent restore-history proof.

### Quarantine a corrupted disposable build cache

Some frameworks keep a regenerable build cache inside a named volume next to other
generated output. When such a cache is corrupted (for example a Turbopack checksum
mismatch reported as a misleading `Module not found`), you can move exactly that
cache directory aside. Nothing is deleted, and other volumes and directories are
unchanged.

First declare the regenerable directories on the volume in the Compose file. Paths
are relative to the volume root. List at most eight distinct directories; one entry
cannot contain another:

```yaml
volumes:
www_next:
x-hack-disposable-cache:
- dev/cache/turbopack
```

The declaration is part of the reviewed plan. It is recorded in the graph receipt
when the graph is admitted, and only a receipt-recorded entry can be selected later.
A volume that is also a shared dependency cache cannot declare disposable
directories. Declaring a path grants no automatic cleanup.

Stop the graph with an ordinary retaining `down` (phase `stopped-data-retained`).
With the owned runtime running, inspect the single target. This step is read-only:

```sh
./hack-native --candidate-root /absolute/private/candidate-home graph inspect-cache-quarantine \
--run-id <run-id> --volume www_next --path dev/cache/turbopack --json
```

The response reports the target's `target_bytes` (regular-file bytes),
`allocated_bytes`, `entries` and a `selection_sha256`. The selection binds the exact
receipt bytes, the volume name, creation time and root directory identity, the
target directory identity and the measured bytes. Review it, then quarantine:

```sh
./hack-native --candidate-root /absolute/private/candidate-home graph quarantine-cache \
--run-id <run-id> --volume www_next --path dev/cache/turbopack \
--expect-selection <selection_sha256> --json
```

These commands, and `resolve-cache-quarantine` below, hold the provider mutation
lease. They refuse when any of these is true:

- the graph is active, its journal is incomplete, or interrupted cleanup is pending
- the path is not exactly a declared entry, or is absolute, traversing or empty
- the volume is missing, carries foreign ownership labels, or has changed
incarnation
- any container mounts the volume, including stopped containers
- a component of the path, or the target itself, is a symbolic link or not a
directory
- the target directory was replaced after inspection

The guest renames the directory under a lock on the volume root into
`/var/lib/docker/volumes/<volume>/hack-quarantine/<id>`. That directory is beside
the volume's `_data` root, and no container can mount it. Before the rename the
guest checks that the source and the quarantine directory are on the same mount
(the same `findmnt` mount ID, not only the same device number). A rename within
one mount cannot fail with a cross-device error, so `mv` never falls back to
copying and then deleting the source. The result reports `quarantined_bytes` and
`reclaimed_bytes: 0`. The bytes remain for diagnosis until the owning volume
itself is explicitly removed (for example `graph cleanup --remove-data`). Restart,
restore and ordinary cleanup never collect them. Then use a normal retaining
restart. The framework regenerates its cache.

Quarantined bytes are outside the volume's `_data` directory. Docker's disk-usage
report, and therefore `graph storage-inventory`, does not count them, so the inventory
undercounts a volume that holds a quarantine. Every `inspect-cache-quarantine`
result, including `target_absent`, reports `retained_quarantine_records` and
`retained_quarantine_bytes`: the recorded bytes still held in the current
incarnation of that volume.

A private record under the candidate home's `run/cache-quarantine/` is written
before the guest effect and marked `quarantined` after the guest syncs the move.
If the command is interrupted, `inspect-cache-quarantine` reports the recorded
digest with one of two phases:

- `resume_required`: the receipt and volume are unchanged. Repeat
`quarantine-cache` with the same selection. Resumption accepts the recorded
directory either still at its source or already at its destination with the
same inode. It refuses a regenerated directory, a different destination or
both present, and never moves new bytes.
- `resolve_required`: resumption is no longer possible, for example because a
restart changed the receipt, or because the move keeps refusing. Settle the
record without moving anything:

```sh
./hack-native --candidate-root /absolute/private/candidate-home graph resolve-cache-quarantine \
--run-id <run-id> --volume www_next --path dev/cache/turbopack \
--expect-selection <recorded selection_sha256> --json
```

Under the same lease, consumer and ownership checks, the guest reads the
recorded destination under the volume root lock. The record becomes
`quarantined` when the recorded directory (same inode) is there, and
`abandoned` when the destination is absent, which means nothing was moved. Any
other layout, such as a symbolic-link quarantine directory or a different
directory at the destination, refuses. After resolution, inspect again for a
fresh selection.

You can also resolve a record that could still be resumed. While a record is
pending, other quarantines of that volume are refused. A torn record write
refuses automatic replay. Records are retained as diagnosis evidence and are not
collected automatically. Each volume admits at most 16 records, and the candidate
home holds at most 1024. Volume names are specific to a run, so a new run starts
with a fresh per-volume budget. When the global limit is reached, every new
quarantine is refused; this candidate has no command that collects records.

This operation does not classify the cause of the corruption. One checksum error
does not show an interrupted write or a storage integrity fault. Container health
also does not prove that application routes work; check the public route
separately after restart.

### Explicit cleanup after a dead foreground owner

For a fully ready graph whose foreground owner died while its pool remains on the
Expand Down
8 changes: 8 additions & 0 deletions docs/guides/prereleases.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ against the existing root, then run `upgrade`. See
[upgrade and return to a retained candidate](candidate-install.md#upgrade-and-return-to-a-retained-candidate).
A fresh `--root` does not need this step.

Channels created by the `5.0.0-next.3`, `next.4` or `next.5` installer share one
manager. It accepts at most 17 archive members, the exact size of the layout those
releases ship, and no file names beyond it. Later installers use a fixed 64-member
ceiling, but every member must still be a reviewed name in a complete group. A
later bundle that adds a file therefore needs the same `upgrade-manager` step
first. Upgrading with that newer installer against such a channel also refuses
until you run it, and leaves the channel unchanged.

A truncated, interrupted or oversized official download is a refused installation.
The installer removes only the staging directory that attempt created. It leaves
the current selection, retained bundles and homes unchanged. Retry the same version
Expand Down
Loading
Loading