# Anything You Background Is Bounded, and Fails Loud

A command started with `run_in_background: true` — or with a bare `&`
inside one — is reparented to init when the session ends. It leaves the
terminal's process group, so closing the tab never signals it, and under
a terminal multiplexer the session outlives the terminal anyway. A
background task therefore cannot be assumed to die when you do. It has
to be built so that it dies on its own.

This is the runtime's execution model, not any one project's domain. Any
session that backgrounds a poll — CI, a deploy, a build, a queue, a
remote job — inherits it.

## Three properties, and all three are load-bearing

1. **A hard ceiling.** A watcher must not be able to outlive the thing
   it reports to. Bound the loop by iterations, not by hope. `while
   true` and an unbounded `until` are both unbounded, however short the
   sleep.

2. **An unreadable result EXITS; it does not keep looping.** `$(cmd)`
   on failure yields `""`, `""` never equals the success sentinel, and
   the loop becomes infinite rather than merely slow. Treat an empty
   read as a distinct terminal state, and check for it explicitly —
   this is the sharper half of the failure, because a broken credential
   or a dropped network turns a ten-minute watcher into a permanent
   process.

3. **"Blind" and "timed out" are not "success."** Report them as their
   own outcomes, with their own exit codes. A watcher that could not
   read must say so. Silence that reads as green is the failure the
   rule exists to prevent — a watcher armed against blindness that goes
   blind and reports nothing is worse than no watcher at all.

## The reference shape

Three distinct exits: success, blind, timed out. The CI watcher is the
worked example, not the subject — the shape is the same for a deploy, a
build, or any other polled remote state.

```bash
RID=<run-id>
for _ in $(seq 60); do                    # ceiling: 60 x 20s = 20 min
  S=$(gh run view "$RID" --json status -q .status 2>/dev/null) || S=""
  if [ "$S" = "completed" ]; then
    echo "CI: $(gh run view "$RID" --json conclusion -q .conclusion)"
    exit 0
  fi
  if [ -z "$S" ]; then
    echo "WATCH BLIND — the read returned nothing (auth? network?)."
    echo "This is NOT a green run."
    exit 2
  fi
  sleep 20
done
echo "WATCH TIMED OUT after 20m — run $RID still '$S'. Check by hand."
exit 3
```

## Prefer the tool's own watcher, then bound it anyway

Where the tool ships a blocking watcher, use it — a hand-rolled poll
loop is a reinvention (see `reach-for-the-stock-solution.md`). It still
needs the ceiling: a blocking watcher has no deadline of its own, so it
inherits property 1 unchanged.

## Portability

macOS ships no `timeout` or `gtimeout` (no coreutils by default), so the
ceiling has to be portable shell — a counted loop — rather than a
`timeout` prefix. Do not reach for a wrapper that is absent on the
machine the session is actually running on.

## The test

**If this session died right now, would this process stop?** If the
only answer is "someone will notice it in `ps`", it is unbounded.

## Why this is a rule

Enforcement type: **guide** (per `enforcement-pipeline.md`). It is a
judgment applied at authoring time, in whatever shell the task happens
to need, so no hook can see it — the rule has to fire before the loop
is written.

It is stated as a rule rather than left to judgment because the cost is
invisible until someone reads `ps`. Measured, 2026-08-13: a project rule
requiring a background CI watcher shipped an unbounded `until` snippet,
sessions followed it faithfully, and the operator's machine was found
running **two orphaned watchers from long-dead sessions — one up 1 day,
one up 31 days** — each with a `sleep 30` child, polling for workflow
runs whose commits had landed weeks earlier. The likely cause of the
31-day process is property 2: that project's API token had been silently
failing every call for months, so the read returned `""` forever and the
loop could not terminate. **The rule that existed to prevent blindness
was itself blind, in exactly the condition it was written for.**

Same class, no CI involved: three orphaned site-audit processes plus 22
browser descendants once held ~704 MB of *host* memory for 35 hours,
and surfaced only as intermittent test-suite failures elsewhere.

This rule owns the SHAPE of a backgrounded task. When to arm one, which
branches or deploys are covered, and what to do with the answer stay
with the project rule that arms it.
