Skip to content

fix: keep runners online and agents reachable when Docker hangs - #57

Merged
kalaspuff merged 4 commits into
mainfrom
fctl/21-look-into-https-github-com-hyperstructur
Oct 18, 2026
Merged

kalaspuff merged 4 commits into
mainfrom
fctl/21-look-into-https-github-com-hyperstructur

Conversation

@fctl-sh

@fctl-sh fctl-sh Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

In #46 a host stopped serving a session for good: the transcript froze on a spinning tool call while the agent kept working, and ten minutes later the fleet stopped the session. Whatever set it off, the host had no way out once Docker stopped answering. Every docker … call was awaited without a deadline, several of them on the control connection itself (hello() probed every driver and ran docker info; the hourly sweep and the cleanup of sandboxes the control plane asked to stop ran inside the serve loop), and nothing noticed an agent link that went quiet or a docker exec that never reached the sandbox.

This is the host's side of #46, items 2 to 5 of its checklist.

Driver commands time out. Every command a driver runs is killed after a minute, or after 30 minutes for image pulls, docker run (which may pull) and Lima's clone, start and stop, and the host logs which one: WARN `docker ps -a …` did not finish within 60 seconds, so the host stopped it. Probes report "the docker daemon is not answering". A container whose state can't be read now fails the session after a minute, instead of the host going on to wait 30 minutes for docker run.

The control connection never waits on a driver. The hello reports driver probes and sandbox limits that the host measures at start and every five minutes in the background. A Docker daemon that doesn't answer leaves the limits as they were. The hourly sweep and the stopping of leftover sandboxes run as chores in tasks of their own, and a session that starts in a sandbox a chore holds waits for it. Heartbeats and pongs keep flowing, and a message that can't be sent waits for the next connection instead of being dropped.

Agent links are checked (crates/fctl-host/src/liveness.rs). The agent must say hello within 30 s of attaching, and the host pings it every 15 s (every fctl-agent answers pings). A link with no frame for a minute is dropped, which kills its exec. The host then attaches again from a task of its own, waiting 1 s, then twice as long each time up to 30 s, and starting fctl-agent again first. A runtime that hangs is retried for as long as it takes. Attaches that fail outright (the exec exits, the sandbox is gone) fail the session after two minutes, as five failed attaches did before. Deliveries the agent hasn't confirmed go over the new link again (the agent ignores ones it has), so a message sent during a stall isn't lost until the uplink happens to reconnect.

Both sides log the link. The host logs each attach with the agent's journal position, each new attempt with its reason, and the exec's stderr. fctl-agent logs host links attaching, replacing each other and detaching, and what it replays from which agent_seq.

Also:

  • The host listens for shutdown signals from the start. Before, it listened only inside its select loops, so a SIGTERM (fctl host restart, fctl host uninstall) that arrived while the daemon awaited something else was lost, and launchd killed the host 20 s later.
  • A host that starts while its driver doesn't answer waits to connect until it can list its sandboxes. Connecting with an empty list would make the fleet stop sessions whose agents may still run.
  • A stop now cancels a sandbox that is still being prepared ("Stopped before it started").
  • fctl-agent announces host connections in the order it accepts them. Each connection used to announce itself from its own task, so a short-lived probe (fctl-agent serve checking whether a supervisor already runs, which the host now does before attaching again) could replace the link the host had just opened. Older agents still recover: the host attaches again when the link closes.

