summaryrefslogtreecommitdiff
path: root/9harness/docs/DESIGN.md
diff options
context:
space:
mode:
Diffstat (limited to '9harness/docs/DESIGN.md')
-rw-r--r--9harness/docs/DESIGN.md208
1 files changed, 208 insertions, 0 deletions
diff --git a/9harness/docs/DESIGN.md b/9harness/docs/DESIGN.md
index 1b5f33e..4868bc8 100644
--- a/9harness/docs/DESIGN.md
+++ b/9harness/docs/DESIGN.md
@@ -214,6 +214,214 @@ HOME under a test tmp dir, never the real harness roots.
- `zig build programs-test` / `programs-itest` — the umbrella steps,
which include 9harness's.
+## /active: the derived view (v2)
+
+v1 mirrors files. `/active` is the first *semantic* layer: one directory
+per live agent, normalized across harnesses, so `ls /active` answers
+"what is running right now" whatever wrote it. It is the discovery half
+of `~/.local/bin/zmxify`, whose resolution ladder it ports.
+
+```
+/active/
+ claude/
+ 345104/
+ pid 345104 ppid 344980
+ started 1790013029 cwd /home/goblin
+ name goblin-e5 title the session's title
+ session 1f9a74f7-... model claude-opus-5[1m]
+ via registry zmx harness (read-write, below)
+ status busy only where the harness publishes one
+ transcript the live .jsonl, growing as it writes
+ agents/ <name>/{model,transcript}
+ omp/
+ 155574/ ...
+```
+
+A harness directory holds one directory per live process of it, named
+by pid. `/proc` spells it that way and so does this: a compound
+`claude-345104` would make you parse a name to recover `harness`, which
+is already the directory above it, and `/active/omp/*` would not glob.
+
+There is no `updated` file. The last write to a session is the mtime of
+`transcript`, which `stat` already carries; serving it again as its own
+file would be the same fact twice, and the copy is the one that goes
+stale.
+
+These are zmxify's picker columns — pid, harness, dir, age, zmx, via,
+session, title — as files, which is the point: the script stops
+scanning `/proc`, reading fds and querying sqlite itself and just reads
+the tree.
+
+**`status` is a promise only some harnesses make.** It is the harness's
+own word for what it is doing, and only Claude Code publishes one
+(`busy`, in `sessions/<pid>.json`); for every other harness the file is
+simply absent, the way a `skills` mount with no target is absent.
+Deriving one from the process's `/proc` state would answer a different
+question (`S` means "not on a CPU this instant", not "waiting for
+you"). For every other harness the honest activity signal is the mtime
+of `transcript`. `ls /active/*/*/status` tells you who publishes the
+stronger answer.
+
+An entry is named `<harness>-<pid>`: unique, stable for the process's
+life, and it sorts by harness. The pid is the identity because it is
+what `/proc` and every harness's own registry agree on.
+
+### Finding the agents
+
+`/proc` is scanned for a process whose `argv[0]` basename is `omp`,
+`claude`, `codex`, `hermes` or `dsh`, or a python running
+`hermes_cli.main`. A command line carrying one of the daemon words
+(`gateway`, `dashboard`, `mcp`, `mcp-server`, `app-server`,
+`exec-server`, `serve`, `daemon`, `acp`, `ps`, `render`, `export`,
+`__omp_worker_daemon_broker`) is a server or a helper, never a session.
+The daemon's own ancestry is excluded, so 9harness can never list or
+act on the process tree it lives in.
+
+Liveness is `/proc/<pid>` existing **and** its `stat` field 22
+(starttime) matching the one remembered for the slot. A pid that has
+been reused is a different process and does not list; a corpse never
+lists.
+
+### Resolving the session
+
+Each harness is asked in its own terms — the `fd -> dir -> db` ladder
+zmxify worked out, with the route named in `via` so a wrong guess is
+visible rather than silent:
+
+| harness | route | `via` |
+|---|---|---|
+| claude | `~/.claude/sessions/<pid>.json`, which the harness maintains itself: sessionId, cwd, name, status, version, and `procStart` as a pid-reuse guard | `registry` |
+| omp | the transcript it holds open under `~/.omp/agent/sessions/`, newest first; else the store directory named after its cwd with `/` becoming `-` | `fd`, `dir` |
+| dsh | the `session-<uuid>/session.jsonl.zstd` it holds open; zstd, so there is no title | `fd` |
+| codex | the rollout it holds open — `sessions/YYYY/MM/DD/rollout-<when>-<uuid>.jsonl`, whose *name* carries the session id, so its sqlite is never opened | `fd` |
+| hermes | nothing to resolve: see below | `none` |
+
+A resolved session is checked against the process's cwd before it is
+believed (the head of an omp transcript carries `"cwd"`). A process
+whose session does not resolve still appears, with everything `/proc`
+knows and an empty `session`: the view never pretends to know what it
+does not, and a move on it is refused.
+
+codex's id is the last 36 bytes of the rollout's name, not what
+splitting on `-` gives — the timestamp in front of it holds dashes too.
+
+**hermes is the one harness with no answer here, and it needs none.**
+Its sessions live only in `state.db`, with no per-session file to find;
+but the only hermes processes that run are the gateway and the
+dashboard, and both are daemon-shaped, so neither is a session anything
+should move. zmxify resolved a hermes id from sqlite and then declined
+to touch the process holding it, for the same reason. If an interactive
+hermes ever exists, this is the gap, and it is the one place sqlite
+would buy something.
+
+### Freshness
+
+A slot remembers only what identifies the agent: harness, pid,
+starttime, cwd, session id and transcript path. Every *field* is
+re-derived from disk when it is read, so `status` is never stale and a
+transcript grows under `cat`. `/proc` is rescanned on a readdir of
+`/active` and on a lookup that misses, not on every read.
+
+### `zmx`: the write path, and why it is not a ctl
+
+Writing a zmx session name into `/active/<id>/zmx` moves that agent into
+a zmx session of that name: the zmxify action half, as a file.
+
+Reading `zmx` gives the `ZMX_SESSION` of the process, empty when it runs
+outside zmx. Writing sets it. The file means *which zmx session this
+agent lives in*, and writing a name makes that true — state, not a verb
+channel. A `ctl` taking words would be the ordinary Plan 9 spelling
+(`/proc/n/ctl`), and an executable `zmxify` script served in the tree
+would be the zmx `attach` spelling, but a script that shells out to a
+local binary is a lie over a remote mount: it would run against a
+session that is not on the client's machine. A write is served where
+the authority is, so it survives being mounted from anywhere.
+
+What a write does, in order, refusing before it destroys anything:
+
+1. The name must be zmx's label charset (`[A-Za-z0-9._-]`) and unused by
+ a live session, else `EEXIST`.
+2. The agent's session must have resolved, else `EPERM`. Nothing is
+ killed that has nowhere to come back to — zmxify's invariant.
+3. `SIGTERM`, then `SIGKILL` after 5s, giving up at 12s with `EIO`.
+4. `fork`, `chdir` to the agent's cwd, `exec zmx run <name> -d` with a
+ **fixed argv per harness** (`claude --resume <sid>`,
+ `omp --resume <transcript>`, `codex resume <sid>`,
+ `hermes --resume <sid>`). No client byte ever reaches `exec`: the
+ only thing the client supplies is the session name, and it is
+ validated first.
+5. The write returns once `$XDG_RUNTIME_DIR/9p/zmx/<name>` appears
+ (zmx self-posts), or `EIO` on timeout.
+
+The agent comes back under a new pid, so the old `/active/<id>` is gone
+and a new entry takes its place with `zmx` reading the new name. The
+window between the kill and the exec is the same exposure zmxify has
+always had, and it is why step 2 comes first.
+
+**This is the tree's first write path, and it is an escalation**: a
+client that can write this file can kill the user's agents and cause a
+process to be spawned. It is therefore off unless `--allow-move` is
+given, and the read-only daemon stays the default. Everything else in
+the tree still answers `EPERM` to writes.
+
+### Where this is not Plan 9
+
+Named, because they are choices rather than oversights:
+
+* **`via` describes how the server found out**, not something true of
+ the process. That is diagnostics in the interface. It stays because a
+ resolution that guesses wrong silently is worse than a wart — it is
+ why zmxify's picker showed the route too.
+* **A write to `zmx` does not change the object, it replaces it.** The
+ process is killed and another starts under a new pid, so the entry
+ written to disappears. `/proc/n/ctl` accepting `kill` is at least
+ honest about being a verb with a consequence. The file still beats an
+ executable served in the tree, which would lie outright over a remote
+ mount, so it stays — with this paragraph.
+* **A move blocks the whole daemon** for as long as the kill and the
+ restart take, because one mutex spans `handle` and the reply. A Plan
+ 9 server keeps answering other fids meanwhile. The engine already has
+ parked replies and Tflush for exactly this; the move does not use
+ them yet, and that is the first thing to revisit.
+* **`/active` lives inside the mirror** rather than being its own
+ posted service. "What files exist" and "what is running" are
+ different jobs, and a stricter reading would separate them; they
+ share the pinned roots and the parsing glue, so they share a daemon.
+
+### Replacing zmxify
+
+`9harness/zmxify` is the whole script now: it lists `/active`, offers
+the rows to fzf, and writes the chosen name into the agent's `zmx`.
+It parses no `/proc`, opens no fd table and queries no database — the
+307 lines that did become a `cat` of a few files, and the knowledge
+they held now lives in a daemon with tests around it.
+
+What the script still decides is which rows to offer and what to call
+the new session. It skips an agent already under zmx — that is what
+this moves things *into* — and one whose session did not resolve, which
+the daemon would refuse anyway. A free name is the caller's business
+too, and the registry already answers which are taken.
+
+What it does **not** skip is the caller's own session, and that is the
+whole point of the daemon owning the action. The old script excluded
+its own ancestry because it did the killing itself: kill your own
+parent and the script dies before it can re-exec, losing the session it
+was rescuing. Now the script's only job is to get the `Twrite` out.
+The daemon kills, re-execs and waits for the new session to post, and
+it completes all of that whether or not the client is still there —
+verified by hanging up immediately after sending the write and watching
+the move land anyway. So zmxifying the terminal you are sitting in
+works; the shell drops, and `zmx attach <name>` is printed before the
+write, because there may be no script left to print it after.
+
+### Testability
+
+`--proc DIR` overrides `/proc` the way `--root NAME=PATH` overrides a
+harness root, so the scan, the liveness guard, the resolution ladder and
+the exclusions are unit-tested against a fixture process tree and never
+against the live machine. The move itself is exercised end-to-end
+against a fake harness in `test/e2e.sh`.
+
## Out of scope for v1 (documented, not hidden)
- Write paths (create/write/setattr stay EPERM), resume/attach