Skip to content
Closed
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
4 changes: 3 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,9 @@ carriers follow only `HACK_NATIVE_STORAGE_TIMEOUT_MS`. Storage
proofs that run while a native `up` or `restart` builds and starts workloads do not
consume the start budget: their elapsed time extends the start deadline, and each
proof stays bounded by its own carrier budget. Each Docker command inside a carrier
keeps its fixed 10–15 second cap.
is bounded by that carrier's remaining budget (reads at most 60 seconds), so a slow
helper start on a loaded host can use the budget rather than a fixed 15-second cap.
Helper cleanup keeps its own 10–60 second bound.

The two variables bound different things:

Expand Down
34 changes: 29 additions & 5 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,12 @@ Use `--env=base` to bypass that default and read only `.hack/hack.env.default.ya
command that accepts `--env`; an overlay file named after one of those spellings is never
selected by name. Any other value must already be a canonical overlay name: lowercase
letters and digits joined by single hyphens (`^[a-z0-9]+(-[a-z0-9]+)*$`, for example
`qa` or `qa-2`); surrounding whitespace is ignored. Hack never rewrites a requested name,
so `QA`, `q_a` or `X=1` is refused with a fixed `Invalid --env value.` message that does
not repeat the argument, before any project file is read or any child process starts.
`qa` or `qa-2`), at most 128 characters long; surrounding whitespace is ignored. Hack
never rewrites a requested name, so `QA`, `q_a`, `X=1` or a name longer than 128
characters is refused with a fixed `Invalid --env value.` message (`E_USAGE` under
`--json`) that does not repeat the argument, before any project file is read or any
child process starts. The limit keeps every file name Hack derives from the overlay
name, such as the layer's lock, within the usual 255-byte file name limit.
See the [command and delivery matrix](#command-and-delivery-matrix) for what each command
does with the selection.

Expand Down Expand Up @@ -653,7 +656,28 @@ message in the envelope:
you cannot write (for example mode `0555`) both give this code.
- `E_NO_SPACE`: the disk or the disk quota is full.
- `E_PATH_CONFLICT`: a path is the wrong kind of entry, such as a file where Hack needs
a directory, or a directory where Hack needs a file.
a directory, a directory where Hack needs a file (including a `.hack/.gitignore` that
is a directory or a symlink), or a `.hack/.internal` that `hack env hydrate` finds to
be a symlink (dangling or not) or a file. A file name or path that is too long for
the filesystem is reported with this code too; use a shorter `--env` overlay name or
move the project to a shorter path.
- `E_LOCKED`: the project env key lock (`<git common dir>/.hack.secret.key.lock`, taken
by the first `--secret` value) stayed held for the whole 10-second wait. Nothing was
written. Another hack command may be creating the key; retry when it finishes. If no
other hack command is running, the lock is stale (for example its process ID now
belongs to an unrelated process, which Hack never treats as abandoned): remove the
lock file the message names, then retry.

The first `--secret` value also works on a filesystem without hard links, such as ExFAT
or FAT on a USB drive. Hack normally publishes the key file and its locks with a hard
link, so they appear complete or not at all. Where that is not possible, it creates an
empty placeholder exclusively and renames the complete file over it, which keeps the
same rules: an existing file or symlink is never replaced or followed, and a reader
never sees a partial file. If Hack is killed between those two steps, an empty
`.hack.secret.key` or lock file is left behind. Hack refuses it rather than use or
replace it; remove it and run the command again. ExFAT and FAT do not store Unix
permissions, so on such a drive the key file is not limited to your user the way mode
`0600` limits it elsewhere.

If you omit `key`, `value`, or `--service`, `hack env add` prompts for them interactively.
Pass `--no-interactive` (or set `HACK_NO_INTERACTIVE=1`) for scripted/agent usage: the
Expand Down Expand Up @@ -918,7 +942,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)
- 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; on a filesystem without hard links, such as ExFAT, an empty placeholder is visible for a moment before the complete key replaces it)
- 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
19 changes: 19 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,25 @@ more changes that contract, not just its cost:

Every command therefore still opens with a full proof.

**Host-side store checks (HACK-1440).** Each proof step also rechecks the private
store on the host: every held store directory gets an `lstat` identity, owner and mode
check, plus a canonical-path check. One xattr-witness `up` made about 3,100 such
rechecks. Two changes cut their file-system calls without dropping a check:

- A held directory beneath another held directory in the same recheck proves both
paths canonical with one `realpath`, because a symlink in any prefix would show in
the deeper path's result. Every directory keeps its own `lstat` checks.
- A material-authority assertion opens and closes with a full store check, so the
generation load between them no longer adds two more. The generation directory's
own hold, recheck and manifest and document identity checks still run.

Measured in-process (fake carrier, spied `node:fs/promises`) for the second `up` of
the same-birth xattr case on a macOS host with load average 60–196 on 18 CPUs, four
alternating trials each: `lstat` 18,834 → 13,452, `realpath` 12,821 → 3,685 and
`open` 4,542 → 3,944 calls per `up` (identical in every trial), with median system CPU
2.9 s → 1.9 s. An ordinary (USTAR) witness resume went from 1,298 / 922 / 280 calls to
1,118 / 282 / 260. Wall time on that host was dominated by load and is inconclusive.

**Revisit trigger.** Revisit this limit after the matched benchmark protocol
(HACK-1169) is frozen and its first complete revision-1 cohort has run, using that
cohort's measured warm startup and restart results. No proof-policy change may be made
Expand Down
15 changes: 15 additions & 0 deletions docs/reference/native-compose-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,21 @@ Hack does not kill a guessed guest PID or automatically stop the application on
client cancellation. Explicit owned `down` stops the containing instance while
retaining persistent data.

When the instance has enrolled persistent storage, saved `exec` proves that
storage before and after the guest command. The opening proof may reuse the
proof that command storage preparation just made under the same lease; the
closing proof after the command always runs fresh. Each proof has its own
`HACK_NATIVE_STORAGE_TIMEOUT_MS` carrier budget. The closing proof is
fail-closed: if it refuses (including a storage probe timeout, reported as
`E_COMPOSE_FAILED` with a `storage-*` stage), `exec` fails with that refusal even
though the guest command already ran and printed its output, and the command's
exit code is not reported. A storage result that cannot be proven after the
command must not look like a clean exec. The refusal keeps ownership and saved
state unchanged, and the next admission proves storage again. Retrying `exec`
runs the guest command again, so check its effects first when it is not
idempotent. A cancelled `exec` keeps its known exit code and starts no closing
proof.

`ps.pending` describes journaled engine mutations, not guest exec sessions. A
cancelled `exec` can therefore have no pending engine operation even when its guest
completion is unknown. An interrupted `run` retains its exact one-off and pending
Expand Down
26 changes: 21 additions & 5 deletions docs/reference/native-compose-storage-xattr-carrier.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,11 +415,27 @@ Budgets are layered and all finite:
the start deadline but capped in total at 16 × the storage budget or 1 hour,
whichever is smaller. A guard proof that would start after the cap is spent is
refused before it runs, and one that ends past it refuses as `storage-budget`.
- Inside a carrier every Docker probe and command keeps a fixed cap, clipped to
the carrier's remaining budget: 15 seconds for reads, target observation and
helper create/start, and 10 seconds for helper removal and cleanup reads, which
are not clipped. A recorded command's 15-second settlement budget follows its
exit.
- Inside a carrier every Docker probe and command is bounded by the carrier's
remaining budget (`nativeComposeStorageProbeTimeoutMs`), not by a fixed cap:
- Volume create and helper create/start get the whole remaining budget, so a
slow but healthy helper start on a loaded host completes while budget
remains, and a hung one times out exactly at the carrier's end. This holds
for recorded original commands too (the readonly `verify` proofs that
`exec` and `up` run): the child, its publication handshake and its identity
reads share that one bound.
- Reads and target observation get the remaining budget, at most 60 seconds
(the ownership probe's own ceiling).
- Helper removal and cleanup reads run even after the carrier's deadline, so
they are never clipped to it. Each has its own bound: the remaining budget
clamped to 10–60 seconds. A hung engine therefore cannot stall cleanup.

Earlier fixed caps (15 seconds for reads, target observation and helper
create/start; 10 seconds for cleanup) refused healthy `docker start -ai`
helpers on loaded hosted runners as `storage-helper-start`/`probe-timeout`
while most of the 90-second budget remained (HACK-1369). A timeout that ends
before the carrier's deadline still reports `probe-timeout`; one that reaches
it reports `storage-budget`. A recorded command's 15-second settlement budget
follows its exit.
- An unrecorded command (an identity read or a legacy root/seed command) waits
at most three seconds for its process group to become absent after exit. Like
the handshake and settlement budgets, this deadline is monotonic
Expand Down
3 changes: 2 additions & 1 deletion plugins/hack/skills/hack-diagnose/references/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ stay within the request.
| `E_PERMISSION` | A filesystem or runtime permission was denied, or a file Hack must write is on a read-only filesystem. | `hack doctor --json` |
| `E_NO_SPACE` | A file Hack must write could not be written because the disk is full or the disk quota is exceeded. | `df -h <path from the message>` |
| `E_PATH_CONFLICT` | A path Hack must read or write is the wrong kind of entry: a file where a directory is needed, or a directory or special file where a regular file is needed. | `ls -ld <path from the message>` |
| `E_LOCKED` | A lock Hack needs stayed held for the whole bounded wait, by another Hack command or by a stale lock Hack cannot prove abandoned; nothing guarded by it was written. | `ls -l <lock path from the message>` |
| `E_INTERACTIVE_REQUIRED` | The command needs an answer at a prompt but is running without one. | `hack <command> --help` |
| `E_USAGE` | Arguments or flags were invalid for the command. | `hack <command> --help` |
| `E_UNEXPECTED` | An unclassified failure; the message is the only detail. | `hack doctor --json` |

---

Content revision: `4c1343788d6a`. Generated by Hack; edit the canonical source and regenerate the plugin.
Content revision: `dc08a206e03d`. Generated by Hack; edit the canonical source and regenerate the plugin.
5 changes: 5 additions & 0 deletions src/agents/error-code-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ const ERROR_CODE_GUIDES: Readonly<Record<HackErrorCode, ErrorCodeGuide>> = {
"A path Hack must read or write is the wrong kind of entry: a file where a directory is needed, or a directory or special file where a regular file is needed.",
readFirst: "`ls -ld <path from the message>`",
},
E_LOCKED: {
meaning:
"A lock Hack needs stayed held for the whole bounded wait, by another Hack command or by a stale lock Hack cannot prove abandoned; nothing guarded by it was written.",
readFirst: "`ls -l <lock path from the message>`",
},
E_INTERACTIVE_REQUIRED: {
meaning:
"The command needs an answer at a prompt but is running without one.",
Expand Down
11 changes: 7 additions & 4 deletions src/cli/env-selection.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { parseEnvConfigSelection } from "../lib/project.ts";
import {
ENV_CONFIG_NAME_MAX_LENGTH,
parseEnvConfigSelection,
} from "../lib/project.ts";
import { CliUsageError } from "./command.ts";

/**
* Fixed refusal for an unusable `--env` value. It names the accepted grammar
* and never the argument, which may be a mistyped `KEY=value` or a pasted
* value.
*/
export const INVALID_ENV_SELECTION_MESSAGE =
"Invalid --env value. Overlay names must match ^[a-z0-9]+(-[a-z0-9]+)*$; use base, default or none for base layers only.";
export const INVALID_ENV_SELECTION_MESSAGE = `Invalid --env value. Overlay names must match ^[a-z0-9]+(-[a-z0-9]+)*$ and be at most ${ENV_CONFIG_NAME_MAX_LENGTH} characters; use base, default or none for base layers only.`;

/**
* Resolve a CLI `--env` value through the one shared selection grammar.
Expand All @@ -16,7 +18,8 @@ export const INVALID_ENV_SELECTION_MESSAGE =
* - `null`: base layers only. `base`, `default` and `none` (any case) and an
* empty value bypass the configured default overlay.
* - `string`: the overlay name, which must already be canonical
* (`ENV_CONFIG_NAME_PATTERN`); surrounding whitespace is ignored.
* (`ENV_CONFIG_NAME_PATTERN`) and at most `ENV_CONFIG_NAME_MAX_LENGTH`
* characters; surrounding whitespace is ignored.
*
* Anything else, including any value containing `=`, is refused with a fixed,
* value-free usage error. A requested name is never slugged: slugging
Expand Down
1 change: 1 addition & 0 deletions src/lib/cli-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const HACK_ERROR_CODES = [
"E_PERMISSION",
"E_NO_SPACE",
"E_PATH_CONFLICT",
"E_LOCKED",
"E_INTERACTIVE_REQUIRED",
"E_USAGE",
"E_UNEXPECTED",
Expand Down
8 changes: 5 additions & 3 deletions src/lib/compose-startup-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@ export function resolveComposeImageTimeoutMs(
* Budget of one native storage carrier (one fresh helper proof session), from
* `HACK_NATIVE_STORAGE_TIMEOUT_MS`. It is separate from the start budget:
* storage preparation, enrollment and every storage proof use it, never
* `HACK_COMPOSE_STARTUP_TIMEOUT_MS`. Each Docker probe inside a carrier keeps its
* own fixed 10-15 s cap, and an exhausted carrier refuses with the closed
* `storage-budget` effect reason. It grants nothing and relaxes no check.
* `HACK_COMPOSE_STARTUP_TIMEOUT_MS`. Each Docker probe inside a carrier is
* bounded by the carrier's remaining budget (cleanup keeps its own 10-60 s
* bound; see `nativeComposeStorageProbeTimeoutMs`), and an exhausted carrier
* refuses with the closed `storage-budget` effect reason. It grants nothing and
* relaxes no check.
*/
export function resolveNativeStorageTimeoutMs(
opts: {
Expand Down
Loading
Loading