Skip to content

fix(native): give storage proofs their own budget outside the start phase - #375

Merged
roodboi merged 6 commits into
nextfrom
wf/g30-native-e2e-budgets
Oct 22, 2026
Merged

roodboi merged 6 commits into
nextfrom
wf/g30-native-e2e-budgets

Conversation

@roodboi

@roodboi roodboi commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fix unit G30: native e2e and storage budgets (HACK-1324, HACK-1320, HACK-1321).

HACK-1324: storage preparation gets its own named budget

Root cause, confirmed on three hosted first attempts (#365 twice, #370):

  • Every storage carrier took its absolute deadline from the start budget
    (Date.now() + resolveComposeStartupTimeoutMs() in
    src/lib/native-compose-command-storage.ts). native-config-down-hooks runs
    with HACK_COMPOSE_STARTUP_TIMEOUT_MS=15000, so its first storage carrier had
    15 s for the dependency check, target observation and helper
    create/policy/start. On loaded Blacksmith runners preparation alone takes
    longer, and the scenario failed at 18.3-18.5 s with storage-target,
    storage-helper-*, probe-timeout/probe-cancel/unclassified.
  • In native-config-build, about a dozen full storage proofs run in the build
    and pre-spawn guards (fresh() in buildNativeComposeImages, then
    runNativeComposeOwnedFileChild). They consumed the 90 s start deadline, so
    the spawn admission refused (compose-child / private-uncertain, run
    39676161586) or the outcome was E_STARTUP_INCOMPLETE (run 39663510147).

Changes:

  • HACK_NATIVE_STORAGE_TIMEOUT_MS (resolveNativeStorageTimeoutMs, default
    90 s, range 1,000-600,000 ms) bounds each storage carrier. It is never derived
    from the start budget. Default behavior on an unset host is unchanged (the old
    start default was also 90 s).
  • createNativeComposeStartDeadline: guard storage proofs during a native
    up/restart are excluded from the start budget; their monotonic elapsed time
    extends the start deadline. Overlaps count once, rejected proofs still count,
    the deadline never moves earlier, and each excluded proof stays bounded by its
    own carrier budget. Build and spawn admission read the live deadline.
  • Whole-phase cap on excluded storage time (7daca0d): the total excluded from one
    up/restart start deadline may not exceed 16 x HACK_NATIVE_STORAGE_TIMEOUT_MS
    or 1 hour, whichever is smaller (24 min at the 90 s default; 1 h from S = 225 s
    up). The deadline moves at most that far; a guard proof that would start past
    the cap is refused before it runs; one that ends past it refuses the phase with
    a closed, value-free NativeStorageTotalBudgetError (effect reason
    storage-budget, stage guard-storage). The phase ends within the start
    budget + the cap + at most one in-flight guard proof.
  • Closed effect reason storage-budget: a refusal that finds its carrier's own
    deadline passed (not cancelled) names the budget; the human summary names
    HACK_NATIVE_STORAGE_TIMEOUT_MS.
  • Scenario budgets: build hack commands 120 s -> 300 s, down-hooks 90 s -> 240 s.
    Both scenarios now log per-command duration plus closed storage phase timings
    from the passive phase trace, so hosted runs record measured preparation
    durations.

No check, ownership fence, retention or recovery rule changed; per-probe caps
(10-15 s) are unchanged.

HACK-1320: networks whole-run budget

  • Named NATIVE_NETWORK_FIXTURE_SCENARIO_TIMEOUT = 90 min. 44 measured hosted
    passes took 2113-3494 s (median ~2650 s); this PR's own run hit 3494 s, 97% of
    the old 60-minute budget. 90 min keeps 1.55x the slowest pass; with cleanup and
    two command bounds the hard bound stays inside the 150-minute shard job.
  • Whole-run and cleanup budgets now gate only the start of a command; a started
    command keeps its full 180 s. Run 39431629301 failed because the remaining
    whole-run budget clipped and SIGKILLed an in-flight down carrier command,
    leaving a created helper that saved down --recover correctly refuses to
    repair.

HACK-1321: M6 and monotonic group absence

  • The unrecorded-command group-absence deadline is now monotonic
    (performance.now), named UNRECORDED_GROUP_BUDGET_MS (3 s).
  • New fake-clock test pins the probe-budget refusal on the no-record path.
  • Carrier doc lists the fixed 10-15 s probe caps.

Release note

  • Storage carriers no longer follow HACK_COMPOSE_STARTUP_TIMEOUT_MS in either
    direction (a short start budget no longer shortens them, a long one no longer
    lengthens them). Hosts that set HACK_COMPOSE_STARTUP_TIMEOUT_MS should also
    review HACK_NATIVE_STORAGE_TIMEOUT_MS, which now sets both the per-carrier
    bound and the whole-phase storage cap.

Evidence

Rework at 7daca0d (verifier gaps)

  • Wiring test: tests/native-compose-start-storage-exclusion.test.ts runs the
    real source CLI up with a 3 s start budget. The Docker stand-in reads the
    CLI's own phase trace (stderr written to a file, so it is on disk before the
    carrier spawns Docker) and delays the carrier dependency check (image inspect) by 4 s only while a guard.storage span is open. No call counting.
    About 43 s on a loaded Mac (the earlier prototype took about 80 s). Mutation:
    with start.excludeStorage(...) removed around the guard's storage.verify,
    the test fails (up exits 1, the workload is never admitted); restored, it
    passes.
  • Cap tests in tests/compose-startup-budget.test.ts (fake clocks). Each of four
    mutations fails them: no post-proof check, no entry check, no min on the
    deadline extension, and the cap error not classified as storage-budget.
  • Docs: docs/cli.md, docs/reference/native-compose-commands.md and
    docs/reference/native-compose-storage-xattr-carrier.md describe the per-proof
    bound, the phase cap and how the two variables interact.
  • Gates: CLI and root typecheck, root bun run check (privacy-check ok), biome
    on the changed files (only an existing complexity warning in
    native-compose-command.ts), gitleaks 8.30.1 over origin/next..HEAD (no leaks).
  • M3 full suite at 7daca0d: 7721 pass, 126 skip, 1 fail
    (repository catalog > the checked-in .hack/tasks.json recipes are all reviewed). It passes locally at the same head and in hosted test, and this
    PR does not touch .hack/ or the task catalog. I could not rerun it on M3 next
    because SSH to M3 failed (local agent signing error).
  • Hosted CI at 7daca0d: all checks pass, including all four docker-e2e shards.

