# WORKER-TRUTH triage — worker-perch lifecycle + visibility (operator-called 2026-07-06)

Batch: perri's live-diagnosed filings (W-1/W-2/W-3, P-1) + three operator riders
(list visibility, run-picker exclusion, parent-derived naming). Operator directive:
immediate diagnosis (done, this doc) + fix as a followup bugfix milestone.
Field context: flynn (live_agent, claude-spt:ccs, spt-hosted, core 0.25.0 /
adapter 0.14.1) ran CC Task subagents; 6 worker perches leaked OFFLINE in
`endpoint list` (two waves 06:46Z/09:08Z), all `session_id=""`, all dead ephemeral
hook-caller pids. Operator purged them via `endpoint purge --force` (works).

## Diagnosis (doyle, code-verified on main @ b78bf42)

**W-1 — worker-stop preserves the record (leak root #1).** `cmd_worker_stop`
(crates/spt/src/api/worker.rs:64) removes only the `ready` marker —
*deliberate* soft-cleanup (REQ-HAZARD-SOFT-CLEANUP: preserve info.json + spool
so in-flight results drain). The bug is not the preservation, it is that a
soft-stopped worker still renders as a permanent OFFLINE row in the default
`endpoint list`. Fix = visibility (V-1) + reap (W-3), not hard-delete on stop
(the cascade-wipe guard rationale stands).

**W-2 — registration drops the sid; stop-auth is effectively token-only (leak
root #2).** `cmd_worker_start` (worker.rs:44) writes
`InfoJson::new(id, stamp, std::process::id(), "", "worker")` — the presented
`--session-id` authenticates against the PARENT (mod.rs:444) but is never
stored; the worker record's sid is hardcoded `""` and its pid is the ephemeral
hook process. `worker-stop` is `gated(&id, …)` against the WORKER's record
(mod.rs:448) — a sid-authed stop compares against `""` and always refuses; only
the start's stdout token works. Every sibling id-scoped verb auths by sid;
workers alone are token-gated. Any adapter stopping by sid (the natural
symmetric read) leaks 100% of workers.

**OPERATOR RULING (binding, via perri):** worker-start does NOT mint a token;
worker-stop does NOT take one. Worker auth goes sid-symmetric with the sibling
verbs: registration STORES the sid it authenticated (the PARENT's sid at start
time). Stop compare: accept the parent's CURRENT sid **or** the stored
registration sid (a `/clear` between start and stop rotates the parent sid;
either endpoint of the rotation is honest custody — same class as
REQ-PSYCHE-SID-CUSTODY's rotation reasoning). Under this ruling the adapter's
current emission (`worker-stop <id> --session-id <parent sid>`) becomes correct
as-is.

**W-3 — no orphan reaping.** Confirmed: no code path reaps a worker whose
lifecycle never saw `worker-stop` (parent killed, abort, timeout — SubagentStop
never fires). The stored pid is useless as a liveness signal (it is the
worker-start hook process, dead by design microseconds later — see W-2). Reap
signals that ARE honest: (a) the PARENT's liveness/session (a worker cannot
outlive its parent's live session — reap all of a parent's soft-stopped +
orphaned workers at parent session-end/boundary and at parent death detection);
(b) a TTL floor for belt-and-braces (default generous, e.g. 24h since
`created`). Mirror shape: sister project's doctor D-21 orphan-worker GC.

**V-1 (operator rider) — workers hidden from plain `endpoint list`.** Worker
rows (endpoint_type `worker`) leave the default view; a flag (`--workers`)
shows them (sister shape: separate `list-working` view; we keep one command +
flag per our `--all`/`--detail` precedent, cli.rs:2695 `cmd_endpoint_list`).
Applies to human, `--json` (additive filter, absent-by-default rows are not a
JSON-parity break), and the registry projection legs. Workers should also stop
gossiping into the subnet registry as peer endpoints if they currently do
(verify during build; a worker is process-local machinery, not a subnet
citizen).

**V-2 (operator rider) — workers out of the `endpoint run` picker.** A worker
perch cannot be driven, instantiated, or controlled — it must not be offered as
a run target. Filter endpoint_type `worker` (and `psyche` if it ever leaks —
same non-drivable class) from every picker source leg.

**N-1 (operator rider) — core-minted parent-derived names.** Worker ids today
are whatever the adapter passes (CC agent ids → "completely random" rows).
Sister shape (claude_skill_owl hook_subagent_start.rs): mint `{parent}-w{N}`
with a per-parent counter at registration. Design: `worker-start` mints and
echoes the id (`WORKER_STARTED:{parent}-w{N} …`); the adapter-presented
agent_id/agent_type ride as metadata on the record (correlation), never as the
perch identity. Contract change — coordinate the verb shape with perri BEFORE
freezing (perri holds adapter emission today; perri also ships loud-refusal
logging once the shape settles).

**P-1 — psyche turn faults on `{psyche_dir}` (URGENT-adjacent, adapter+core
split).** flynn's psyche died (3-strike budget) on
`no value for substitution key {psyche_dir}` — the adapter's
`[session.psyche_resume]` carries `cwd="{psyche_dir}"`, a key that belonged to
the RETIRED resident `psyche_init` shape. Core verdict: **0.26.0 does NOT
carry a fix and none is needed core-side for the key itself** — the freeze
contract (lifecycle.rs:841 `run_psyche_event_turn` doc; the F-030 design doc)
never lists `{psyche_dir}`; the catalog retired it deliberately
(runtime.rs:65 — "a catalog key must have a real fill"). Fix is ADAPTER-side
(perri: drop/replace the cwd in `psyche_resume`; core fills no psyche cwd —
harness-boundary rule). CORE-side hardening this milestone: **registration-time
template-key validation** — `adapter add`/`update` validates every declared
role template (command + cwd + [env] values) against the fill-key catalog and
refuses with the offending key + role named (same fail-fast family as the
[strings] pointer validation, manifest.md:411). A permanent config fault must
die at registration, not at the Nth psyche turn in the field.

## Proposed REQs (registered now, `required_stages = []` — activate per-wave)

- `REQ-WORKER-SID-SYMMETRIC-AUTH` — W-2 + operator ruling (store sid; stop by
  current-or-registration sid; token machinery retired).
- `REQ-WORKER-REAP` — W-3 (parent-session-end/death reap + TTL floor; dead-pid
  rows never persist indefinitely).
- `REQ-WORKER-LIST-VISIBILITY` — V-1 (default list excludes workers; `--workers`
  reveals; no registry gossip as peer endpoints).
- `REQ-WORKER-PICKER-EXCLUDED` — V-2 (non-drivable classes never picker rows).
- `REQ-WORKER-MINTED-NAME` — N-1 (`{parent}-w{N}` core-minted; adapter ids =
  metadata).
- `REQ-ADAPTER-TEMPLATE-KEY-VALIDATION` — P-1 core half (registration-time
  catalog validation, loud refusal).

## Build order

1. **N-1 + W-2 together** (both change the worker-start/stop verb shape — ONE
   contract freeze, perri coordinates once).
2. **V-1 + V-2** (pure view filters; unblock the operator-visible symptom
   immediately; independent of 1).
3. **W-3 reap** (needs W-2's stored sid/parent linkage for the session-end leg).
4. **P-1 validation** (independent; any time).
5. perri adapter legs (psyche_resume cwd fix = URGENT, ships independent of
   core; loud-refusal logging + worker verb migration after the freeze in 1).

Roles: todlando executes core waves, doyle gates per chunk, perri owns adapter
halves (blind-builds from published docs — publish the frozen verb shape to
docs-site with the wave that lands it).

## P-2 addendum (perri-filed 2026-07-06, post-triage; doyle-diagnosed same day)

**P-2 — psyche turns run in the WRONG ACCOUNT ROOT (unauthenticated) for
profile-relocated parents.** flynn's post-0.14.2 turns spawn clean (P-1 fixed,
hot-reload live-proven) but every turn answers headless "Not logged in" → exit 1
→ strike loop (correctly no reseed — the session exists, auth is absent). Root:
the ccs wrapper sets `CLAUDE_CONFIG_DIR` at PARENT launch; the perch record
CAPTURED it (the `[env] direction="read"` seam, startup.rs:291) — but the daemon
spawns `psyche_resume` with bare env, so the psyche lands in the default
`~/.claude` root: unauthenticated here, and a DIFFERENT root than the parent
(breaks root-scoped continuity generally, not just auth).

**Contract ruling (the fix fork perri posed): YES — core gap.** The F-027
Half-B env-parity contract (design-frozen BINDING,
F-027-ENDPOINT-SPAWN-FAIL-DESIGN.md: captured-at-creation read_env stamped on
the record, threaded IDENTICALLY to spawns, spawn never reads its own env for a
stamped var) covers exactly this class and was NEVER IMPLEMENTED (impl parked
post-F-029, zero `pre_spawn` code hits). P-2 = its first field casualty, on a
role (psyche_resume) the design predates. Fix = `REQ-PSYCHE-SPAWN-ENV-PARITY`:
thread the perch record's read_env stamps into the psyche_resume spawn env.
Perri's manifest needs NOTHING (CLAUDE_CONFIG_DIR already declared read;
captured value already on flynn's record). The FULL F-027 Half B (pre_spawn
command + endpoint-session spawn threading) stays design-parked unless the
operator pulls it forward — flag at next triage.

**Interim mitigation (no code):** `claude /login` in the default root on the
affected box heals psyches now; account/billing choice is the operator's
(perri surfacing). Note the base-profile caveat: any box whose default root
is unauthenticated has silently dead psyches today — reinforces
stamp-the-captured-root over per-box login hygiene.

Build order update: P-2 wave rides after wave-1 (independent of it; small,
daemon-side) — ahead of W-3 if flynn's operator wants the psyche healed by
code rather than login.
