Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
1c14276
test(runtime): isolate fork-exposed host checks and make scratch name…
roodboi Oct 22, 2026
f6bc8ed
test(runtime): isolate the remaining dropped-listener recovery tests
roodboi Oct 22, 2026
fa86dc9
chore: merge next into wf/g40-runtime-core-test-isolation
roodboi Oct 22, 2026
cb6661f
test(runtime): let the publisher stop fixture bind its own port
roodboi Oct 22, 2026
cfc3886
fix(adoption): search every ancestor for an adoption and word doctor'…
roodboi Oct 23, 2026
a33511f
fix(ci): shard the hosted test suite so a slow runner cannot hit the …
roodboi Oct 23, 2026
631d3fc
fix(native): keep the closed exit code when the graph owner's pipes d…
roodboi Oct 22, 2026
8dca82e
fix(native): drain bounded runtime requests to EOF with the shared ex…
roodboi Oct 23, 2026
bccc3b2
fix(logs): persist Alloy positions, release SIGINT handlers and carry…
roodboi Oct 23, 2026
e4be689
test(native): hold inherited pipes past the shared drain bound in log…
roodboi Oct 23, 2026
71fb4a0
Merge origin/next into wf/g65-runtime-client-drain
roodboi Oct 23, 2026
8465ff7
fix(perf): report every out-of-range protocol number on every Python
roodboi Oct 23, 2026
aed5f4f
fix(env): name unreclaimable layer locks and the re-include cause
roodboi Oct 23, 2026
84bf2cc
Merge remote-tracking branch 'origin/next' into g63-local-wf-g63-shar…
roodboi Oct 23, 2026
bbbfa5f
chore: merge next into wf/g67-loki-followups-2
roodboi Oct 23, 2026
bb7c930
docs(logs): state which kept containers Alloy reopens and what each p…
roodboi Oct 23, 2026
daac38d
test(native): wait for a complete pid in late-writer handshakes
roodboi Oct 23, 2026
503dc18
test: pin the console logger in the exec env-change test
roodboi Oct 23, 2026
2b62dba
Merge remote-tracking branch 'origin/next' into g62-local-wf-g62-adop…
roodboi Oct 23, 2026
ed877f5
test(env): set the doctor fixture env per key so the spawn-env guard …
roodboi Oct 23, 2026
0f747d2
fix(doctor): drop doctor's adoption search-bound catch now that the s…
roodboi Oct 23, 2026
7cde887
fix(project): treat a vanished info/exclude as writable like .hack/.g…
roodboi Oct 24, 2026
7ac7cbb
Merge origin/next into wf/g69-env-lock-key-followups
roodboi Oct 24, 2026
900a1cd
Merge origin/next into wf/g67-loki-followups-2
roodboi Oct 24, 2026
c4d3d10
fix(agents): keep a value-free diagnostic on adoption marker access r…
roodboi Oct 24, 2026
28ff612
fix(agents): refuse rebase--helper --add-exec-commands and cover deta…
roodboi Oct 24, 2026
5773687
chore: merge next into the hosted test shard branch
roodboi Oct 24, 2026
3a3efd2
fix(cli): guard ssh/node prompts without a TTY, add secrets delete --…
roodboi Oct 24, 2026
e48baf5
fix(doctor): name the recovery each pending adoption state accepts fo…
roodboi Oct 24, 2026
c723223
train: merge #399
roodboi Oct 24, 2026
fa0bfe0
train: merge #441
roodboi Oct 24, 2026
1b14f67
train: merge #447
roodboi Oct 24, 2026
0fc5bee
train: merge #449
roodboi Oct 24, 2026
a5ad553
train: merge #453
roodboi Oct 24, 2026
8f39298
train: merge #451
roodboi Oct 24, 2026
29a2d40
train: merge #466
roodboi Oct 24, 2026
00f046d
train: merge #463
roodboi Oct 24, 2026
e73d83b
train: merge #460
roodboi Oct 24, 2026
c31275e
train: merge #467
roodboi Oct 24, 2026
f1d2cc1
train: merge #462
roodboi Oct 24, 2026
9db4ad3
train: merge #442
roodboi Oct 24, 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
21 changes: 19 additions & 2 deletions .ai/skills/hack-repo-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,27 @@ untracked candidate additions. Keep private artifacts excluded from the index.
| Native authored command slices | `tests/native-authored-command.test.ts` (unsupported matrix), `tests/native-authored-command-dispatch.test.ts`, `tests/native-authored-project-{start,run,observe,logs,exec,recovery}.test.ts` and the [command contract](../../../docs/reference/native-authored-commands.md); the dispatch "source CLI" cases need the prepared compiler (`bun run build:config-compiler` or absolute `HACK_CONFIG_COMPILER_BINARY`) and fail without it; `native-authored-project-start` and `native-authored-project-exec` are nondeterministic on a local macOS host, so a single local pass or failure is not proof; live acceptance runs host-serialized on one prepared macOS provider slot |
| Native VM/routing/reclamation | [candidate guide](../../../docs/guides/native-candidate.md), relevant acceptance gate and isolated host fixture; Linux checks cannot qualify macOS effects |
| Resource/performance claims | [performance skill](../hack-repo-performance/SKILL.md), matched workload and measured boundaries |
| Bun runtime crashes | Read-only history: `bun run ci:bun-crash-census -- --since <UTC> --until <UTC>` (failed `test` job logs; `--scan-passing` for all). Matched reproduction: the gated [experiment](../../../.github/workflows/bun-crash-experiment.yml), which spends hosted macOS minutes and needs an explicit go-ahead; never loop on a shared host. Its default `focused` mode loops `tests/native-https-owner.test.ts` (where both original segfaults happened); `full` needs a budget of at least 40 minutes because one full suite takes 30-35. A run never starts unless its estimate fits the remaining budget, so read `summary.json` for the verdict and per-mode crash, failure and skipped-for-budget counts. Both share [the classifier](../../../scripts/lib/bun-crash-classify.ts) |
| Bun runtime crashes | Read-only history: `bun run ci:bun-crash-census -- --since <UTC> --until <UTC>` (failed `test` job logs; `--scan-passing` for all; since the suite moved to `test shard i/4` jobs, pass `--job "test shard i/4"` once per shard for newer runs). Matched reproduction: the gated [experiment](../../../.github/workflows/bun-crash-experiment.yml), which spends hosted macOS minutes and needs an explicit go-ahead; never loop on a shared host. Its default `focused` mode loops `tests/native-https-owner.test.ts` (where both original segfaults happened); `full` needs a budget of at least 40 minutes because one full suite takes 30-35. A run never starts unless its estimate fits the remaining budget, so read `summary.json` for the verdict and per-mode crash, failure and skipped-for-budget counts. Both share [the classifier](../../../scripts/lib/bun-crash-classify.ts) |

Read [CI](../../../.github/workflows/ci.yml) for current hosted gates. A local pass
does not mean CI ran. Existing unsupported remote/gateway/native-app checks are
does not mean CI ran.

Hosted CI runs the `bun run test` file set on four `test shard i/4` jobs;
the required `test` check is their fan-in and passes only when every shard did.
[`scripts/test-shard-plan.ts`](../../../scripts/test-shard-plan.ts) splits the files
by the durations in `scripts/test-shard-durations.json`. A file with no recorded
duration is planned at 5 s. Each shard runs each of its files in its own
`bun test ./<file>` process and prints a start line and a result line per file.
It stops a file after 12 min, and stops the shard at 30 min or on cancellation.
When it stops early it names the running file, the files not started and the
slowest files. Each shard first checks that the four shards cover the files
`git ls-files` lists exactly once. To reproduce one shard locally, run
`bun scripts/test-shard.ts plan --shard=i/4` or
`bun scripts/test-shard.ts run --shard=i/4`. To check the split after adding
or moving tests, run `bun scripts/test-shard.ts verify --count=4`. To refresh the
durations, download the `test-shard-durations-<i>` artifacts and run
`bun scripts/test-shard.ts update-durations --source=<run> <files>`. A per-file
process does not share module state with other files, unlike one `bun test` run. Existing unsupported remote/gateway/native-app checks are
not default local-product requirements; use them only for explicitly scoped work.

## Verify the verifier
Expand Down
59 changes: 55 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,21 @@ jobs:
exit 1
fi

test:
test-shard:
# One deterministic slice of the `bun run test` file set per runner
# (scripts/test-shard-plan.ts, HACK-1381). The serial job took 37-42 min and
# reached the 60 min limit on slow runners; each file now runs in its own
# `bun test` process with a start/result line, so a hang names its file.
name: test shard ${{ matrix.shard }}/4
strategy:
# Let every shard finish so one failure still reports the other files.
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
runs-on: blacksmith-6vcpu-macos-15
timeout-minutes: 60
# A shard plans about 10.5 min of tests. The runner's own 30 min deadline
# stops first and names the running file; this bounds a hung runner.
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -373,22 +385,61 @@ jobs:
bun-version: "1.4.2"
- name: Install dependencies
run: bun install --frozen-lockfile
# The one-off checks and builds run once, on shard 1.
- name: Typecheck (Turbo)
if: matrix.shard == 1
run: bun run turbo:typecheck
- name: Privacy check
if: matrix.shard == 1
run: bun run privacy:check
- name: Quality checks (Turbo)
if: matrix.shard == 1
run: bun run turbo:check
- uses: dtolnay/rust-toolchain@1.97.1
- name: Prepare matching compiler for private file owner tests
run: bun run build:config-compiler
- name: Tests (Turbo)
run: bun run turbo:test
# Runs check:bun-version and test:preflight as `bun run test` does, then
# verifies that the four shards cover the full test file set exactly once.
- name: Tests (shard)
env:
HACK_TEST_SHARD: ${{ matrix.shard }}/4
run: bun scripts/test-shard.ts run --shard="$HACK_TEST_SHARD" --durations-out="$RUNNER_TEMP/test-shard-durations.json"
- name: Preserve measured file durations
# Per-file seconds only; refresh scripts/test-shard-durations.json with
# `bun scripts/test-shard.ts update-durations`.
if: always()
uses: actions/upload-artifact@v4
with:
name: test-shard-durations-${{ matrix.shard }}
path: ${{ runner.temp }}/test-shard-durations.json
if-no-files-found: ignore
retention-days: 14
- name: Build CLI
if: matrix.shard == 1
run: bun run build
- name: Build release smoke (no tests)
if: matrix.shard == 1
run: bun run build:release --skip-tests --no-clean --out=dist/release-ci

test:
# The required `test` check (repository ruleset) for `next` and `main`. It
# runs even when a shard fails or is cancelled and passes only when every
# shard succeeded, so a failed shard can never surface as a skipped check.
needs: test-shard
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Require every test shard to succeed
env:
SHARD_RESULT: ${{ needs.test-shard.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "test shards did not all succeed (result: $SHARD_RESULT)"
exit 1
fi

linux-process-lifetime:
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
Expand Down
70 changes: 49 additions & 21 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,14 @@ returns no entries and Loki was not requested explicitly, `hack logs` writes a o
to stderr and answers from `docker compose logs` instead. Nothing from Loki is written first,
so `--json` output still has one `start`/`end` pair. When Loki was requested explicitly
(`--loki`, `--services`, `--query`, `--since`, `--until`), the empty result stands and the
notice suggests `hack logs --compose --no-follow`, keeping any `--path`, `--project`,
`--branch` and service you gave. Follow mode (`hack logs` without `--no-follow`) does not
fall back. When Loki is the follow backend (`logs.followBackend: loki`, or `--loki`) and no
entry arrives within 8 seconds, `hack logs` writes a one-line stderr notice that suggests
`hack logs --compose` and keeps following.
notice suggests `hack logs --compose --no-follow` with every selector you gave that the
compose backend accepts: `--path`, `--project`, `--branch`, `--tail`, `--profile`, `--pretty`,
`--json` and a single service. `--query`, `--since`, `--until` and more than one service have
no compose equivalent, so the notice names them instead of dropping them. Follow mode
(`hack logs` without `--no-follow`) does not fall back. When Loki is the follow backend
(`logs.followBackend: loki`, or `--loki`) and no entry arrives within 8 seconds, `hack logs`
writes the same kind of one-line stderr notice, suggesting `hack logs --compose`, and keeps
following.

Short-lived containers: Alloy finds containers by polling Docker every 5 seconds. It includes
exited containers, so a one-shot Compose service (a migration or seed job) is shipped after it
Expand All @@ -207,22 +210,47 @@ between two polls (for example `docker compose run --rm`) never reaches Loki; us
discovery config after `hack global install` rewrites `~/.hack/logging/alloy.alloy` and the
Alloy container restarts (`hack global down` then `hack global up`).

Alloy keeps its read positions inside its own container (there is no positions volume). Each
time the Alloy container is recreated, for example by `hack global down` then
`hack global up`, it reads the retained logs of all exited Compose
containers again and can send entries that Loki already holds. Loki rejects entries older than
its sample age limit (one week by default).

Exited containers have a polling cost. In Alloy v1.12.0, `loki.source.docker` checks each
discovered container on a fixed 5-second timer. It reopens an exited container's log when the
container finished after the last log line Alloy shipped from it. That is true for a container
that never logged anything, and for one whose last line was shipped before it exited, so such
a container is reopened every 5 seconds (two container inspect calls and one logs call) for as
long as Docker keeps it. Docker discovery does not expose a container's state or exit time, so
the config cannot skip old exited containers. Remove them (`hack down` or
`docker compose down`) to stop the checks. Each reopen also makes Alloy log a
"finished transferring logs" info line. Alloy runs as a Compose service, so it would ship that
line into Loki every 5 seconds per container. The Alloy config sets
Alloy keeps its read positions (`positions.yml`) under `--storage.path`, which the logging
compose maps to the named volume `alloy-data`. `hack global down` then `hack global up`
recreates the Alloy container but keeps the volume, so Alloy resumes each container's log
after the last line it shipped instead of reading all retained logs again. A position is a
whole second and Docker's `since` filter includes that second, so lines from the last second
Alloy read can be sent again. `hack global logs-reset` removes the volume together with Loki's
data (`docker compose down -v`); the next start reads the retained logs from the beginning,
which matches the emptied Loki. Installs made before this volume existed pick it up after
`hack global install` rewrites `~/.hack/logging/docker-compose.yml` and the stack restarts.
Loki rejects entries older than its sample age limit (one week by default).

Kept containers have a polling cost. In Alloy v1.12.0
(`internal/component/loki/source/docker/runner.go`), `loki.source.docker` calls container
inspect on every discovered container every 5 seconds, running or exited, for as long as
Docker keeps it. After that call it reopens the container's log (one more inspect call and one
logs call) when the container is running and its log is not already open, or when
`finished.Unix() >= target.Last()`. `finished` is the container's `State.FinishedAt` in whole
seconds (0 if it cannot be parsed). `Last()` is the wall-clock second at which Alloy last
processed a line from that container: `dockertarget/target.go` sets it to the current time
after each line, it starts at 0 when the target is created, and it is not stored in the
positions file. A reopen asks Docker for the lines since the container's position, which is a
whole second, and Docker includes that second. So a reopen of a container that logged
anything reads at least its last line again, and `Last()` moves past the container's finish
time. For an exited container this means:

- A container that never logged a line keeps `Last()` at 0, so it is reopened every
5 seconds for as long as Docker keeps it.
- A container whose last line Alloy read in or before the second the container finished (it
logged and then exited while Alloy followed it), or one whose lines the positions file
already covers (for example after Alloy restarts), is reopened once. That reopen sends the
lines from its last logged second again, and then the container is not reopened. If the
reopen happens within the second the container finished, there is one more reopen.
- A container whose last line Alloy read in a later second than the one the container
finished in, for example a one-shot service that exited before Alloy discovered it, is not
reopened.

Docker discovery does not expose a container's state or exit time, so the config cannot skip
old exited containers. Remove them (`hack down` or `docker compose down`) to stop the inspect
calls. Each reopen also makes Alloy log a "finished transferring logs" info line. Alloy runs
as a Compose service, so it would ship those lines into Loki: one per reopen, and one every
5 seconds for each kept container that never logged a line. The Alloy config sets
`logging { level = "warn" }`, so only Alloy's warnings and errors are written and shipped.

Retention:
Expand Down
13 changes: 8 additions & 5 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,11 @@ Historical remote guides are excluded from the supported documentation.
from `up`, `down`, `restart`, `ps`, `logs`, `run`, `exec`, `open`, `tui`, `branch ...`,
`config get`/`set`, `env ...`, `host exec`/`shell` and `project owner`/`target`/`task`, for
example `hack ps -p <dir> --json` and `hack branch list -p <dir>`. Under `--json` it is one
error envelope; without `--json` it is one closed error line, `E_PROJECT_NOT_FOUND: <message>`,
and no stack trace. The logger adds its own prefix: the console logger (`HACK_LOGGER=console`)
prints `ERROR: E_PROJECT_NOT_FOUND: ...` on stderr, gum prints a styled `ERROR` level on
stderr, and clack prints a `■` marker on stdout after its `│` guide line. `project target` and
error envelope; without `--json` it is one closed error message, `E_PROJECT_NOT_FOUND: <message>`,
and no stack trace. The logger decides how it looks: the console logger (`HACK_LOGGER=console`)
prints one line, `ERROR: E_PROJECT_NOT_FOUND: ...`, on stderr; gum prints one line with a styled
`ERROR` level on stderr; and clack prints two lines on stdout, its `│` guide line and then the
message after a `■` marker. `project target` and
`project task` use their own message, and `target` adds a
`detail`. These commands report something else:
- `tui` without a terminal refuses for that reason first.
Expand All @@ -159,7 +160,9 @@ Historical remote guides are excluded from the supported documentation.
a safe default apply it (`hack env unset KEY` removes the key; `hack doctor --fix` applies safe
remediations and skips destructive ones with a note); prompts that need a real answer fail
closed with `E_INTERACTIVE_REQUIRED`, naming the flag or argument that makes the invocation
scriptable (for example `hack update --yes`, `hack mcp --all`, `hack session attach <name>`).
scriptable (for example `hack update --yes`, `hack mcp --all`, `hack session attach <name>`,
`hack secrets delete <name> --yes`, `hack ssh --host <host>`). Destructive confirmations without
such a flag decline or refuse; they never proceed on their own.
Set `HACK_NO_INTERACTIVE=1` explicitly in scripts anyway so the intent is visible.
- `NO_COLOR` (or `HACK_NO_COLOR`) disables colored/decorated output.

Expand Down
Loading
Loading