Measured storage durations (hosted shard 4, this branch, 9 attempts)

From the new per-command phase-trace lines:

Measure Range across 9 attempts
Longest single storage proof 3.6-11.7 s
Cold enrollment (storage.enroll, first up) 11.7-35.0 s
native-config-build first up 50-176 s, of which 16 proofs took up to 145 s

Old behavior on the slowest attempt: the 145 s of guard proofs alone exceed the
90 s start budget, and the 35 s cold enrollment exceeds the 15 s budget the
down-hooks scenario gave every carrier. New budgets: 90 s per carrier (2.6x the
slowest observed carrier work, 7.7x the slowest proof); build hack commands
300 s (1.7x the slowest up); down-hooks hack commands 240 s.

Hosted CI on this branch (11 attempts across 0af0fb6, 94ce188, cee65eb, 4e2b056)

Scenario Passes Durations
native-config-build 11/11 116.7-336.9 s
native-config-down-hooks 11/11 84.9-181.4 s
native-config-networks 11/11 1766-3494 s

The merged head 4e2b056 (next a25160d merged in) passed both of its full
runs (run 39721132414 attempts 1 and 2). Before this change, shard 4 failed on
first attempts of #365, #370, #374 and on next itself (39663080763,
39670689406).

Other attempt failures, all unrelated to these files:
tests/native-authored-project-start.test.ts frontend cascade (also seen on 6+
other branches and next), tests/private-env-file.test.ts symlinked .env
ENOENT, and one runtime-core Rust test
(rebind::tests::healthy_fixed_selection_is_noop_and_complete_shared_slot_refreshes_together).

Gate vs PR shard-4 comparison

Same runner pool and labels (blacksmith-4vcpu-ubuntu-2404, runner 2.337.0),
identical shard composition and order, no leftover volumes before
native-config-build (end-of-job cleanup volumes are the failed scenarios'
own retained fixtures). The failing jobs (including next push run
39663080763) ran unrelated earlier scenarios 1.3-1.9x slower, so the
difference is runner speed, not composition or state. The logs print no
docker info storage-driver lines, so that dimension could not be compared.