Not here: item 1 (the trigger; what the code tells about it is in #46), item 6 (showing a stall on the session page, a control-plane and web change), and item 7 and the disk questions, which wait on decisions. Draining a host is #56.

Compatibility

Hosts and agents only, with no protocol change. A new host works with existing sandboxes, since every fctl-agent answers Ping. The supervisor's logging and connection order come with a new fctl-agent, which a sandbox gets when its agent next starts.

#39 also changes ensure() in driver/container.rs and hello() in daemon.rs; whichever merges second needs a small rebase.

How I verified it

  • Unit tests: a hanging command is killed and its error says which (driver::tests); link health and the waits between attempts (liveness::tests).
  • cargo xtask check --quick.
  • Against the control plane of cargo xtask dev, with two hosts of this build:
    • Docker hangs. FCTL_DOCKER_BIN pointed at a stand-in that answers like an empty Docker until a flag file makes every command hang.
      • The runner stayed online, last_seen_at advancing every 15 s, while a session's docker inspect hung until the host killed it after 60 s.
      • Dropping the control connection (disabling and re-enabling the runner) got the host registered again 12 ms after it reconnected. The old hello() would have waited on docker version indefinitely.
      • SIGTERM ended the host in 0.1 s while a session's docker run hung.
      • A host started while Docker hung logged the commands that timed out, and connected 10 s after Docker answered again.
      • A new session failed after a minute, with the docker inspect timeout as its reason. One stopped during a hung prepare ended "Stopped before it started".
    • A frozen agent. On the process driver, I stopped a session's fctl-agent with SIGSTOP and sent the session a message. The host dropped the link after a minute of silence, and the next attach timed out on its hello. Once the agent resumed, the host attached again, the journal replayed from the last acknowledged event, and the message was answered once.

Refs #46

Session: https://app.fctl.sh/s/ses_01M56G8AYJ4ECHDXFSXZW31XVP

fctl-sh Bot and others added 4 commits October 18, 2026 04:12
Every command a driver runs is killed after a minute (image pulls,
container creation and VM clones, boots and shutdowns after 30), and the
host logs which one. Probes report a runtime that doesn't answer as such,
a container whose state can't be read fails instead of being created
again, and `Driver::measure` tells a failed measurement apart from the
machine's size. Probing all drivers runs them side by side.

Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daemon no longer awaits a driver on its control connection: the hello
reports driver probes and sandbox limits that a background task refreshes
every five minutes, and the hourly sweep and the cleanup of sandboxes the
control plane asks to stop run as chores in tasks of their own (a session
that starts in a sandbox a chore holds waits for it). Messages that can't
be sent wait for the next connection. A shutdown signal is listened for
from the start, so one that arrives while the daemon is busy is no longer
lost, and a host that starts while its driver doesn't answer waits to
connect until it can list its sandboxes.

Each session checks its agent link (liveness.rs): the agent must say
hello within 30 s of attaching, the host pings it every 15 s, and a link
with no frame for a minute is dropped, which kills its exec. The host
attaches again in a task of its own, backing off from 1 s to 30 s and
starting fctl-agent again first; a hung runtime is retried for as long as
it takes, one that keeps failing outright gives up after two minutes.
Deliveries the agent hasn't confirmed go over the new link again, a stop
without a link waits for one until its deadline, and a stop calls off a
sandbox that is still being prepared. Attaches are logged with the
agent's journal position, re-attaches with the reason, and an exec's
stderr is logged.

Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The supervisor logs at info when a host link attaches, replaces another
or detaches, with the journal's position, and how many events it replays
from which agent_seq. Connections are announced in the order they are
accepted: announced from their own tasks, a short-lived probe (`fctl-agent
serve` checking for a running supervisor) could replace the link the host
had just opened.

Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kalaspuff
kalaspuff merged commit acc16de into main Oct 18, 2026
4 checks passed
@kalaspuff
kalaspuff deleted the fctl/21-look-into-https-github-com-hyperstructur branch October 18, 2026 11:12
fctl-sh Bot added a commit that referenced this pull request Oct 18, 2026
Brings in #54, #57 and #58. The conflicts are #57's against #39's:

- The daemon starts by retrying until the runtime lists the sandboxes
  (#57); the background pull of the host's image (#39) starts after
  that, once the runtime answers.
- The hello keeps #57's sandbox limits and #39's resolved image.
- session.rs imports both sides' names.

With #57's timeouts, a `run` that times out no longer counts as a
missing image: it fails at once instead of trying a pull that would hang
on the same runtime for up to 30 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kalaspuff pushed a commit that referenced this pull request Oct 18, 2026
)

Every host built the default sandbox image itself with `fctl host image
build`, and its Claude Code stayed as old as that build, since fctl
turns off Claude Code's auto-updater. #2 asks for CI to publish the
image instead, for arm64 and amd64, under a stable name with version
tags and labels, rebuilt when Claude Code moves on, with a documented
layout that custom images (#16) can build `FROM`; hosts on the default
image pull the tag of their own version.

The image now lives in a public repository of its own,
[hyperstructure/sandbox](https://github.com/hyperstructure/sandbox) (set
up in hyperstructure/sandbox#1), which publishes it as
`ghcr.io/hyperstructure/sandbox`. This pull request makes hosts pull it
and takes the image out of this repository.

## What changed

**The image moves to hyperstructure/sandbox.** `images/agent` (the
Dockerfile, `provision.sh`, `check.sh`) and the Image workflow leave
this repository; hyperstructure/sandbox#1 adds them there, with a
README, AGENTS.md, skills, lint and title checks. Its Image workflow
keeps what this pull request had built: each architecture builds on a
native runner and is checked with `check.sh`, then both are tagged as
one image.

- `ghcr.io/hyperstructure/sandbox:<version>` for every fctl release,
plus `:<major>.<minor>` and `:latest` when that release is the newest
they cover. Each is the sandbox's `main` with that release of fctl and
the newest Claude Code.
- It looks for new fctl releases on fctl.sh every hour, rebuilds the
newest release's image as soon as a change to the image merges there,
and every day when Claude Code has a newer release or the image is a
week old. Only its `main` publishes; its pull requests build and check
both architectures.
- Labels and index annotations as before: `sh.fctl.version`,
`sh.fctl.claude-code.version`, and the commit and date it was built
from, which the workflow reads back to decide whether an image is
current.

The Release workflow no longer starts an image build: its token can't
start workflows in another repository, so the sandbox's hourly run
notices the release. Why a separate repository: [ADR
0012](docs/adr/0012-the-sandbox-image-lives-in-its-own-public-repository.md)
(the image has to be public, and it has a cadence of its own). It's 0012
because #55 adds 0011.

**Hosts: `crates/fctl-host`, `crates/fctl`**

- `image` in `host.toml` is optional. Unset, sessions start from
`ghcr.io/hyperstructure/sandbox:<the host's version>`, so upgrading a
host moves its new sessions to the new version's image.
`fctl-agent:latest`, which every host installed so far has in its
`host.toml`, also means the default, in `host.toml` and in project
settings.
- The daemon pulls the host's image in the background when it starts and
the image is missing. A session pulls a missing image itself. Nothing
pulls over an image that is already there, so a local build is kept.
- When the version's image can't be pulled (the hour between a release
and its image, or a development build), a session on the default image
starts from `:latest` and says so in its log. A failure that isn't about
the image (the image is there but `run` fails) no longer tries a pull.
- Apple's `container image pull` gets `--platform linux/<the host's
architecture>`. Without it, Apple's CLI downloads every platform of a
multi-arch image (`DefaultPlatform.resolve` in apple/container).
- `fctl host image build` downloads an archive of the sandbox's `main`
from GitHub and builds its Dockerfile with the host's driver, under the
host's image name so sessions use the build, or gives the Lima VM its
`provision.sh`. `--source DIR` builds from a checkout instead, to try
changes to the image with fctl. The binary no longer carries the files.
- CLI: `fctl host image pull` (new, `--json`) pulls the host's image
again, for a newer build of its tag. `install` says which image sessions
start from, `status` (and `--json`) shows it, `doctor` says whether it's
there, and `install --image ""` goes back to the default.
- A sandbox without fctl now says in its log that the default image has
one, instead of suggesting `fctl host image build`.

**Docs:** `docs/sandbox-images.md` (new) covers the image, its tags and
labels, how hosts get it, and its layout as an interface for images
built `FROM` it: the `agent` user (uid 1000, passwordless sudo),
`~/.local/bin`, `~/.cargo/bin`, `~/.bun/bin` and `/opt/fctl/bin` first
on the PATH, and the tools. It's the "Sandbox images" page #16 asks for,
which #16 can extend; the image's README in hyperstructure/sandbox lists
the versions. Getting started, the CLI, self-hosting, security,
architecture, development and the top-level README say that hosts pull
the image instead of building it. AGENTS.md lists hyperstructure/sandbox
among the other repositories (what the host relies on there, and that
its Rust follows `rust-toolchain.toml`), and the release and issues
skills point to it. The roadmap item is ticked.

**Web:** the runner dialog drops step 3 ("Build the sandbox image"), and
the project's image field shows `ghcr.io/hyperstructure/sandbox` as its
placeholder instead of `fctl-agent:latest`.

## For a person

0.3.0 shipped without this pull request, so the next release (0.4.0) is
the first whose hosts pull the image.

1. **Merge hyperstructure/sandbox#1.** Its Image workflow then publishes
`ghcr.io/hyperstructure/sandbox:0.3.0`, `:0.3` and `:latest` from the
sandbox's `main`, which creates the package.
2. **Make hyperstructure/sandbox public** and apply its settings: the
commands are in hyperstructure/sandbox#1.
3. **Make the package public**: the organization's Packages → sandbox →
Package settings → Change visibility. A package starts private even when
its repository is public, and hosts pull without signing in. The
organization's settings must allow public packages. Until then, sessions
on hosts of 0.4.0 fail with "ghcr.io/hyperstructure/sandbox:0.4.0 is not
on this host and can't be pulled (`fctl host image build` builds it
here)".
4. **Merge this pull request**, then hyperstructure/fctl-site#7 once
fctl's CI has pinned fctl.sh past it (`gh pr update-branch 7 -R
hyperstructure/fctl-site`).

## Compatibility

- No protocol or control-plane change. `HostHello.image`, already
optional, now carries the image the host actually uses. Hosts pick up
the new behavior with the release that includes it; hosts of 0.3.0 and
earlier keep building `fctl-agent:latest` from the files in their
binary.
- `image = "fctl-agent:latest"` in `host.toml` now means the published
image. A host that ran a modified image under that name should rename it
and set `image`, or rebuild it with `fctl host image build`, which tags
it with the default name.
- `fctl host image build` needs to reach github.com, as its build
already needed Debian's mirrors and the tools' installers.
- Hosts keep the images of earlier versions until someone removes them
(`docker image rm …`), as they kept the images `fctl host image build`
replaced. Removing them automatically, and refreshing a tag when its
image is rebuilt, is #47.

## Verification

- **The sandbox's Image workflow** ran on hyperstructure/sandbox#1: both
architectures built natively (amd64 in 2m56s, arm64 in 2m20s) and
`check.sh` passed in each with fctl 0.3.0 and Claude Code 2.1.302
([run](https://github.com/hyperstructure/sandbox/actions/runs/39123588178)).
Its lint (ShellCheck, actionlint) and title checks pass too.
- **Its planning step**, run locally against stubbed fctl.sh,
downloads.claude.ai and registry responses, decides correctly in 19
cases: a pull request; hourly with no image (build), a current image
with an older Claude Code (skip: hourly runs ignore Claude Code), an
image built from the last change to the image (skip) and one from before
it (build), an index without annotations (build); daily with an older
Claude Code or a week-old image (build) and a current one (skip); a push
after a change (build); dispatches of 0.2.0 (no `:0.2`: 0.2.1 exists),
v0.2.1 (`:0.2`), a current image with an older Claude Code (build), a
forced rebuild, another branch (build without publishing), a version
fctl.sh doesn't have and a malformed one (errors); and odd answers from
fctl.sh and downloads.claude.ai (errors).
- **`fctl host image build`** with a stand-in `docker`: `--source` with
a checkout builds it with `FCTL_VERSION=0.3.0` and
`FCTL_DOWNLOAD=https://fctl.sh/releases`. The download path, pointed at
a `git archive` of the sandbox served locally (GitHub's format, with its
pax header), unpacks it, builds `…/sandbox-main` and removes the
temporary directory; pointed at a real GitHub archive of a public
repository, it follows the redirect, unpacks it and says the result has
no `provision.sh`; against the real URL while the repository is still
private, it fails with HTTP 404. Unit tests cover unpacking a
GitHub-style archive and refusing a checkout without `provision.sh`.
- **End to end with the stand-in:** an old `host.toml` with
`fctl-agent:latest` resolves to `ghcr.io/hyperstructure/sandbox:0.3.0`
in `status` and `status --json`; `doctor` reports it "not here yet",
then ✓ after `fctl host image pull --json`.
- **Unit tests:** the config tests cover unset, empty and old-default
images, a custom image and saving. The driver tests use a stand-in
`docker`/`container` CLI with an image store and a registry: an image
already there, a pull, Apple's `--platform`, falling back to `:latest`,
both images missing (with and without a fallback), and a `run` failure
that isn't about the image.
- `cargo xtask check --quick` passes locally on the move and on the
merges with `main` (#48, #50, #52, then #54, #57, #58), and CI's full
`cargo xtask check` passes. The merge with #57 keeps its startup retry
loop and starts the background pull once the runtime answers; a `run`
that times out under #57's limits now fails at once instead of being
taken for a missing image and trying a pull.
- hyperstructure/fctl-site#7, with its `main` merged, builds against
this branch's docs: 8 pages, Sandbox images among them, linking to
hyperstructure/sandbox.
- Earlier on this pull request, before the move: the publish step's
commands against a local registry (one OCI index for both architectures
that all three tags point to, with the annotations) and `fctl host
install --driver docker` with a stand-in, which writes no `image` and
says which image sessions start from.
- **Not verified here:** pushing to GHCR with the sandbox workflow's
token, creating the package and its visibility. They need `packages:
write` on the sandbox's `main`, so merging hyperstructure/sandbox#1 is
their test, well before a release depends on them.

## Screenshots

Sessions can't attach screenshots yet (#40), so here is what they show.
Taken at 7771600 with Playwright against `cargo xtask dev` (before:
`main` from a worktree), in both themes and at 390 px:

- **Runner dialog, "Bring studio mac online":** before, three numbered
steps: 1. Install fctl, 2. Register it as a runner and start the
service, 3. Build the sandbox image (once, a few minutes) with `fctl
host image build`. After, steps 1 and 2 only, and the note under them
reads "It shows up here as online within seconds and pulls the sandbox
image in the background. With Lima, prepare the base VM once with `fctl
host image build`. `fctl host doctor` checks everything, including
whether the Mac is set to never sleep." Both themes and the phone width
wrap cleanly.
- **Project → Settings → Sandbox:** the Image field's placeholder reads
`ghcr.io/hyperstructure/sandbox` (at 7771600 it read
`ghcr.io/hyperstructure/fctl-agent`; `main` has `fctl-agent:latest`).
Nothing else in the section changes. The move changed only this
placeholder's text, which wasn't screenshotted again.

Refs #2

Sessions: https://app.fctl.sh/s/ses_01M56C4589JCHJ2W8TXC8V2S4P,
https://app.fctl.sh/s/ses_01M56FZDD6WY8J56E3TA0SEMZX for merging #45 and
v0.3.0, and https://app.fctl.sh/s/ses_01M57B991V0YYBRJFN0WKEW118 for
moving the image to hyperstructure/sandbox

---------

Co-authored-by: fctl-sh[bot] <fctl-sh[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant