Skip to content

fix(runner): classify every Docker failure once in the client - #73

Merged
namikmesic merged 3 commits into
mainfrom
fm/puck-66-docker-failure-classifier
Oct 4, 2026
Merged

namikmesic merged 3 commits into
mainfrom
fm/puck-66-docker-failure-classifier

Conversation

@namikmesic

Copy link
Copy Markdown
Owner

Intent

Deliver puck issue #66, "runner: one Docker failure classifier" (#66). The Docker client classifies every result once (not found, permission, daemon down, CLI missing, timeout, other) so the ops branch on a field instead of matching stderr, the health check consumes the same classification instead of matching the discovery module's prose, and the three timeout constants become the one table. Acceptance criteria: no stderr regular expression remains in the ops module, and every tolerated failure is a classified outcome; a discovery failure reaches the health check as a value, not a message, and the literal message assertion in the health test is gone; all Docker timeouts are read from the one table; the ops and health tests feed classified results, and one classifier test owns the patterns. Closes #66.

What Changed

  • A new src/puck-runner/docker/failure.ts holds the only stderr patterns (classifyStderr). The Docker client now sets DockerResult.failure on every result to one of not-found, permission, daemon-down, cli-missing, timeout, or other. A failed CLI discovery becomes cli-missing, and the old timedOut/aborted flags are removed. An exit 0 that races the timeout guard or the abort signal now counts as a success.
  • The ops (ops.ts) no longer match stderr. They check failure, and only a not-found result is tolerated (missing container, volume or image when inspecting or removing). One behavior change: docker image inspect used to try a pull after any failure. Now it pulls only on not-found and stops with an "Inspecting image" error for anything else, such as the Docker daemon being down. The health check (health.ts) maps failure to its existing DockerProblem wire names instead of matching the discovery module's error text. ServerErrors from docker info go through the same classifier.
  • All Docker timeouts now come from one TIMEOUTS table in a new leaf module, timeouts.ts: the client default, docker info, every op, the docker stop grace, and the login-shell probe in discovery. AGENTS.md and the line reference in the delivery spec now point to it. A new runner-docker-failure.test.ts owns the stderr patterns. The ops and health tests (and the other tests that build DockerResults) now pass classified results, and the ops tests also check each command's timeout against the table.

Closes #66.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: A contained refactor of the runner's Docker module: every result is classified once in the client, ops and health branch on the field with behavior equivalent to the old stderr checks (the one intentional change, failing fast when an image inspect fails for a reason other than not-found, is tested), all Docker timeouts come from one table, every test fake sets the new field consistently, and the new tests exercise a real scripted CLI rather than source text.

Testing