Mutation proofs (each killed by a focused test)

M6 (groupAbsent deadline -> plain refuse()), M6b (wall-clock deadline),
carrier deadline from the start budget, no start-deadline exclusion,
nested exclusion double-count, build deadline read once, carrier reason ignores
budget, budget reported over cancellation, xattr lifetime unmarked, effect
reason ignores mark, summary not naming the variable, networks command clipped
to the whole-run remainder.

Local and M3 gates

  • bun run typecheck and bun run check: 0 errors at 4e2b056.
  • gitleaks 8.30.1 over origin/next..HEAD: no leaks.
  • M3 full suite at 0af0fb6: 7587 pass, 126 skip, 0 fail.
  • M3 full suite at 4e2b056 (host load 25-39): 7681 pass, 126 skip, 12 fail.
    10 of the 12 pass when their files rerun serially; the other 2
    (tests/native-authored-project-recovery.test.ts no-host death/recovery)
    fail the same way on plain next a25160d. None touch the changed code.
  • M3 is not a qualifying host for the native e2e scenarios; hosted CI is the
    e2e proof.

Gaps

  • The whole-phase cap is proven at unit level only. A command-level cap test
    cannot isolate it, because at S = 1 s the per-carrier budget fires first.
  • An in-flight guard proof is not aborted when the cap is reached. It finishes
    within its own per-carrier bound, then the phase refuses.
  • The networks scenario still SIGKILLs a command that exceeds its own 180 s
    bound; that genuine hang still leaves storage evidence for explicit recovery.

🤖 Generated with Claude Code

roodboi and others added 3 commits October 21, 2026 17:50
…hase

Native storage carriers took their deadline from HACK_COMPOSE_STARTUP_TIMEOUT_MS,
and guard storage proofs consumed the start deadline of up/restart. On loaded
hosted runners storage preparation alone exceeded a 15 s start budget
(native-config-down-hooks) and a dozen build/spawn guard proofs exhausted the
90 s start budget (native-config-build).

- HACK_NATIVE_STORAGE_TIMEOUT_MS (default 90 s, 1-600 s) bounds each storage
  carrier; it is never derived from the start budget.
- Guard storage proofs during native up/restart extend the start deadline by
  their monotonic elapsed time instead of consuming it.
- A refusal that finds its carrier budget exhausted reports the closed effect
  reason storage-budget, and the summary names the variable.
- Unrecorded command group absence uses a monotonic deadline; a fake-clock test
  pins its probe-budget refusal (kills mutation M6).
- Network acceptance: named 75-minute whole-run budget that gates command
  starts only, so it never kills an in-flight carrier command.
- Build/down-hooks scenarios get measured command budgets and log closed
  per-command storage timings.

Refs HACK-1324, HACK-1320, HACK-1321

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The slowest of 42 hosted passes (2113-3343 s) used 93% of the old 60-minute
budget; 80 minutes keeps 1.44x that pass inside the 150-minute shard job.

Refs HACK-1320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This PR's own hosted run passed in 3494 s, 97% of the old 60-minute budget
and 73% of the 80-minute one. 90 minutes keeps 1.55x the slowest of 44
measured passes and stays inside the 150-minute shard job.

Refs HACK-1320

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@blacksmith-sh

This comment has been minimized.

roodboi and others added 3 commits October 21, 2026 23:01
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uard exclusion

Guard storage proofs extend the up/restart start deadline instead of consuming
it, but their sum had no bound. The excluded total is now capped at 16 x
HACK_NATIVE_STORAGE_TIMEOUT_MS or 1 hour, whichever is smaller: the deadline
moves at most that far, a proof that would start past the cap is refused before
it runs, and a proof that ends past it refuses the phase with a closed,
value-free error whose effect reason is storage-budget.

