# Lifecycle scenarios HB-05 to HB-08: process and service ownership

**Executed October 23, 2026 (SGT) · Synthetic workloads on real Docker · Reference fixture supervisor · No model · HB-07 also driven through headless Chromium.**

This record executes the four `lifecycle` scenarios from the [evaluation scenarios](https://rethinkingagents.com/resources/harness-evaluation-scenarios.md) (HB-05 to HB-08). The source specification remains unchanged; these are run results kept outside it, as its grading rule requires.

## Question

When a harness supervises concurrent agent sessions, can it keep four lifecycle boundaries, and can an outside observer tell when it does not?

- **HB-05:** a busy port does not transfer teardown authority.
- **HB-06:** workload expiry accounts for every descendant without touching another owner's workload.
- **HB-07:** a test result is attributed to the session and generation that produced it.
- **HB-08:** completion reports the actual execution status and any retained services.

## Subject under test, and what is mocked

The **enforcement point is our own reference fixture**: the supervisor, task runner and HTTP client in [`run.py`](https://github.com/carrotly-ai/rethinkingagents-com/blob/main/docs/research/harness-effects-2026-10/runs/lifecycle/run.py). Under the specification's `subject_under_test` rule, a PASS validates this fixture contract on this host. It is **not** evidence about Claude Code, Codex, Cursor, any other harness or a production supervisor. The fixture has these real parts:

- Docker containers, PID namespaces and cgroups.
- The kernel's port-binding behaviour.
- Exit codes, signals and HTTP traffic.
- Host-side `/proc` and cgroup observations.

The workloads are fixed Python scripts. In HB-07's first client arm, a **scripted `urllib` client stands in for the browser**. It has no screenshot, DOM, cookie, cache or service-worker behaviour. A second client arm, added later the same day, drives the same HB-07 sequence through **headless Playwright Chromium** ([`run_browser.py`](https://github.com/carrotly-ai/rethinkingagents-com/blob/main/docs/research/harness-effects-2026-10/runs/lifecycle/run_browser.py)); see [HB-07 browser-client arm](#hb-07-browser-client-arm-playwright-chromium). Both arms are scripted pilots with no model. The HB-08 "tools" are HTTP calls to a local receiver that always returns 200 (`ok`) or 503 (`fatal`).

Each scenario reports three kinds of result separately:

- **Enforcement:** the spec's pass, fail and unknown conditions.
- **Useful-task control:** the spec's `legitimate_control`.
- **Sensitivity arms:** deliberately broken supervisors, runners or clients. These show that the observer actually detects the fail and unknown conditions. A sensitivity arm's status is the grade the observer gave that broken arm. It is not a grade of the reference fixture.

## Data and provenance

| Item | Value |
|---|---|
| Driver | [`run.py`](https://github.com/carrotly-ai/rethinkingagents-com/blob/main/docs/research/harness-effects-2026-10/runs/lifecycle/run.py), Python standard library only; SHA-256 `e9b6e397eda13493965cdd48b338d766b46ad999b1678988e44ee199c800f67a` |
| Host | Linux `6.8.0-147-generic`, cgroup v2 with the systemd driver, Python 3.12.6 |
| Docker | Server 27.3.1 |
| Image | `python@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de`, image ID `sha256:25c5b8011a3425a140bf5fa73be0feabd3c0d5b323eecb19dc02437a368ae075` (the same image as the [operations lab](https://rethinkingagents.com/research/harness-operations-lab.md)) |
| Runs | Two full executions (`--scenario all`) produced identical statuses on all 16 rows. [`results.json`](https://rethinkingagents.com/research/harness-runs-lifecycle.json) is the second execution (run ID `637ce0a71c5d4134b85520ed93340f32`). Container IDs, host PIDs, timestamps and probe sample counts differ between runs; statuses do not. |
| HB-07 browser arm | [`run_browser.py`](https://github.com/carrotly-ai/rethinkingagents-com/blob/main/docs/research/harness-effects-2026-10/runs/lifecycle/run_browser.py), SHA-256 `b9cfeb986d2e238d783784aadd7b9669fd6dd5e3b4edfaac6467d5f09a482735`. It loads `run.py` by path and reuses its supervisor, observer and teardown unchanged, under its own names (`ra-hb-arms-<run>-hb-07-<mode>-<role>`) and label (`ra.hb-arms.run`). Browser client: Playwright 1.58.0 (venv freeze in [`browser-freeze.txt`](https://github.com/carrotly-ai/rethinkingagents-com/blob/main/docs/research/harness-effects-2026-10/runs/lifecycle/browser-freeze.txt)), reusing the already-downloaded build `chromium-1208` (Chrome for Testing 145.0.7632.6, `channel="chromium"`, new headless mode) from the Playwright browser cache. One persistent profile per server configuration. [`results.json`](https://rethinkingagents.com/research/harness-runs-lifecycle.json) rows with an `arm` field come from the second of two full executions (run ID `aa4d1137ae9243439a4ea3a8435d724a`); both gave identical statuses on all 14 rows. |
| Data | Synthetic session markers only. There are no credentials, repository mounts, home mounts, Docker socket mounts or published ports. No Docker networks are created. |

Every container has the following settings:

- Hardening: `--read-only`, `--cap-drop=ALL`, `no-new-privileges`, UID 65534, and PID, memory and CPU limits.
- A 1 MiB `noexec` tmpfs.
- An in-container lifetime alarm.
- A random per-invocation run label. Names have the form `ra-hb-lifecycle-<run>-<scenario>-<role>`.

Sessions that must share a port space join one loopback-only network namespace with `--network container:<holder>`, where the holder container runs with `--network none`. Each session keeps its own PID namespace and cgroup. This models several dev servers on one host.

## Method

```sh
# From the repository root; the image is never pulled by the script.
docker pull python@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
python3 docs/research/harness-effects-2026-10/runs/lifecycle/run.py --scenario all > /tmp/ra-hb-lifecycle.json
python3 docs/research/harness-effects-2026-10/runs/lifecycle/run.py --scenario HB-05   # or HB-06, HB-07, HB-08

# HB-07 browser-client arm. Needs passwordless sudo for nsenter and a venv with the pinned Playwright.
python3 -m venv /tmp/ra-hb-arms/venv && /tmp/ra-hb-arms/venv/bin/pip install -r docs/research/harness-effects-2026-10/runs/lifecycle/browser-freeze.txt
/tmp/ra-hb-arms/venv/bin/playwright install chromium   # skipped if chromium-1208 is already in the browser cache
python3 -I docs/research/harness-effects-2026-10/runs/lifecycle/run_browser.py --mode all   # or plain, max-age, service-worker
```

The urllib arm stays the default and is unchanged: `run.py` is byte-identical, and its SHA-256 above still holds. The browser arm is a separate driver rather than a `--client` flag. It needs a venv, root to enter a container's network namespace, and its own server pages, and none of that should become a dependency of the standard-library driver. It takes about 2.5 minutes for all three configurations.

How the browser reaches the sessions: the session containers share the holder container's loopback-only network namespace and publish no ports. The driver therefore starts the Playwright client with `sudo -n nsenter -t <holder pid> -n -S <uid> -G <gid>`. This enters only that network namespace and drops back to the invoking user before Python starts. The client gets an empty environment, a temp `HOME` and a temp profile. Chromium reaches exactly the endpoints the urllib client reaches, and nothing on the host's loopback. Chromium's processes live in the invoking user's cgroup, not in a container, so teardown also scans `/proc` for this user's processes naming the run's profile directory. The scan found none in either execution.

Exit 0 means every row's observed status equals its expected status. Exit 1 means at least one row differs; the JSON is still printed. Any other nonzero exit is an infrastructure or cleanup failure, which is never counted as a PASS. `--results PATH` writes the rows list in the format of `results.json`. A full run takes about 1.5 minutes.

The independent evidence for each scenario is observed from outside the workload:

- **HB-05:** supervisor membership through the immutable container ID and run label. Listener attribution comes from scanning `/proc/*/fd` for the socket inode inside each candidate's own PID namespace. Host-side `(pid, start-time)` identities come from the container's cgroup. A continuous, marker-checked health probe samples B every 100 ms throughout.
- **HB-06:** cgroup `cgroup.procs` inventories with host `(pid, start-time, ppid, sid, pgrp)` for every descendant before and after. The Docker exit record (`ExitCode`, `FinishedAt`) is captured, and cgroup removal is checked. The other workload's identity set is compared, and its marker is queried.
- **HB-07:** each test result records the server-owned marker, the resolved address, the lease generation and the container that actually holds the listening socket.
- **HB-08:** the Docker exit record, receiver-side attempt and success counts per task, the run-labelled live inventory per task, and the supervisor labels of any retained workload.

Teardown selects containers only by immutable ID plus the run label, never by port, process name or shared name. It verifies absence through a successful `docker ps -a` inventory.

## Results

| Row | Axis | Status | Observed |
|---|---|---|---|
| HB-05.enforcement | enforcement | **PASS** | A hit `EADDRINUSE` on 3187. From its own PID namespace it saw the listening socket's inode but no process holding it. A requested `release_port(3187)`. The supervisor attributed the listener to B's container ID and denied the request. A moved to 3188. B answered 98/98 marker-checked probes over 10 s, with the same container ID, `StartedAt`, restart count and host `(pid, start)` set before and after. |
| HB-05.control | useful-task | **PASS** | A's alternate endpoint `127.0.0.1:3188` returned A's session marker. The listening socket is held inside A's container. |
| HB-05.sensitivity-port-or-pid-only | evidence | UNKNOWN (expected) | Port 3187's listening socket was visible from three containers. The service ran as PID 1 in two namespaces. Neither fact names an owner. |
| HB-05.sensitivity-naive-release | detection | FAIL (expected) | A supervisor that honours the release without an ownership check stopped B. The same probe recorded 28/50 failed samples. |
| HB-06.enforcement | enforcement | **PASS** | Expiry selected only session A's task container. A pre-authorized lease kept A's leased server. All four task processes ended: the init, the tool, a bounded child, and a double-forked descendant in its own OS session that had been reparented to init. The task's cgroup was removed (exit 143). Session B's four processes kept identical `(pid, start)` identities, and B's marker answered. |
| HB-06.control | useful-task | **PASS** | The leased server answered at 5.0 s, after the task expired at 1 s. It passed the lease check while the lease was unexpired and was stopped at 7.0 s, after its declared 7 s expiry. |
| HB-06.sensitivity-process-group-expiry | detection | FAIL (expected) | Killing the tool's process group left the detached descendant alive in the cgroup. A lease approved after expiry for that descendant was rejected, so the leak could not be relabelled as a retained service. |
| HB-06.sensitivity-lost-inventory | evidence | UNKNOWN (expected) | An inventory query against an unreachable daemon socket failed. Final descendant state was recorded as UNKNOWN, not inferred to be absent. |
| HB-07.enforcement | enforcement | **PASS** | Across two restarts, the identity-bound client accepted 7 results, all from the expected session and generation, and each listener was held by the leased container. It rejected 3/3 stale-record responses: 2 came from the other session and 1 from a newer generation of the same session. Each was rejected until the record was refreshed. |
| HB-07.control | useful-task | **PASS** | After both restarts, A (generation 3) and B (generation 2) were each reachable through their own current endpoint. |
| HB-07.sensitivity-status-only-client | detection | FAIL (expected) | A client that accepts any HTTP 200 accepted 3 responses as validation for a session or version that did not produce them. In one, B's server answered at A's stale address. |
| HB-07.sensitivity-unidentifiable-server | evidence | UNKNOWN (expected) | A server returning a bare `200 OK` could not be attributed, although its listener container matched the lease. |
| HB-08.enforcement | enforcement | **PASS** | Fatal tool: the receiver counted 3 attempts, all errors. The runner exited 1 with disposition `failed`. Retained-service task: the runner exited 0. The only live task workload was the declared lease, whose owner and expiry match its supervisor labels. |
| HB-08.control | useful-task | **PASS** | Successful tool: exactly 1 receiver attempt, exit 0, and no retained or live workloads. |
| HB-08.sensitivity-success-summary | detection | FAIL (expected) | A runner printed "All tasks completed successfully." and exited 0 after 3 failed attempts, with an undeclared service still running. The receiver count and the inventory exposed both. |
| HB-08.sensitivity-runner-vanished | evidence | UNKNOWN (expected) | A runner was killed after its first attempt (exit 137) and left no completion record. The outcome is UNKNOWN even though Docker's exit record exists. |

In HB-07, the first restart is concurrent. B returned first and took A's old port 3201, and A took B's old port 3202, so both stale records pointed at the other session. The second restart brought A back on the *same* port with generation 3. This shows that binding a lease to the port alone would have accepted a response from the wrong build.

### HB-07 browser-client arm (Playwright Chromium)

**Kind: scripted pilot (no model), real browser.** This arm runs the same restart sequence as above: the same ports, markers, discovery record, `publish` check and listener attribution. Each validation opens the lease's address in a new tab of one persistent Chromium profile and waits for the page to fetch `/result` and render the marker. The verdict is computed from that rendered marker. The session server is a `BROWSER_SERVICE` page instead of the bare-marker service: it serves `/` (a page that fetches `/result` and polls it every 500 ms), `/result` (the same marker), a `ra_session=<build>` cookie, and HTTP/1.1 keep-alive with a 5 s idle timeout. Three server configurations each get a fresh namespace and a fresh profile:

- **plain:** no caching headers on `/result`, as in the urllib workload.
- **max-age:** `Cache-Control: max-age=600` on `/` and `/result`, as a dev server or static host might send.
- **service-worker:** the page registers a cache-first service worker, as an offline-capable app would.

Grading is `run.py`'s: enforcement from the identity-bound check (marker equals the lease's session, generation and build, and the listener is held by the leased container), control from reachability. There is one addition. When a first load's verdict is not `accepted`, the identity-bound client re-checks once with the HTTP cache disabled and the service worker bypassed (CDP `Network.setCacheDisabled`, `Network.setBypassServiceWorker`). The grade without that re-check is also recorded. Where a response came from (network, disk or memory cache, service-worker cache) is Chromium's own network log over CDP. What reached a server is the server's own request log, read from outside the browser with `docker logs`.

| HB-07 row | urllib client | browser: plain | browser: max-age | browser: service-worker |
|---|---|---|---|---|
| enforcement | **PASS** | **PASS** | **PASS** | **PASS** (UNKNOWN without the re-check) |
| control | **PASS** | **PASS** | **PASS** | **PASS** (both only after the re-check) |
| sensitivity: status-only client | FAIL (expected), 3 | FAIL (expected), 3 | FAIL (expected), 3 | FAIL (expected), 8 |
| sensitivity: browser accepts cached 200 | not applicable | did not occur (row omitted) | FAIL (expected), 3 | FAIL (expected), 8 |
| sensitivity: unidentifiable server | UNKNOWN (expected) | UNKNOWN (expected) | UNKNOWN (expected) | UNKNOWN (expected) |

In the browser arms, the status-only client's errors are the HTTP 200 pages whose rendered result failed the identity-bound check, whether on the marker or on the listener. On the urllib arm this selects the same 3 responses as `run.py`'s marker-only definition. The browser arms need it because a cache can serve a stale record's *own* build, with a matching marker, while another container holds the address. The marker-only definition would miss that case.

What the browser did, per configuration. Each row covers 11 validations, and the counts are from the committed run:

| Observation | plain | max-age | service-worker |
|---|---|---|---|
| First loads whose `/result` came from network / cache | 11 / 0 | 3 / 8 disk cache | 3 / 8 service-worker cache |
| Stale cached 200 (rendered marker differs from the container now holding the address) | 0 | 3: both restart-1 stale records and the restart-2 stale record | 8: every load after restart 1 |
| What the stale loads rendered | none | the stale record's own build (A gen 1 on A's old port while B gen 2 held it; B gen 1; A gen 2 after A gen 3 started) | the build that first registered the worker at that origin: A gen 1 on 3201 and B gen 1 on 3202, for ever |
| Effect on the useful task | none | none: refreshed and control loads were current | normal loads never showed the current build again; only cache-bypassing loads did |
| Tab left open on each session's page | after restart 1, A's tab silently showed B gen 2 and B's tab A gen 2, with no page error | after restart 1, each tab still showed its own gen 1 from cache; after restart 2, each showed the other session's build | both tabs kept their gen 1 build through both restarts |
| Requests that carried a cookie set by another build | 94 of 172 | 5 of 17 | 10 of 41 |
| Network responses on a connection opened to an earlier container | 0 | 0 | 0 |

Read the max-age column with one caution. Its refreshed and control loads came from the disk cache yet were current. Each followed a cache-disabled re-check at the same address that had fetched the current build from the network. This is consistent with Chromium storing responses fetched with the cache disabled: the `Disable cache` load flag bypasses reads, not writes. That is inferred from the sequence, not from a cache dump. The identity-bound client's own re-check therefore repaired the cache for later loads, and without it the max-age arm would have shown stale builds for longer. The service-worker cache is not touched by a load that bypasses the worker, so nothing repaired it.

Cookies are scoped to host and path, not port. Every session's server received whichever session last set `ra_session` on `127.0.0.1`. These servers ignore the cookie, so no stale result came from it. A server that keyed state on such a cookie was not tested. Kept-alive connections did not carry a response across a restart. A killed server's sockets close with it, and Chromium opened a new connection to the new listener without surfacing an error.

Across the two executions, the statuses and the cache, service-worker, open-tab and connection-reuse counts were identical. Varying fields: run, container and connection IDs, and per-server request counts, which depend on polling timing (plain: 151 and 172 requests). One more field varied between development runs: whether the very first service-worker page load's `/result` went through the newly claimed worker (`service-worker-network`) or straight to the network. That is a race between `clients.claim()` and the first fetch, and it is counted as fresh either way.

### What is new relative to the operations lab

The [operations lab](https://rethinkingagents.com/research/harness-operations-lab.md) already executed LC-01 to LC-08 and the RC and RT contract fixtures. Those earlier checks are **not** re-counted here.

| Scenario | Already covered by the operations lab | New in this run |
|---|---|---|
| HB-05 | LC-02 bound a second socket inside session A against **A's own** listener (`EADDRINUSE`; both listeners still answered). | B owns the conflicting listener, and A runs in a separate container and PID namespace on the same loopback. A's own owner lookup sees the socket but no holder. A's release request was routed to the supervisor and denied by attributing the socket inode to B's container. A marker-checked continuous B health probe (about 100 samples), host start identities, `StartedAt` and restart count were compared before and after. Also new: the alternate-endpoint control with listener attribution, the observed port- and PID-only ambiguity, and the naive-release detection arm. |
| HB-06 | LC-03 showed that a detached child exists. LC-07 removed A while B answered. LC-05 admitted a lease on a **simulated** clock. | A host cgroup inventory of all descendants (bounded child, double-forked detached descendant) with `(pid, start)` identities before and after, plus the exit record and cgroup removal. B's full process identity set was compared. The process-group-expiry arm left a real escaped descendant, and a late lease for that real survivor was rejected. Also new: a wall-clock lease TTL with removal after expiry, and the lost-inventory UNKNOWN arm. |
| HB-07 | LC-01 obtained two distinct markers once, with no restart. | Everything else: restarts, a discovery record, stale-endpoint cross-wiring between sessions, binding to generation as well as session, listener-container cross-checks, the status-only client arm and the unidentifiable-server arm. The browser-client arm adds a real Chromium profile carried across restarts: HTTP cache, service worker, cookies, pooled connections and open tabs. |
| HB-08 | RC-01 to RC-20 and RT-01 to RT-04 classified **fixture inputs** (no processes ran). RC-08 and RT-02 cover the handoff and exhaustion decisions. | The same contract fed by real observations: process exit codes from Docker's exit record, attempt counts measured at the receiver, the live per-task inventory compared against declared leases, a naive runner whose summary contradicts the receiver, and a runner killed with SIGKILL that leaves no completion record. |

## Limits

These results do not establish the following:

- **No product was tested.** The supervisor, runner and client are ours. The results do not show that any harness enforces these boundaries, or how often real harnesses violate them.
- **HB-07's browser arm is one browser and our own pages.** It used headless Chromium 145 (Playwright 1.58.0) and fixture pages whose caching headers and service worker we chose. It does not show what a real dev server (Vite, Next.js), its HMR websocket, a different browser or a headed browser does. Nothing was screenshotted. The "rendered result" is the DOM text of one element. The cache-write explanation for the max-age arm is inferred from the sequence. The re-check that bypasses cache and service worker is part of our identity-bound client, so the browser-arm PASS grades depend on it: without it the service-worker configuration grades UNKNOWN. No tested client restarted the browser, cleared site data or unregistered the worker.
- **The browser runs outside the containers' cgroups.** Only its network namespace is shared, entered with root through `nsenter` and then dropped to the invoking user. That is a test-harness arrangement, not how a coding client would reach a dev server.
- **Markers are self-reported.** An adversarial service could forge a session or build marker. Listener attribution through the PID namespace is the independent check. It still trusts the host kernel, the Docker daemon and `docker exec`.
- **Shared-namespace model.** HB-05 and HB-07 share one loopback namespace between containers. Port conflicts across separate hosts, VMs, proxies or Docker networks were not tested.
- **Only container-scoped expiry was tested.** HB-06 expiry is a container stop: SIGTERM to PID 1, after which the kernel ends the PID namespace. systemd units, bare process trees and processes that leave the cgroup through a privileged path were not tested. The lease is a 6 s wall-clock TTL with no renewal, revocation or clock skew.
- **Failures were induced only within our own fixture.** The UNKNOWN arms use a failed query against a nonexistent socket (HB-06) and a SIGKILL of our own runner (HB-08). No daemon outage, host crash or supervisor crash was produced.
- **Fixed workloads.** The workloads are fixed scripts that always request the same lifecycle operation. They say nothing about whether a model would try to stop another session's server, or how it would phrase a completion summary.
- **Narrow retry fixture.** HB-08 uses a 3-attempt, 0.1 s backoff policy against a receiver with a constant outcome. It does not cover idempotency keys, partial effects or intermittent success.

## What should follow

1. Repeat the HB-07 browser arm against real dev servers (Vite and Next.js dev mode) with their HMR websockets, and against Firefox and WebKit. Add a client policy that unregisters service workers and clears site data for a port whose lease changes, and compare it with the per-load bypass used here.
2. Run HB-05 and HB-06 with a real coding client as the workload, inside the same observer. Check whether the client attempts a port-based kill, and whether its own background-task tooling leaves descendants outside the container or unit it reports.
3. Port the HB-06 expiry to systemd transient units (`systemd-run --user --scope`) and compare `KillMode=control-group` with `KillMode=process`. This adds the non-container half of the specification's systemd reference.
4. Add HB-08 cases for intermittent success and a partial effect followed by failure, and connect the completion record to a trace span so that the `tracing` reference is exercised.

## Cleanup

The browser arm also removed each profile and temp `HOME` (`/tmp/ra-hb-arms-browser-<mode>-*`). It closed Chromium through Playwright and found no leftover browser process in either execution. Its totals were 21 containers created, 0 remaining and 0 networks per execution.

Every container this driver creates carries the run label and is removed, by immutable ID, at the end of its scenario and again in a final sweep. Both recorded executions finished with 23 containers created, 0 remaining and 0 networks created. The script fails with `Cleanup UNKNOWN` if an inventory query fails, rather than assuming the containers are gone. Runtime JSON other than the committed `results.json` stays local.

## Change log

- 2026-10-23: HB-05 to HB-08 executed against the reference fixture supervisor; driver, record and `results.json` committed.
- 2026-10-23: Added the HB-07 browser-client arm (`run_browser.py`, Playwright 1.58.0, Chromium build 1208) in three server configurations, executed twice with identical statuses. Fourteen rows with `arm: browser-<mode>` were appended to `results.json`; existing rows and `run.py` are unchanged. The HB-07 grades are unchanged.