I built the runner bundle and drove its real config command against the local Docker 29.5.2 engine. I changed the Docker state for each health class, and the user saw the right message for every one: healthy (version, CPUs and memory), CLI missing (discovery's own explanation), daemon down, socket permission, and timeout after 20 s. I also drove DockerOps with the real client through a temporary probe, since removed. On a healthy engine it treated a missing container, volume or image as absent (not-found). With the engine down it raised the daemon-down error and never tried a pull. Transcripts are in the evidence directory. The real-Docker runner suite couldn't run because this engine hosts the user's own running Puck environment, which the suite refuses to touch. The three targeted unit test files pass. I removed the build output, temp dirs and the probe file afterwards.

  • Live validation: ✅ go - 8 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Healthy Docker: the runner's config.sh prints the Docker version, CPUs and memory ✅ pass live runner-config-docker-health.txt: 'Docker 29.5.2, 8 CPUs, 23.4 GiB'
Docker CLI missing: config.sh shows discovery's own explanation, passed as a cli-missing value, not matched from the message text ✅ pass live runner-config-docker-health.txt: PUCK_DOCKER_BIN=/nonexistent/docker -> 'PUCK_DOCKER_BIN is set to "/nonexistent/docker" but that is not an executable file.'
Docker daemon down (missing docker.sock, or TCP refused): config.sh says Docker is not running ✅ pass live runner-config-docker-health.txt: daemon-down message for both DOCKER_HOST cases
Socket permission denied: config.sh tells the user to join the docker group ✅ pass live runner-config-docker-health.txt: mode-000 docker.sock -> socket-permission message
Hung engine: the health check times out after TIMEOUTS.info (20 s) with the timeout message ✅ pass live runner-config-docker-health.txt: 'Docker did not answer within 20 s', elapsed=20s
Deleting or inspecting a missing environment on a healthy engine: real 'No such …' results are classified not-found and treated as absent ✅ pass live live-ops-probe.jsonl: state=null, volumesPresent=false, delete resolved through all three stages; raw container, volume and image inspect and image rm classified not-found
Adversarial: with the engine down, no op mistakes the failure for not-found, and image() raises without trying a pull ✅ pass live live-ops-probe.jsonl 'engine down': state, volumesPresent, delete and image all threw DockerError with failure daemon-down; stages=['checking-image'] only
A missing image is pulled; a present local image is used as is ✅ pass live live-ops-probe.jsonl: missing image went checking-image -> pulling-image (pull failed, failure 'other'); puck-daemon-test:1 returned as is
Full runner create/attach/delete flow through the real-Docker suite (test/docker/runner.test.ts) ⏸️ untested no This Docker engine hosts the user's running Puck environment puck-env_01M3Q7GRKH6G86ZD4D5E2MPYYY (labelled puck=instance, up 4 days). The runner therefore reports 'active' instead of 'idle', and the s…
Evidence: Runner config.sh Docker health transcript across 7 Docker states
=== healthy docker
Puck runner 0.1.0 (macos-arm64)
Checking Docker…
Docker 29.5.2, 8 CPUs, 23.4 GiB
Registering with http://127.0.0.1:9…
Cannot reach the Puck server at http://127.0.0.1:9: fetch failed
exit=1
=== CLI missing (PUCK_DOCKER_BIN points nowhere)
Puck runner 0.1.0 (macos-arm64)
Checking Docker…
PUCK_DOCKER_BIN is set to "/nonexistent/docker" but that is not an executable file.
exit=1
=== daemon down (DOCKER_HOST socket missing)
Puck runner 0.1.0 (macos-arm64)
Checking Docker…
docker info failed: failed to connect to the docker API at unix:///tmp/puckrt.6IlA/missing.sock; check if the path is correct and if the daemon is running: dial unix /tmp/puckrt.6IlA/missing.sock: connect: no such file or directory
exit=1
=== daemon down (DOCKER_HOST tcp refused)
Puck runner 0.1.0 (macos-arm64)
Checking Docker…
Docker is not running on this machine. Start the Docker service (or Docker Desktop, or colima) and try again.
exit=1
=== daemon down (docker.sock missing, the Docker Desktop/colima shape)
Puck runner 0.1.0 (macos-arm64)
Checking Docker…
Docker is not running on this machine. Start the Docker service (or Docker Desktop, or colima) and try again.
exit=1 elapsed=0s
=== socket permission (docker.sock mode 000)
Puck runner 0.1.0 (macos-arm64)
Checking Docker…
This user cannot use the Docker socket. Add this user to the docker group (sudo usermod -aG docker $USER, then log in again).
exit=1 elapsed=0s
=== timeout (docker.sock accepts and never answers)
Puck runner 0.1.0 (macos-arm64)
Checking Docker…
Docker did not answer within 20 s. Check that the Docker service is healthy.
exit=1 elapsed=20s
Evidence: Live DockerOps probe: real docker results, their classification, and ops outcomes
{"step":"docker container inspect puck-LIVEPROBEMUUAVC1O","code":1,"failure":"not-found","stderr":"Error response from daemon: No such container: puck-LIVEPROBEMUUAVC1O"}
{"step":"docker volume inspect puck-LIVEPROBEMUUAVC1O-data","code":1,"failure":"not-found","stderr":"Error response from daemon: get puck-LIVEPROBEMUUAVC1O-data: no such volume"}
{"step":"docker image inspect puck-img-liveprobemuuavc1o","code":1,"failure":"not-found","stderr":"Error response from daemon: No such image: puck-img-liveprobemuuavc1o:latest"}
{"step":"docker image rm puck-img-liveprobemuuavc1o","code":1,"failure":"not-found","stderr":"Error response from daemon: No such image: puck-img-liveprobemuuavc1o:latest"}
{"step":"docker rm -f puck-LIVEPROBEMUUAVC1O","code":0,"failure":null,"stderr":"Error response from daemon: No such container: puck-LIVEPROBEMUUAVC1O"}
{"step":"docker volume rm -f puck-LIVEPROBEMUUAVC1O-data","code":0,"failure":null,"stderr":""}
{"step":"docker info --format {{.ServerVersion}}","code":0,"failure":null,"stderr":""}
{"step":"healthy engine: state/volumesPresent/delete of a missing environment","state":{"value":null},"present":{"value":false},"del":{},"stages":["removing-container","removing-volumes","removing-image"]}
{"step":"image() of a missing image","missing":{"error":"Pulling localhost:9/puck-live-missing:none: Error response from daemon: failed to resolve reference \"localhost:9/puck-live-missing:none\": failed to do request: Head \"https://localhost:9/v2/puck-live-missing/manifests/none\": dial tcp 127.0.0.1:9: connect: connection refused","failure":"other"},"stages":["checking-image: localhost:9/puck-live-missing:none","pulling-image: docker pull localhost:9/puck-live-missing:none","pulling-image: Error response from daemon: failed to resolve reference \"localhost:9/puck-live-missing:none\": failed to do request: Head \"https://localhost:9/v2/puck-live-missing/manifests/none\": dial tcp 127.0.0.1:9"]}
{"step":"image() of a present image","present":{"value":"puck-daemon-test:1"}}
{"step":"engine down: state/volumesPresent/delete/image","state":{"error":"Inspecting puck-LIVEPROBEMUUAVC1O: failed to connect to the docker API at unix:///tmp/puckrt.6IlA/gone/docker.sock; check if the path is correct and if the daemon is running: dial unix /tmp/puckrt.6IlA/gone/docker.sock: connect: no such file or directory","failure":"daemon-down"},"present":{"error":"Inspecting volume puck-LIVEPROBEMUUAVC1O-data: failed to connect to the docker API at unix:///tmp/puckrt.6IlA/gone/docker.sock; check if the path is correct and if the daemon is running: dial unix /tmp/puckrt.6IlA/gone/docker.sock: connect: no such file or directory","failure":"daemon-down"},"del":{"error":"Removing the container: failed to connect to the docker API at unix:///tmp/puckrt.6IlA/gone/docker.sock; check if the path is correct and if the daemon is running: dial unix /tmp/puckrt.6IlA/gone/docker.sock: connect: no such file or directory","failure":"daemon-down"},"img":{"error":"Inspecting image puck-daemon-test:1: failed to connect to the docker API at unix:///tmp/puckrt.6IlA/gone/docker.sock; check if the path is correct and if the daemon is running: dial unix /tmp/puckrt.6IlA/gone/docker.sock: connect: no such file or directory","failure":"daemon-down"},"stages":["checking-image"]}
{"step":"list()","list":{"value":[{"envId":"env_01M3Q7GRKH6G86ZD4D5E2MPYYY","container":"puck-env_01M3Q7GRKH6G86ZD4D5E2MPYYY","state":"running","definition":"puck","image":"node:22-bookworm"}]}}
Evidence: Live DockerOps probe run
(!) Your Vite config uses features that are unsupported by `configLoader: 'native'`, which is planned to become the default in a future major version of Vite:
  - ESM syntax in a file loaded as CommonJS (vitest.config.ts:1:1). Use a `.mjs` extension or set `"type": "module"` in the closest package.json
Set `VITE_CONFIG_NATIVE_IGNORE_WARNING=true` to suppress this warning.

 RUN  v4.1.10 ~/.no-mistakes/worktrees/81e07ded9ba4/01M44AFTW56HRBDABFA0VEPK0A


 Test Files  1 passed (1)
      Tests  5 passed (5)
   Start at  22:55:23
   Duration  392ms (transform 26ms, setup 0ms, import 34ms, tests 302ms, environment 0ms)
Evidence: Real-Docker runner suite (blocked by a foreign Puck environment on this engine)
(!) Your Vite config uses features that are unsupported by `configLoader: 'native'`, which is planned to become the default in a future major version of Vite:
  - ESM syntax in a file loaded as CommonJS (vitest.docker.config.ts:1:1). Use a `.mjs` extension or set `"type": "module"` in the closest package.json
Set `VITE_CONFIG_NATIVE_IGNORE_WARNING=true` to suppress this warning.

 RUN  v4.1.10 ~/.no-mistakes/worktrees/81e07ded9ba4/01M44AFTW56HRBDABFA0VEPK0A

puckd (test build) 0.1.0 90075234c4a2 → .webpack/daemon-test/puckd.js (590916 bytes)
puck-runner 0.1.0 (development) → ~/.no-mistakes/worktrees/81e07ded9ba4/01M44AFTW56HRBDABFA0VEPK0A/.webpack/runner/puck-runner.cjs (446 KiB, sha256 4827e2558a52)
 ❯ test/docker/runner.test.ts (9 tests | 8 failed) 1330ms
     × 1. registers against the test server and reports Docker 353ms
     × 2. creates an environment, attaches, and runs a turn through the relay 4ms
     × 3. pumps GitHub installation tokens into the environment, never through argv 0ms
     × 4. restarting the runner mid-turn leaves the turn unaffected 10ms
     × 4b. a daemon restart mid-turn resumes the conversation through the harness session, behind the same runner 0ms
     × 5. removes a runner and keeps its environments 106ms
     × 6. removes a runner and deletes its environments 14ms
     × 8. refuses an inbox the daemon would discard, and creates no container 348ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 8 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  test/docker/runner.test.ts > Docker scenarios: puck-runner > 1. registers against the test server and reports Docker
AssertionError: expected { …(14) } to match object { name: 'suite-a', …(2) }
(11 matching properties omitted from actual)

- Expected
+ Received

  {
    "docker": {
      "ok": true,
    },
    "name": "suite-a",
-   "status": "idle",
+   "status": "active",
  }

 ❯ test/docker/runner.test.ts:133:18
    131|       return v && v.status !== 'offline' && (v.docker as { version: st…
    132|     });
    133|     expect(view).toMatchObject({ name: 'suite-a', status: 'idle', dock…
       |                  ^
    134|     expect((view.docker as { ncpu: number }).ncpu).toBeGreaterThan(0);
    135|     runnerKey = String(view.publicKey);

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/8]⎯

 FAIL  test/docker/runner.test.ts > Docker scenarios: puck-runner > 2. creates an environment, attaches, and runs a turn through the relay
TypeError: Cannot read properties of undefined (reading 'open')
 ❯ createEnvironment test/docker/runner.test.ts:76:47
     74|   const id = String(created.body.envId);
     75|   envIds.push(id);
     76|   const control = new ControlClient(await app.open(runnerId, publicKey…
       |                                               ^
     77|   await control.welcome();
     78|   const bundleSha = await uploadBundle(control, bundle);
 ❯ test/docker/runner.test.ts:140:29

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/8]⎯

 FAIL  test/docker/runner.test.ts > Docker scenarios: puck-runner > 3. pumps GitHub installation tokens into the environment, never through argv
TypeError: Cannot read properties of undefined (reading 'open')
 ❯ test/docker/runner.test.ts:97:23
     95|     const channel = await waitFor(
     96|       'an attach channel',
     97|       async () => app.open(runnerId, publicKey, 'attach', id).catch(()…
       |                       ^
     98|       Math.max(1, deadline - Date.now()),
     99|     );
 ❯ waitFor test/docker/runner-helpers.ts:98:21
 ❯ attachReady test/docker/runner.test.ts:95:27
 ❯ test/docker/runner.test.ts:164:21

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/8]⎯

 FAIL  test/docker/runner.test.ts > Docker scenarios: puck-runner > 4. restarting the runner mid-turn leaves the turn unaffected
TypeError: Cannot read properties of undefined (reading 'open')
 ❯ test/docker/runner.test.ts:97:23
     95|     const channel = await waitFor(
     96|       'an attach channel',
     97|       async () => app.open(runnerId, publicKey, 'attach', id).catch(()…
       |                       ^
     98|       Math.max(1, deadline - Date.now()),
     99|     );
 ❯ waitFor test/docker/runner-helpers.ts:98:21
 ❯ attachReady test/docker/runner.test.ts:95:27
 ❯ test/docker/runner.test.ts:184:21

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/8]⎯

 FAIL  test/docker/runner.test.ts > Docker scenarios: puck-runner > 4b. a daemon restart mid-turn resumes the conversation through the harness session, behind the same runner
TypeError: Cannot read properties of undefined (reading 'open')
 ❯ test/docker/runner.test.ts:97:23
     95|     const channel = await waitFor(
     96|       'an attach channel',
     97|       async () => app.open(runnerId, publicKey, 'attach', id).catch(()…
       |                       ^
     98|       Math.max(1, deadline - Date.now()),
     99|     );
 ❯ waitFor test/docker/runner-helpers.ts:98:21
 ❯ attachReady test/docker/runner.test.ts:95:27
 ❯ test/docker/runner.test.ts:212:21

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/8]⎯

 FAIL  test/docker/runner.test.ts > Docker scenarios: puck-runner > 5. removes a runner and keeps its environments
AssertionError: expected undefined to be true // Object.is equality

- Expected:
true

+ Received:
undefined

 ❯ test/docker/runner.test.ts:244:52
    242|     runnerA = null;
    243|     for (const f of ['.runner', '.credentials', '.runner_key']) expect…
    244|     expect((await containerStart(envId))?.running).toBe(true);
       |                                                    ^
    245|     const instance = await call(server, 'GET', `/v1/instances/${envId}…
    246|     expect(instance.body).toMatchObject({ instance: { status: 'orphane…

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/8]⎯

 FAIL  test/docker/runner.test.ts > Docker scenarios: puck-runner > 6. removes a runner and deletes its environments
Error: This Docker engine hosts Puck environments the suite did not create (env_01M3Q7GRKH6G86ZD4D5E2MPYYY); not deleting them.
 ❯ test/docker/runner.test.ts:257:31
    255|       .split('\n')
    256|       .filter((id) => id && !envIds.includes(id));
    257|     if (foreign.length) throw new Error(`This Docker engine hosts Puck…
       |                               ^
    258|     const { runnerId } = await configureRunner(server, session, dirB, …
    259|     runnerB = new RunnerProcess(dirB);

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/8]⎯

 FAIL  test/docker/runner.test.ts > Docker scenarios: puck-runner > 8. refuses an inbox the daemon would discard, and creates no container
TypeError: Cannot read properties of undefined (reading 'open')
 ❯ test/docker/runner.test.ts:335:49
    333|       return v && v.status !== 'offline' ? v : undefined;
    334|     });
    335|     const control = new ControlClient(await app.open(runnerId, String(…
       |                                                 ^
    336|     await control.welcome();
    337|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/8]⎯


 Test Files  1 failed (1)
      Tests  8 failed | 1 passed (9)
   Start at  22:54:44
   Duration  3.12s (transform 67ms, setup 0ms, import 94ms, tests 1.33s, environment 0ms)

exit=1
Evidence: Health check results by Docker state
healthy -> Docker 29.5.2, 8 CPUs, 23.4 GiB
CLI missing -> PUCK_DOCKER_BIN is set to "/nonexistent/docker" but that is not an executable file.
docker.sock gone -> Docker is not running on this machine. Start the Docker service ...
tcp refused -> Docker is not running on this machine. ...
socket mode 000 -> This user cannot use the Docker socket. Add this user to the docker group ...
hung socket -> Docker did not answer within 20 s. (elapsed 20s)
- Outcome: ⚠️ 1 info across 1 run (2m50s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ⚠️ src/puck-runner/docker/failure.ts:19 - The intent enumerates six failure classes: "not found, permission, daemon down, CLI missing, timeout, other". failure.ts adds a seventh, cancelled (also set at client.ts:105 and client.ts:148, and mapped at health.ts:26). No consumer in src reads it (control.ts:350 maps every DockerError to the generic 'docker' code; the old aborted flag had no reader either), and the intent's own 'other' class ("anything else; stderr carries the detail") would cover an aborted command with stderr 'cancelled'. It is a deliberate, low-stakes extension that preserves the distinction the removed aborted flag carried, so this is the author's call: either keep it as an intentional addition or fold it into 'other'. Removing it is the smaller change.
  • ℹ️ test/unit/runner-docker-health.test.ts:110 - The acceptance criterion says one classifier test owns the stderr patterns, but the health test's client-only case still asserts that the literal 'Cannot connect to the Docker daemon' (fed through ServerErrors) yields problem 'daemon-down', so a pattern change in failure.ts would break two tests. This is defensible as a behavior test of the ServerErrors routing in classifyInfo (health.ts:63-64) rather than of the pattern, and there is no way to prove the routing without some classifiable text; noting it as a known overlap, no action needed.

🔧 Fix applied.
2 infos still open:

  • ℹ️ test/unit/runner-docker-health.test.ts:110 - The acceptance criterion says one classifier test owns the stderr patterns, but the health test's client-only case still asserts that the literal 'Cannot connect to the Docker daemon' (fed through ServerErrors) yields problem 'daemon-down', so a pattern change in failure.ts would break two tests. This is defensible as a behavior test of the ServerErrors routing in classifyInfo (health.ts:63-64) rather than of the pattern, and there is no way to prove the routing without some classifiable text; noting it as a known overlap, no action needed.
  • ℹ️ src/puck-runner/docker/ops.ts:128 - Behavior change beyond a pure refactor, consistent with the intent's "every tolerated failure is a classified outcome": image() used to treat any docker image inspect failure as "not cached" and fall through to docker pull; it now tolerates only not-found and throws Inspecting image <img>: ... for anything else (daemon-down, permission, timeout, other). Concrete trace: an engine that is busy enough for image inspect to hit the 20 s guard (TIMEOUTS.inspect) now aborts the create with a timeout error, where before the 30-minute pull would have been attempted and likely succeeded. The failure is loud, classified, and covered by the new ops test ("pulls only an image that is not found, and stops on any other inspect failure"), so this is an acknowledged tradeoff rather than a defect.
⚠️ **Test** - 1 info
  • ℹ️ src/puck-runner/docker/failure.ts:23 - Existing behaviour, not caused by this change (base health.ts had the same patterns): if DOCKER_HOST points at a missing socket whose filename isn't docker.sock, the health check reports 'unknown' instead of 'daemon-down'. Docker 29 words this error as 'failed to connect to the docker API … if the daemon is running', which no daemon-down pattern matches. The default socket paths (Docker Desktop, colima, /var/run) all end in docker.sock and classify correctly.
  • Live validation: ✅ go - 8 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Healthy Docker: the runner's config.sh prints the Docker version, CPUs and memory ✅ pass live runner-config-docker-health.txt: 'Docker 29.5.2, 8 CPUs, 23.4 GiB'
Docker CLI missing: config.sh shows discovery's own explanation, passed as a cli-missing value, not matched from the message text ✅ pass live runner-config-docker-health.txt: PUCK_DOCKER_BIN=/nonexistent/docker -> 'PUCK_DOCKER_BIN is set to "/nonexistent/docker" but that is not an executable file.'
Docker daemon down (missing docker.sock, or TCP refused): config.sh says Docker is not running ✅ pass live runner-config-docker-health.txt: daemon-down message for both DOCKER_HOST cases
Socket permission denied: config.sh tells the user to join the docker group ✅ pass live runner-config-docker-health.txt: mode-000 docker.sock -> socket-permission message
Hung engine: the health check times out after TIMEOUTS.info (20 s) with the timeout message ✅ pass live runner-config-docker-health.txt: 'Docker did not answer within 20 s', elapsed=20s
Deleting or inspecting a missing environment on a healthy engine: real 'No such …' results are classified not-found and treated as absent ✅ pass live live-ops-probe.jsonl: state=null, volumesPresent=false, delete resolved through all three stages; raw container, volume and image inspect and image rm classified not-found
Adversarial: with the engine down, no op mistakes the failure for not-found, and image() raises without trying a pull ✅ pass live live-ops-probe.jsonl 'engine down': state, volumesPresent, delete and image all threw DockerError with failure daemon-down; stages=['checking-image'] only
A missing image is pulled; a present local image is used as is ✅ pass live live-ops-probe.jsonl: missing image went checking-image -> pulling-image (pull failed, failure 'other'); puck-daemon-test:1 returned as is
Full runner create/attach/delete flow through the real-Docker suite (test/docker/runner.test.ts) ⏸️ untested no This Docker engine hosts the user's running Puck environment puck-env_01M3Q7GRKH6G86ZD4D5E2MPYYY (labelled puck=instance, up 4 days). The runner therefore reports 'active' instead of 'idle', and the s…
  • npm run build:runner, then node .webpack/runner/puck-runner.cjs config --url http://127.0.0.1:9 --token PRT_… --unattended with Docker healthy, PUCK_DOCKER_BIN=/nonexistent/docker, DOCKER_HOST=unix://…/gone/docker.sock, DOCKER_HOST=tcp://127.0.0.1:9, DOCKER_HOST=unix://…/missing.sock, a mode-000 docker.sock, and a docker.sock that accepts connections but never answers
  • Temporary live probe (removed afterwards) that runs DockerOps with realDocker on Docker 29.5.2: raw classification of inspect, rm and info results; state, volumesPresent and delete of a missing environment; image() for a missing and a present image; the same operations with the engine down; list()
  • npx vitest run --config vitest.docker.config.ts test/docker/runner.test.ts (could not run on this host: the engine hosts a Puck environment the suite did not create)
  • npx vitest run test/unit/runner-docker-failure.test.ts test/unit/runner-docker-health.test.ts test/unit/runner-docker-ops.test.ts
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Namik Mesic added 3 commits October 4, 2026 22:37
The Docker client now attaches one classified failure to every result
(not-found, permission, daemon-down, cli-missing, timeout, cancelled,
other; null on exit 0), from the one classifier in failure.ts. The ops
branch on that field: the tolerated failures are not-found outcomes and
stderr only goes into the message, so ops.ts matches no stderr pattern.
An image inspect pulls only on not-found and stops on any other failure.

A discovery failure reaches the health check as the cli-missing value
instead of discovery prose to match, and health maps each class to its
wire problem name. A client-only `docker info` answer runs its engine
errors through the same classifier.

The client default, the health timeout, the stop grace, and the
login-shell probe join the ops timeouts in one TIMEOUTS table, a leaf
module (timeouts.ts) every Docker module reads without an import cycle.

The ops and health tests feed classified results; one classifier test
owns the patterns and drives the real client against a scripted CLI.

Closes #66
@namikmesic
namikmesic merged commit 0e6add2 into main Oct 4, 2026
4 checks passed
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.

runner: one Docker failure classifier

1 participant