Add a command-level failure-injection test for the start.excludeStorage wiring.
The Docker stand-in reads the CLI's own phase trace and delays the carrier
dependency check only while a guard.storage span is open, so the guard proof
outlasts a 3 s start budget. It fails with the wrap removed. Add unit tests for
the cap, each failing under mutation, and document the per-proof bound, the
phase cap and how the two variables interact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 22, 2026 13:34
@roodboi
roodboi merged commit 616407a into next Oct 22, 2026
16 checks passed
@roodboi
roodboi deleted the wf/g30-native-e2e-budgets branch October 22, 2026 13:34
roodboi added a commit that referenced this pull request Oct 22, 2026
Brings in #375, #407 and #412. #407 changes the doctor .hack/.gitignore
repair helper and the untrack step; this branch's adopted-project branch of
that step still skips the .hack/.gitignore writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi added a commit that referenced this pull request Oct 22, 2026
…ough it, and support adopted projects (#392)

## Summary

Two `hack doctor` fixes.

- **HACK-1316:** doctor no longer follows a symlinked `.hack` or `.hack/.internal`. Before this
  fix, `doctor --fix` deleted a file outside the project.
- **HACK-1311:** doctor now reports an adopted (`hack config adopt`) project correctly, and
  `doctor --fix` completes in one. Before this fix, doctor refused the project as native input,
  the `--fix` plan threw, and `--fix` with tracked generated files failed after it had already
  changed the Git index.

## Rework after review (commit `ff7f11e1`)

The verifier found one blocking defect in the first version (`ffbc4ffd`):

- **Defect.** In an active adopted project whose generated files are tracked by Git (for example
  a committed `.hack.secret.key`), `hack doctor --fix --no-interactive` exited 1 with
  `E_NATIVE_PROJECT_UNSUPPORTED`. It did this after `git rm --cached` had already run, so
  `.hack/.gitignore` was not written and the later fix steps (hackd, Docker networks, Caddy and
  the rest) never ran.
- **Root cause.** `maybeUntrackGeneratedFiles` selected the adopted project, ran `git rm
  --cached`, and then called `ensureHackDirGitignore`. That writer calls
  `assertLegacyProjectDirectory`, which throws `ProjectInputSelectionError("native")` for an
  active adoption (`src/lib/project-input-selection.ts`).
- **Fix.** For an adopted project, the step now changes only the Git index. It still adds a
  committed `.hack.secret.key` to the root `.gitignore`, which is not an adoption input. It
  leaves the adoption's `.hack/.gitignore` unchanged and says so in a closed note: `Removed N
  path(s) from the git index. Left .hack/.gitignore unchanged: an adopted Compose project's
  .hack/ belongs to 'hack config adopt'.` The plan line names the same change. Legacy projects
  behave exactly as before.
- **Docs.** `docs/reference/native-compose-adoption.md` now matches the code: `--fix` leaves
  `.hack/.gitignore` unchanged and adds a committed key to the root `.gitignore`.
- **Test gaps closed.**
  - The `findDoctorLegacyProjectContext` guards on the later `--fix` steps had no regression
  test. The new test runs the whole `--fix` flow with Docker reachable and `--migrate-env-
  config`, so the lifecycle, incomplete-runtime and env-migration steps run against the adopted
  project.
  - The writer's refusal before `mkdir` (HACK-1316) had no test. A new unit test covers it.

## Update: merge with `next` at `5c9cc82e` (commits `0d6aa587`, `51244fc9`)

- `0d6aa587` merges `origin/next` at `5c9cc82e` (#375, #407, #412) with no conflicts. #407
  changes the same `.hack/.gitignore` code in doctor: the repair helper now returns `{ changed,
  readOnly }` and the untrack step can leave a read-only file alone. After the merge, the
  adopted branch of the untrack step still skips the `.hack/.gitignore` writer, and only the
  legacy branch uses #407's read-only handling.
- `51244fc9` is a one-line docs fix. `docs/reference/native-compose-adoption.md` listed every
  check that doctor skips for an adopted project except the `.hack/.gitignore` check. The code
  already skips it.
- **The verifier's repro, rerun on the merged head.** It is the verifier's own scratch test:
  `retainedSourceBindFixture`, a real `git init`, `git add -f .hack.secret.key`, adopt (prepare
  and publish), then `runCli doctor --fix --no-interactive`, with shell `run` mocked. The output
  has no `E_NATIVE_PROJECT_UNSUPPORTED`. `git ls-files` is empty, `.hack/.gitignore` is not
  created, the closed note above is printed, the adoption is still `active`, and the later steps
  run (the global `daemon start` call and the closing `Re-run: hack doctor` note).
- **Why that run still exits 1.** Doctor's exit code comes from the checks that run before
  `--fix`. This is existing behavior and is the same for legacy projects. The verifier's harness
  makes every non-Git `exec` fail, so `docker daemon` is an error, and the committed key is an
  error in the `generated files` and `env key` checks. The PR's `doctor-fix-adopted-project`
  test makes Docker reachable. Its `.hack/.env.state.json` case exits 0. Its key case exits 1
  only because of the committed-key error.
- **Mutations rerun on the merged head**, each killing its test for the intended reason:

| Mutation | Result |
  | --- | --- |
  | Adopted untrack step writes `.hack/.gitignore` again (`ffbc4ffd` behavior) | `doctor-fix-
  adopted-project` 0/2, with `E_NATIVE_PROJECT_UNSUPPORTED` in the output |
  | `findDoctorLegacyProjectContext` no longer skips an adopted project (survived the first
  review) | `doctor-fix-adopted-project` 0/2, with `E_NATIVE_PROJECT_UNSUPPORTED` |
  | Writer's pre-`mkdir` refusal removed (survived the first review) | `compose-env-at-rest`
  15/16; "the writer refuses a symlinked .hack before creating .internal behind it" fails |

## Root cause

### HACK-1316: symlinked `.hack/.internal`

`src/lib/compose-env-at-rest.ts` checked that `.hack/.internal` was a real directory, but only
inside `readReplaceablePrivateFile`, and it never checked `.hack`. When that read refused,
`inspectEntry` called `lstat(path)` to choose between `absent` and `unverifiable`. That `lstat`
followed the `.internal` link, found the outside `compose.env.override.yml`, and classified it
as `unverifiable`. With `includeUnverifiable`, the `--fix` removal then ran `lstat`, `rename`
and `unlink` through the same link, which deleted the outside file. The stale-aside sweep ran
`readdir` on the linked directory before any symlink check, so it could also unlink
`.remove`-shaped files in the outside directory.

### HACK-1311: adopted projects

An active or pending adoption makes `inspectProjectInputsAtRoot` classify the root as `native`.
Doctor's first step, `assertLegacyProjectDiscovery`, therefore threw
`E_NATIVE_PROJECT_UNSUPPORTED`. Every project check and `buildDoctorRemediationPlanLines` call
`findProjectContext`, which throws the same refusal. Every `--fix` step that writes legacy
project files does too.

## Fix

### HACK-1316 (`src/lib/compose-env-at-rest.ts`)

- One no-follow ownership check now covers both `.hack` and `.hack/.internal`. The writer and
  every read, removal and sweep use it.
- A new `refused` state covers these cases:
  - a symlinked or non-directory `.hack` or `.hack/.internal`;
  - a symlinked or non-regular override.

Doctor reports it with a closed, value-free message. A `refused` override is never eligible for
  removal.
- Removal returns before the sweep when the directories are not real. It checks the directory
  identities again before the move and before each aside unlink. The aside listing runs only on
  the held directory.
- The writer refuses a linked `.hack` or `.internal` before `mkdir`, so it creates nothing
  through the link.

### HACK-1311 (`src/commands/doctor.ts`, `src/lib/doctor-adopted-project.ts`)

- Doctor selects an active or pending adoption first, with `selectLegacyComposeAdoptedRoot`, the
  selector that adopted `ps`, `logs` and the other adopted commands use. The legacy-discovery
  refusal runs only when no adoption is found.
- **Checks for an adopted project:**
  - The `project` check names the adopted root. A pending adoption is a warning that asks for
  explicit recovery.
  - The env key, tracked generated files and Compose env at rest checks run against the adopted
  root.
  - Each check that reads adoption-owned inputs is listed as not checked.
  - Agent integrations use the adopted root, and sessions mux uses global settings.
- **`--fix` for an adopted project:**
  - The plan says it leaves the adoption's inputs, retained containers and state alone, and it
  skips env migration.
  - Lifecycle, incomplete-runtime and env-migration repairs treat the project as having no
  legacy project.
  - Untracking generated files changes only the Git index (see the rework above).
- The legacy project-check block moved unchanged into `runProjectChecks`, to stay under the
  complexity limit. The #365 compose backend features check and the #357 env-at-rest adopted
  fallback are unchanged.

There are no CLI surface changes and no agent-guidance changes.

## Tests

| Test | Proves | Mutation that kills it |
| --- | --- | --- |
| `doctor-fix-adopted-project` (new), 2 cases: a committed `.hack.secret.key` and a committed `.hack/.env.state.json` | Real `runCli doctor --fix --no-interactive --migrate-env-config` in an adopted fixture with a real `git init`. Docker is reachable through the mock, and Git runs only on the fixture with no global or system config. Each case checks:<ul><li>no `E_NATIVE_PROJECT` and no canary or key text;</li><li>the exact plan line and step note;</li><li>the closing notes, which prove every later step ran;</li><li>`git ls-files` is empty and the file is unchanged on disk;</li><li>the root `.gitignore` gets the key entry only in the key case, and `.hack/.gitignore` is not created;</li><li>the receipt is unchanged, the adoption is still active, and no stub engine command or project `run` call happens.</li></ul>Exit code: 0 for the env state file. For the key it is 1, because the `env key` check reports the committed key as an error before `--fix` runs. That is existing doctor behavior. | <ul><li>`ffbc4ffd` behavior (always write `.hack/.gitignore`): 0/2 pass, exit 1.</li><li>`findDoctorLegacyProjectContext` no longer skipping adopted projects: 0/2 pass, exit 1.</li><li>Plan line reverted to the legacy wording: 0/2 pass.</li></ul> |
| `compose-env-at-rest`: "the writer refuses a symlinked .hack before creating .internal behind it" (new) | A link to an outside directory without `.internal` gets nothing created behind it, and the refusal is closed. | Writer's pre-`mkdir` refusal removed: fails (this mutation survived before). |
| `doctor-fix-noninteractive`: "doctor --fix leaves a file behind a symlinked .hack/.internal untouched (HACK-1316)" | Real `runCli doctor --fix`: the outside file survives, and the plan and notes do not name it. | `origin/next`'s helper: fails with ENOENT, because the outside file was deleted. |
| `compose-env-at-rest`: symlinked `.hack` and `.hack/.internal` (2 cases) | `refused` state, closed message, no repair planned, no asides removed with a stale-aged clock, writer refusal, outside contents byte-identical. | Old helper: both fail. Directory list without `.hack`: the `.hack` case fails. `inspectEntry` always `unverifiable`: the symlinked-override case fails. |
| `doctor-adopted-project`: `doctor --json` and the `--fix` plan in an adopted project | Closed project message, the checks that run, the not-applicable list, the exact plan, the receipt unchanged, no container start or stop. | Adopted branch of `runProjectChecks` disabled: fails. Preflight forced back to legacy discovery: fails. |

All tests use temporary directories only. They do not touch the real HOME, `~/.hack`, the Docker
socket, the keychain or launchd.

## Evidence

- **Toolchain.** Bun 1.4.2 (`mise x bun@1.4.2`), `bun install --frozen-lockfile`. `bun.lock` is
  unchanged.
- **Local gates on the current head `51244fc9` (M5):**
  - `bun run --cwd packages/cli typecheck`: 0 errors.
  - Root `bun run check`: exit 0, including `privacy-check: ok` (34 existing warnings in other
  files).
  - Biome on the 7 changed source and test files: clean.
  - gitleaks 8.30.1: `gitleaks dir` over a `git log -p origin/next..HEAD` export: no leaks.
- **Focused tests at `51244fc9`, each file run on its own with `--timeout 120000` (the M5 was
  under heavy load), all passing:**
  - `compose-env-at-rest` 16, `doctor-fix-noninteractive` 10, `doctor-adopted-project` 2,
  `doctor-fix-adopted-project` 2
  - `native-compose-backend-features` 13, `native-compose-backend-features-cli` 15, `compose-
  env-at-rest-cli` 9
  - `doctor-command` 28, `doctor-json` 3, `doctor-generated-files` 11, `doctor-env-key` 31,
  `doctor-utils` 8, `doctor-worktree` 6
  - From #407 and #412, which touch the same code: `hack-gitignore` 14, `project-env-config` 58,
  `project-env-hydrate` 18, `compose-env-shared-references` 6
- The five doctor `--fix` and at-rest files also pass together in one process (41/41), which
  checks that the scoped module mocks do not interfere.
- Under the same load, `doctor-fix-adopted-project` with Bun's default 5 s timeout failed in a
  fixture hook. It passes with the longer timeout. This is load, not a code change.
- **Full suite on the M3: not run.** The runner for `91dae070` waited about 6.5 hours and never
  got the shared CI lock. I stopped it and queued a runner for `51244fc9`, which waited about 50
  minutes. During that time the lock directory had existed since 12:37 with no suite process
  running on the M3, several other units were also waiting, and free space was about 57.6 GiB,
  under the 58 GiB floor. I did not touch the lock, since I could not prove who owned it. I
  stopped my runner. It created no worktree or ref.
- **Hosted CI:** all 16 checks pass on the current head `51244fc9` (run 39839971608): the full-
  suite `test` job (39 min), all four `docker-e2e` shards, candidate core, config compiler,
  runtime state models, and secret scan. Earlier heads `0a94d881`, `a2470049` and `91dae070`
  also passed.

## Remaining gaps

- **No M3 full suite.** It did not run because the shared lock was blocked and free space was
  under the floor (see Evidence). The hosted full-suite `test` job ran at `51244fc9` on ubuntu,
  not on macOS.
- **Symlinked `.hack` now refused by the writer.** A Compose command that renders the env
  override now refuses a symlinked `.hack`, not only a symlinked `.internal`. This matches the
  existing `.hack/.env` rule. A project that relied on writing through a linked `.hack` will see
  a closed refusal.
- **Small race window remains.** Directory identities are checked again before each move and
  unlink, not held as open descriptors. A same-user swap in that window remains, as already
  documented for the move-aside window.
- **Other generated files can be committed again.** For an adopted project, `--fix` does not add
  the untracked generated files under `.hack/` to any ignore file. Only the key gets a root
  `.gitignore` entry. The note asks the user to keep them out of later commits.
- **`--fix` with a committed key exits 1.** The exit code reflects the checks that ran before
  the fix, for legacy and adopted projects alike. The fix itself completes and removes the key
  from the Git index, which is what the tracked-key checks read.
- **Pending adoption not tested.** The pending-adoption warning has no test: the fixture has no
  cheap pending state.
- **Native backend skips adopted project checks.** With `HACK_RUNTIME_BACKEND=native`, native
  doctor still skips project checks for an adopted project. This is unchanged.

Refs HACK-1316 Refs HACK-1311

🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi added a commit that referenced this pull request Oct 24, 2026
…d exclude only carrier proofs (#421)

## Summary

Fix unit G50: storage-budget diagnostics follow-ups after #375 (HACK-1361).

### (1) A policy refusal caught after the carrier deadline was reported as `storage-budget`

Root cause, in `src/lib/native-compose-storage-witness-docker.ts`:

- `nativeComposeStorageCarrierBudgetReason` returned `storage-budget` for any
  refusal once the carrier deadline had passed, without looking at the error's
  own observed reason.
- Guard proofs run with original command records, so every helper command goes
  through `runNativeComposeStorageDockerCommand` with `originalCommand`. When its
  admission check (`assertAdmitted`, which re-inspects the helper through
  `checkNativeComposeStorageDockerCarrier`) refuses with a helper policy reason,
  the runner replaces that error with its fixed command refusal
  (`refuseCommand`). The replacement dropped the issued
  `storage-helper-policy` / `helper-*` boundary. The outer `invoke` catch then
  labelled it `storage-helper-start` with `storage-budget` if the deadline had
  passed (and `unclassified` otherwise). The human summary then told the
  operator to raise `HACK_NATIVE_STORAGE_TIMEOUT_MS` for a policy failure.

Fix:

- `nativeComposeStorageCarrierBudgetReason` now takes the error's issued reason
  (registry boundary, or the classified command cause) first. It reports
  `storage-budget` only for a deadline-shaped reason (`probe-timeout`,
  `probe-budget`, `storage-budget`, or no observed cause) when the deadline has
  expired and the carrier was not cancelled. Any other observed reason, such as
  `helper-host-policy`, is kept.
- `refuseCommand` takes an optional source and copies its issued boundary onto
  the replacement refusal (first attach still wins). The runner keeps the first
  publication-stopping error (`publicationSource`) for the bounded-await and
  child-publication paths, and the outer `originalCommand` catch passes the
  caught error.

Diagnostics only: no check, deadline, retention or recovery rule changes.

### (2) `start.excludeStorage` wrapped all of `storage.verify`

The exclusion covered `assertFresh` (twice) and both `store.loadCurrent` reads as well as the
carrier proofs. That non-carrier time also extended the start deadline, and the per-carrier
bound does not limit it. I narrowed it: `NativeComposeCommandStorageVerifyOptions.excludeProof`
now wraps only each enrolled volume's carrier proof (fresh carrier creation plus
`verifyNativeComposeStorageXattrWitness`). `executePreparedGeneration` passes
`start.excludeStorage` through it. The cap entry and exit checks now run per carrier proof,
which matches the documented "a proof that would start at or past the cap is refused before it
runs".

### (3) Comment precision

The `NATIVE_STORAGE_EXCLUSION_MULTIPLE` comment no longer calls the uncapped sum "unbounded in
practice". It now says the sum is finite, at most (5B+3) x V x S, and can reach hours. For
example, 4 builds and 3 volumes allow 69 carriers: about 1.7 h at the 90 s default and 11.5 h at
the 600 s maximum. The `createNativeComposeStartDeadline` doc and the reference docs say which
span is excluded.

## Tests (each proven by mutation)

- `tests/native-compose-storage-witness-docker.test.ts`: "a helper policy
  refusal caught after its carrier deadline keeps its policy reason". It uses a
  real `checkNativeComposeStorageDockerCarrier` host-policy refusal and a fake
  clock past the deadline. Mutation: drop the deadline-shaped precondition
  (the old behavior) -> `storage-budget` received, test fails.
- `tests/native-compose-storage-command-record.test.ts`: "a helper policy refusal
  {before arming, after arming, after the stopped child is published} keeps its
  own reason after the carrier deadline". It uses the real recorded-command
  runner with a temp private directory, a child that is never resumed (marker
  absent), and a fake clock for the reason. Mutations:
  - outer catch without source -> all 3 fail;
  - settlement `refuseCommand` without `publicationSource` -> the
    child-published case fails;
  - `refuseCommand` without the copy -> all 3 fail.
- Narrowed exclusion: the existing wiring test
  `tests/native-compose-start-storage-exclusion.test.ts` passes (about 103 s on
  this loaded Mac). Mutations, each killed (`up` exits 1, workload not
  admitted):
  - `excludeProof: start.excludeStorage` removed from the guard;
  - `verify` ignoring `options.excludeProof`.

## Evidence

- Bun 1.4.2 (`mise x bun@1.4.2`), `bun install --frozen-lockfile`; no
  `bun.lock` change.
- `bun run --cwd packages/cli typecheck`, root `bun run typecheck`, root
  `bun run check` (privacy-check ok): 0 errors. Biome on the changed files
  reports only 3 existing complexity warnings with unchanged scores (checked
  against the `origin/next` copy).
- gitleaks 8.30.1 (`gitleaks dir` over the `git log -p origin/next..HEAD`
  export): no leaks.
- Focused local run: 7 files, 282 pass and 1 timeout-only failure under load.
  The files are: storage command record, docker witness, docker IO, xattr owner,
  startup budget, effect diagnostics and build. The failure was `runEffect
  captures the original fresh carrier factory ...`, which hit the 5 s default
  timeout. It passes serially in 3.7 s with `--timeout 60000`.
  `tests/agent-instruction-source.test.ts` (reads the changed docs) passes.
- M3 full suite at 9301b86: pending: queued behind the shared M3 CI lock (several units ahead);
  this section will be updated with the result.

## Gaps

- The narrowed exclusion is proven by the command-level wiring test and two
  mutations. I did not add a unit test that pins `assertFresh` or the
  saved-state reads outside the excluded span. A full proof through
  `prepareNativeComposeCommandStorage` needs a real witness.
- An untagged policy-shaped mismatch (for example a plain `refuse()` when a
  re-inspected carrier's `createdAt` or policy string changed) still has no
  observed reason. Past the deadline it is still reported as `storage-budget`.
  Tagging those checks is a separate change (see findings).
- Hosted CI and native e2e scenarios are not run by this report. M3 is not a
  qualifying host for them.

Refs HACK-1361

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant