diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-22 10:30:02 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-25 17:52:33 -0300 |
| commit | df20863879fe2d83077534f4726a985ffc239def (patch) | |
| tree | 3f13dfc786509d1e2c599894a8beea48695e253c | |
| parent | eb1a104f385f375a74319695e1a59f6f82e6384e (diff) | |
| download | cloud9-df20863879fe2d83077534f4726a985ffc239def.tar.gz cloud9-df20863879fe2d83077534f4726a985ffc239def.zip | |
Rename 9harness to 9agents; README, file-backed qids, worded errors
- 9harness/ becomes 9agents/ (build option -D9agents, package paths).
- 9agents serves /README, reports qid paths from the file's (dev, ino)
and qid versions that move with the file, and names its refusals.
| -rw-r--r-- | 9agents/build.zig (renamed from 9harness/build.zig) | 28 | ||||
| -rw-r--r-- | 9agents/docs/DESIGN.md (renamed from 9harness/docs/DESIGN.md) | 112 | ||||
| -rw-r--r-- | 9agents/src/active.zig (renamed from 9harness/src/active.zig) | 2 | ||||
| -rw-r--r-- | 9agents/src/main.zig (renamed from 9harness/src/main.zig) | 65 | ||||
| -rw-r--r-- | 9agents/src/tree.zig (renamed from 9harness/src/tree.zig) | 292 | ||||
| -rwxr-xr-x | 9agents/test/e2e.sh (renamed from 9harness/test/e2e.sh) | 83 | ||||
| -rw-r--r-- | build.zig | 16 | ||||
| -rw-r--r-- | build.zig.zon | 4 |
8 files changed, 453 insertions, 149 deletions
diff --git a/9harness/build.zig b/9agents/build.zig index 4cf84d6..0a63d9b 100644 --- a/9harness/build.zig +++ b/9agents/build.zig @@ -1,12 +1,12 @@ -//! Build fragment for 9harness: the harness fs daemon (binary `9harness`), +//! Build fragment for 9agents: the coding-agents fs daemon (binary `9agents`), //! a read-only fresh-from-disk 9P2000 view of every harness's state, -//! posted by default under the name `harness`. It is `@import`ed by the +//! posted by default under the name `agents`. It is `@import`ed by the //! root build.zig and called with the root builder, so every `b.path(...)` -//! here is relative to the cloud9 root (hence the `9harness/` prefix), +//! here is relative to the cloud9 root (hence the `9agents/` prefix), //! every option is defined by the root and every step it registers lands -//! in the root's step list under the `9harness` prefix. +//! in the root's step list under the `9agents` prefix. //! -//! Steps: 9harness, 9harness-test, 9harness-itest. +//! Steps: 9agents, 9agents-test, 9agents-itest. const std = @import("std"); pub const Context = struct { @@ -21,32 +21,32 @@ pub const Context = struct { pub const Artifacts = struct { exe: *std.Build.Step.Compile, - /// `9harness-test`: the tree's unit tests (exclusions, path safety, + /// `9agents-test`: the tree's unit tests (exclusions, path safety, /// node ids, vanishing files — all against a fake HOME). test_step: *std.Build.Step, - /// `9harness-itest`: test/e2e.sh (fixture roots + scratch registry, - /// plus the real-registry end-to-end when the `harness` name is free). + /// `9agents-itest`: test/e2e.sh (fixture roots + scratch registry, + /// plus the real-registry end-to-end when the `agents` name is free). itest_step: *std.Build.Step, }; pub fn add(b: *std.Build, ctx: Context) Artifacts { const mod = b.createModule(.{ - .root_source_file = b.path("9harness/src/main.zig"), + .root_source_file = b.path("9agents/src/main.zig"), .target = ctx.target, .optimize = ctx.optimize, .imports = &.{.{ .name = "cloud9", .module = ctx.cloud9 }}, }); - const exe = b.addExecutable(.{ .name = "9harness", .root_module = mod }); + const exe = b.addExecutable(.{ .name = "9agents", .root_module = mod }); const install = b.addInstallArtifact(exe, .{}); b.getInstallStep().dependOn(&install.step); - b.step("9harness", "Build and install only the harness fs daemon").dependOn(&install.step); + b.step("9agents", "Build and install only the coding-agents fs daemon").dependOn(&install.step); - const test_step = b.step("9harness-test", "Run the 9harness tree's unit tests (fake HOME; never the live roots)"); + const test_step = b.step("9agents-test", "Run the 9agents tree's unit tests (fake HOME; never the live roots)"); test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = mod })).step); - const itest_step = b.step("9harness-itest", "Run 9harness/test/e2e.sh (posted name, reads, cmp of a transcript, mntgen mount, exclusions, live appends)"); + const itest_step = b.step("9agents-itest", "Run 9agents/test/e2e.sh (posted name, reads, cmp of a transcript, mntgen mount, exclusions, live appends)"); const run = b.addSystemCommand(&.{"bash"}); - run.addFileArg(b.path("9harness/test/e2e.sh")); + run.addFileArg(b.path("9agents/test/e2e.sh")); run.addArtifactArg(exe); if (ctx.ns) |ns| run.addArtifactArg(ns); itest_step.dependOn(&run.step); diff --git a/9harness/docs/DESIGN.md b/9agents/docs/DESIGN.md index 340f3da..8d399d3 100644 --- a/9harness/docs/DESIGN.md +++ b/9agents/docs/DESIGN.md @@ -1,6 +1,6 @@ -# 9harness design +# 9agents design -9harness is the harness fs daemon: a long-running 9P2000 server that +9agents is the coding-agents fs daemon: a long-running 9P2000 server that replaces the `zmxify` rc script's introspection half with a unified, file-shaped, always-fresh view of every AI-agent harness's state on the machine — Claude Code (`~/.claude`), Codex (`~/.codex`), omp (`~/.omp`), @@ -13,13 +13,16 @@ One binary, hosted Linux only (it posts through `cloud9.post`). The backend is `src/tree.zig`, a `cloud9.fs.Server` backend run by `cloud9.serve.Runner`; the CLI is `src/main.zig`. Like its siblings (9proc, 9ns) the build is a fragment in `build.zig` wired into the root -behind `-D9harness` (steps `9harness`, `9harness-test`, `9harness-itest`; +behind `-D9agents` (steps `9agents`, `9agents-test`, `9agents-itest`; installed by the plain `zig build` next to the other programs). ## The tree (v1 contract) ``` / read-only root (dr-xr-xr-x) +/README the tree explained in place: what each entry is, + the /active fields, and the exclusions. Static text, + so it is the one fact needing no buffer. /pid the daemon's pid, one line /uptime seconds since start, one line /claude/ @@ -101,6 +104,72 @@ names with `/` or NUL are refused before the filesystem is touched, `.` and `..` are refused as components inside the walk, and `..` as a lookup resolves through the backend's own parent map, never the kernel's. +## Qids, and what the protocol says they mean + +Read out of the Plan 9 source (`~/05-genizah/principia-softwarica`), not +recalled — the three rules below were all being broken. + +`intro(5)`: "The qid represents the server's unique identification for the +file being accessed: two files on the same server hierarchy are the same if +and only if their qids are the same. ... The path is an integer unique among +all files in the hierarchy. If a file is deleted and recreated with the same +name in the same directory, the old and new path components of the qids +should be different. The version is a version number for a file; typically, +it is incremented every time the file is modified." + +* **The qid path is the file's identity, not the server's bookkeeping.** The + backend's node id is a slot in the path table, and a slot freed by its last + release returns with a new generation — the guard that stops a forged node + id resolving. Reporting that as the qid path meant `9p stat` of one + unchanged file answered a *different* path every time (`...50000104`, + `...70000104`, `...90000104`), which by the rule above says "a different + file" on every walk. The qid path now comes from the file itself, the way + u9fs builds one — `qid.vers = st->st_mtime ^ (st->st_size << 8)` and a path + from the inode with the device mixed in (`u9fs.c`) — while the engine goes + on resolving by node id. `fs.Attr.path` exists for exactly this split and + the engine uses it for nothing else. The device is folded in because an + inode number is only unique within its filesystem and the five roots need + not share one. + +* **The qid version has to move when the file does.** It was 0 everywhere, + which tells every client that nothing this server holds ever changes — + precisely wrong for a tree whose whole point is transcripts that grow while + you read them. It is now `mtime ^ (size << 8)` for a file on disk (u9fs's + formula: the shift is what makes an append within one second still change + it) and a hash of the text for the fields this server derives rather than + reads, where `status` flipping `busy` to `idle` keeps its size and only the + content can carry the change. + +* **A directory's length is 0 on purpose.** `stat(5)`: "Directories and most + files representing devices have a conventional length of 0." That part of + the engine's "fidelity gap" note below is the convention, not a shortfall. + +## Errors are strings + +`error(5)` is `Rerror tag[2] ename[s]`: 9P2000 has no numeric error, and acme +names its refusals in words (`Eperm[] = "permission denied"`, `Eexist[] = +"file does not exist"`, `editors/acme/fsys.c`). A server that answers only +bare errnos throws away the one channel the protocol gives it for explaining +itself, and a move has seven different reasons to refuse that all used to +arrive as one undifferentiated `EPERM`: moves not allowed, illegal name, no +session resolved, no cwd, dsh has no resume form, nothing to resume, the name +is taken. Each says so now, and so does the over-cap listing, which used to +claim "Too many open files in system" — a true sentence about the wrong +resource. + +The errno is still carried beside the text, because the reader may be a Linux +mount rather than a person: 9ns maps the *text* back to an errno by +case-insensitive substring, first match wins (`enameToErrno`, 9ns/src/nine.zig). +So each message keeps the phrase that carries its errno — "not permitted" for +EPERM, "invalid" for EINVAL, "exists" for EEXIST, "no such" for ENOENT — and +avoids the ones that would silently retarget it: "permission" and "denied" map +to EACCES, "read-only" to EROFS, and the bare substring "fid" to EBADF. That +is why the read-only refusal reads "9agents serves a view, not a store: +writing is not permitted" and not the more obvious wording. + +Opens refused by the mode bits never reach the backend at all: the engine +checks `perm` first and answers `e_perm`, which is already acme's spelling. + ## Backend shape `Harness` (tree.zig) is the backend of `fs.Server(Harness, opts)` on @@ -149,14 +218,14 @@ per-connection buffers; ~3 MB of tables, untouched pages cost nothing). ## CLI and process model ``` -usage: 9harness [--unix PATH | --tcp IP:PORT | --fd N] [--no-post] +usage: 9agents [--unix PATH | --tcp IP:PORT | --fd N] [--no-post] [--name NAME] [--root NAME=PATH]... ``` -- Default: post itself under the name `harness` via +- Default: post itself under the name `agents` via `serve.Runner.listenPosted`, so it appears at - `$XDG_RUNTIME_DIR/9p/harness` and every interactive fish (self-wrapped - in `9ns --mntgen`) sees it at `/mnt/9p/harness` with zero + `$XDG_RUNTIME_DIR/9p/agents` and every interactive fish (self-wrapped + in `9ns --mntgen`) sees it at `/mnt/9p/agents` with zero configuration. `stop()` unposts; the stale-socket protocol covers a killed daemon. - `--unix`/`--tcp` listen forms (beside the post, or with `--no-post`), @@ -167,8 +236,13 @@ usage: 9harness [--unix PATH | --tcp IP:PORT | --fd N] [--no-post] descriptor N (socket activation, `9ns --spawn` handoffs), driving the engine directly; it posts nothing. - SIGTERM/SIGINT stop cleanly (unpost); SIGPIPE is ignored. -- No daemonization in v1: run it in a zmx session, the zmx way — - `zmx run harness -d 9harness`. +- No daemonization in v1: the daemon never forks or detaches, so it is + run under something that supervises it. On this machine that is a + systemd user service (`~/.config/systemd/user/9agents.service`, + `Restart=always`, lingering on), which is what keeps `/mnt/9p/agents` + there across logouts and reboots; a restart reclaims the posted name + through the stale-socket protocol. For a throwaway instance the zmx + way still works — `zmx run agents -d 9agents`. ## Integration test plan (`test/e2e.sh`) @@ -188,11 +262,11 @@ scratch `XDG_RUNTIME_DIR` registry, plan9port's `9p` as the client): 7. SIGTERM unposts the name. Part B is the money shot, read-only against the real roots and the real -`$XDG_RUNTIME_DIR/9p` — only when the `harness` name is free (a live +`$XDG_RUNTIME_DIR/9p` — only when the `agents` name is free (a live daemon owning the name skips it, not fails): the posted name in the real registry, `9p` walks of the live `~/.claude`, a real transcript byte-identical through the tree, the live `~/.claude/.credentials.json` -unreachable, the `9ns --mntgen` mount of `/mnt/9p/harness` with +unreachable, the `9ns --mntgen` mount of `/mnt/9p/agents` with `head` of `/claude/history`, and the stop unposting the real name. Unit tests (tree.zig) cover the exclusions (including the predicate @@ -205,14 +279,14 @@ HOME under a test tmp dir, never the real harness roots. ## Verification -- `zig build` (installs `9harness` beside 9ns, 9proc-demo, 9web). +- `zig build` (installs `9agents` beside 9ns, 9proc-demo, 9web). - `zig build test` — the library's 80 tests stay green. -- `zig build 9harness-test` — the tree's unit tests. -- `zig build 9harness-itest` — `test/e2e.sh` (fixture + real registry). +- `zig build 9agents-test` — the tree's unit tests. +- `zig build 9agents-itest` — `test/e2e.sh` (fixture + real registry). - `zig build 9proc-check-freestanding` — the daemon is hosted-only but must not break the root module. - `zig build programs-test` / `programs-itest` — the umbrella steps, - which include 9harness's. + which include 9agents's. ## /active: the derived view (v2) @@ -274,7 +348,7 @@ what `/proc` and every harness's own registry agree on. (`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 +The daemon's own ancestry is excluded, so 9agents can never list or act on the process tree it lives in. Liveness is `/proc/<pid>` existing **and** its `stat` field 22 @@ -393,9 +467,9 @@ Named, because they are choices rather than oversights: There is no replacement script, because the mount is the replacement: ``` -ls /mnt/9p/harness/active/*/* what is running -cat /mnt/9p/harness/active/claude/345104/session what it is -echo mine > /mnt/9p/harness/active/claude/345104/zmx +ls /mnt/9p/agents/active/*/* what is running +cat /mnt/9p/agents/active/claude/345104/session what it is +echo mine > /mnt/9p/agents/active/claude/345104/zmx zmx attach mine ``` diff --git a/9harness/src/active.zig b/9agents/src/active.zig index fb55cf6..daaddc9 100644 --- a/9harness/src/active.zig +++ b/9agents/src/active.zig @@ -324,7 +324,7 @@ pub const Sources = struct { /// empty base means that harness is unreachable. roots: [5][]const u8 = @splat(""), /// The daemon's own pid: it and its ancestors are never listed, so - /// 9harness can neither show nor act on the tree it lives in. Zero + /// 9agents can neither show nor act on the tree it lives in. Zero /// disables the check (fixtures have no ancestry). self_pid: u32 = 0, /// Seconds between the epoch and boot, from `/proc/stat`'s `btime`. diff --git a/9harness/src/main.zig b/9agents/src/main.zig index fe0a07b..2ac9078 100644 --- a/9harness/src/main.zig +++ b/9agents/src/main.zig @@ -1,19 +1,21 @@ -//! 9harness: the harness fs daemon — a long-running 9P2000 server that +//! 9agents: the coding-agents fs daemon — a long-running 9P2000 server that //! mirrors every AI-agent harness's state (Claude Code, Codex, omp, //! hermes, dsh + their skills) as one read-only, fresh-from-disk tree. //! See docs/DESIGN.md for the tree contract and src/tree.zig for the tree. //! -//! By default it posts itself under the name `harness` with -//! `serve.Runner.listenPosted`, so it appears at $XDG_RUNTIME_DIR/9p/harness +//! By default it posts itself under the name `agents` with +//! `serve.Runner.listenPosted`, so it appears at $XDG_RUNTIME_DIR/9p/agents //! and every interactive fish (self-wrapped in `9ns --mntgen`) sees it at -//! /mnt/9p/harness with zero configuration. `--unix`, `--tcp` and `--fd` +//! /mnt/9p/agents with zero configuration. `--unix`, `--tcp` and `--fd` //! are the other listen forms; `--no-post` serves without posting; the //! five harness roots default to $HOME/.<name> and `--root NAME=PATH` //! pins any of them elsewhere (the tests use fake homes). //! -//! v1 has no daemonization: run it in a zmx session, the zmx way: +//! v1 has no daemonization: it never forks or detaches, so it is run under +//! something that supervises it — a systemd user service with +//! `Restart=always` (see 9agents.service), or a zmx session: //! -//! zmx run harness -d 9harness +//! zmx run agents -d 9agents //! //! SIGTERM or SIGINT stops it cleanly (a posted name is unposted). const std = @import("std"); @@ -29,11 +31,12 @@ const Io = std.Io; const linux = std.os.linux; const usage_text = - \\usage: 9harness [--unix PATH | --tcp IP:PORT | --fd N] [--no-post] + \\usage: 9agents [--unix PATH | --tcp IP:PORT | --fd N] [--no-post] \\ [--name NAME] [--root NAME=PATH]... [--proc DIR] \\ [--allow-move] [--zmx PATH] \\ \\A read-only, fresh-from-disk 9P2000 view of every harness's state: + \\ /README the tree, explained in place \\ /pid /uptime daemon facts \\ /claude/{projects,history,skills} \\ /codex/{sessions,session-index,history} @@ -41,9 +44,9 @@ const usage_text = \\ /skills/{claude,codex,omp} the union skills view \\ /active/<harness>/<pid>/ what is running right now \\ - \\By default the daemon posts itself under the name `harness`, so it is - \\dialable at $XDG_RUNTIME_DIR/9p/harness and mountable by 9ns --mntgen - \\at /mnt/9p/harness. --no-post skips posting; --unix/--tcp add plain + \\By default the daemon posts itself under the name `agents`, so it is + \\dialable at $XDG_RUNTIME_DIR/9p/agents and mountable by 9ns --mntgen + \\at /mnt/9p/agents. --no-post skips posting; --unix/--tcp add plain \\listeners beside the post; --fd N serves one 9P session over the \\connected stream on descriptor N and posts nothing. \\--root NAME=PATH pins one harness root (NAME: claude, codex, omp, @@ -57,7 +60,7 @@ const usage_text = \\that can mount it can then kill an agent. --zmx PATH names the \\binary a move runs (default: zmx, found on $PATH). \\ - \\Run it in a zmx session, the zmx way: `zmx run harness -d 9harness`. + \\Run it in a zmx session, the zmx way: `zmx run agents -d 9agents`. \\ ; @@ -144,7 +147,7 @@ fn run(init: std.process.Init) !void { var unix_path: []const u8 = ""; var tcp_addr: []const u8 = ""; var fd_no: i32 = -1; - var post_name: []const u8 = "harness"; + var post_name: []const u8 = "agents"; var no_post = false; var base_overrides: [5]?[]const u8 = @splat(null); var proc_root: []const u8 = "/proc"; @@ -165,7 +168,7 @@ fn run(init: std.process.Init) !void { { i += 1; if (i >= args.len) { - std.debug.print("9harness: {s} needs an argument\n{s}", .{ a, usage_text }); + std.debug.print("9agents: {s} needs an argument\n{s}", .{ a, usage_text }); return error.Usage; } const v = args[i]; @@ -178,7 +181,7 @@ fn run(init: std.process.Init) !void { } else if (std.mem.eql(u8, a, "--fd")) { mode = .fd; fd_no = std.fmt.parseInt(i32, v, 10) catch { - std.debug.print("9harness: --fd: not a number: {s}\n", .{v}); + std.debug.print("9agents: --fd: not a number: {s}\n", .{v}); return error.Usage; }; } else if (std.mem.eql(u8, a, "--proc")) { @@ -188,17 +191,17 @@ fn run(init: std.process.Init) !void { } else if (std.mem.eql(u8, a, "--name")) { post_name = v; if (!post.legalName(post_name)) { - std.debug.print("9harness: illegal post name: {s}\n", .{v}); + std.debug.print("9agents: illegal post name: {s}\n", .{v}); return error.Usage; } } else { const eq = std.mem.indexOfScalar(u8, v, '=') orelse { - std.debug.print("9harness: --root NAME=PATH: {s}\n{s}", .{ v, usage_text }); + std.debug.print("9agents: --root NAME=PATH: {s}\n{s}", .{ v, usage_text }); return error.Usage; }; const name = v[0..eq]; const root = std.meta.stringToEnum(tree.Root, name) orelse { - std.debug.print("9harness: unknown root {s} (claude, codex, omp, hermes, dsh)\n", .{name}); + std.debug.print("9agents: unknown root {s} (claude, codex, omp, hermes, dsh)\n", .{name}); return error.Usage; }; base_overrides[@intFromEnum(root)] = v[eq + 1 ..]; @@ -207,7 +210,7 @@ fn run(init: std.process.Init) !void { std.debug.print("{s}", .{usage_text}); return; } else { - std.debug.print("9harness: unknown argument {s}\n{s}", .{ a, usage_text }); + std.debug.print("9agents: unknown argument {s}\n{s}", .{ a, usage_text }); return error.Usage; } } @@ -225,7 +228,7 @@ fn run(init: std.process.Init) !void { var any = false; for (bases) |b| any = any or b.len != 0; if (!any) { - std.debug.print("9harness: no harness roots (set $HOME or pass --root NAME=PATH)\n", .{}); + std.debug.print("9agents: no harness roots (set $HOME or pass --root NAME=PATH)\n", .{}); return error.Usage; } @@ -236,7 +239,7 @@ fn run(init: std.process.Init) !void { else onPath(io, arena, post.getenv(envp, "PATH") orelse "", zmx_path) catch zmx_path; if (allow_move and std.mem.indexOfScalar(u8, zmx_abs, '/') == null) { - std.debug.print("9harness: --allow-move needs zmx on $PATH (or --zmx PATH)\n", .{}); + std.debug.print("9agents: --allow-move needs zmx on $PATH (or --zmx PATH)\n", .{}); return error.Usage; } @@ -252,10 +255,10 @@ fn run(init: std.process.Init) !void { .envp = envp, }); if (allow_move) { - std.debug.print("9harness: moves allowed — a write to /active/<h>/<pid>/zmx re-execs that agent under {s}\n", .{zmx_abs}); + std.debug.print("9agents: moves allowed — a write to /active/<h>/<pid>/zmx re-execs that agent under {s}\n", .{zmx_abs}); } if (no_post and mode == .posted) { - std.debug.print("9harness: --no-post needs a listen form (--unix, --tcp or --fd)\n{s}", .{usage_text}); + std.debug.print("9agents: --no-post needs a listen form (--unix, --tcp or --fd)\n{s}", .{usage_text}); return error.Usage; } @@ -263,7 +266,7 @@ fn run(init: std.process.Init) !void { switch (mode) { .fd => { - std.debug.print("9harness: serving one session on fd {d}\n", .{fd_no}); + std.debug.print("9agents: serving one session on fd {d}\n", .{fd_no}); try serveFd(io, fd_no); return; }, @@ -277,29 +280,29 @@ fn run(init: std.process.Init) !void { if (!no_post) { runner_mem.listenPosted(envp, post_name, 16) catch |err| { if (err == error.AlreadyPosted) { - std.debug.print("9harness: the name `{s}` is already posted by a live server\n", .{post_name}); + std.debug.print("9agents: the name `{s}` is already posted by a live server\n", .{post_name}); return err; } - std.debug.print("9harness: cannot post as {s}: {t}\n", .{ post_name, err }); + std.debug.print("9agents: cannot post as {s}: {t}\n", .{ post_name, err }); return err; }; posted_something = true; var pbuf: [transport.sun_path_len]u8 = undefined; const path = post.registryPath(envp, post_name, &pbuf) catch ""; - std.debug.print("9harness: posted as {s} at {s}\n", .{ post_name, path }); + std.debug.print("9agents: posted as {s} at {s}\n", .{ post_name, path }); } switch (mode) { .unix => { _ = try runner_mem.listen(.{ .unix = try arena.dupeZ(u8, unix_path) }, 16); - std.debug.print("9harness: listening on {s}\n", .{unix_path}); + std.debug.print("9agents: listening on {s}\n", .{unix_path}); }, .tcp => { const addr = Io.net.IpAddress.parseLiteral(tcp_addr) catch { - std.debug.print("9harness: bad --tcp address: {s}\n", .{tcp_addr}); + std.debug.print("9agents: bad --tcp address: {s}\n", .{tcp_addr}); return error.Usage; }; const bound = try runner_mem.listen(.{ .tcp = addr }, 16); - std.debug.print("9harness: listening on tcp!{f}\n", .{bound}); + std.debug.print("9agents: listening on tcp!{f}\n", .{bound}); }, else => {}, } @@ -307,14 +310,14 @@ fn run(init: std.process.Init) !void { for (bases) |b| { if (b.len != 0) pinned += 1; } - std.debug.print("9harness: serving ({d} roots pinned, {d} bytes of tables)\n", .{ pinned, @sizeOf(tree.Harness) }); + std.debug.print("9agents: serving ({d} roots pinned, {d} bytes of tables)\n", .{ pinned, @sizeOf(tree.Harness) }); // The runner's tasks drive themselves; the main thread only waits for // a stop signal (SIGTERM/SIGINT) and then unposts via stop(). while (!stop_requested.load(.acquire)) { io.sleep(.fromMilliseconds(250), .awake) catch break; } - std.debug.print("9harness: stopping\n", .{}); + std.debug.print("9agents: stopping\n", .{}); } const root_dirs = [5][]const u8{ "claude", "codex", "omp", "hermes", "dsh" }; diff --git a/9harness/src/tree.zig b/9agents/src/tree.zig index b6ff4a4..ed0699c 100644 --- a/9harness/src/tree.zig +++ b/9agents/src/tree.zig @@ -1,6 +1,7 @@ //! The harness file tree served over 9P2000: a unified, read-only, //! fresh-from-disk view of every AI-agent harness's state on the machine. //! +//! /README what the tree is and how to read it //! /pid /uptime daemon facts, one line each //! /claude/ projects/ (mirror of ~/.claude/projects), //! history (~/.claude/history.jsonl), @@ -86,18 +87,21 @@ pub const Top = enum(u8) { dsh, skills, active, + readme, /// Directory entries of the root, in listing order. - pub const listed = [_]Top{ .pid, .uptime, .claude, .codex, .omp, .hermes, .dsh, .skills, .active }; + pub const listed = [_]Top{ .readme, .pid, .uptime, .claude, .codex, .omp, .hermes, .dsh, .skills, .active }; pub fn fileName(t: Top) []const u8 { - return @tagName(t); + // The only name that is not its tag: a README announces itself in + // the listing, and lowercase would bury it among the harness dirs. + return if (t == .readme) "README" else @tagName(t); } pub fn dir(t: Top) bool { return switch (t) { .root, .claude, .codex, .omp, .hermes, .dsh, .skills, .active => true, - .pid, .uptime => false, + .pid, .uptime, .readme => false, }; } @@ -359,6 +363,25 @@ fn fail(tag: u64, e: u16) Answer { return .{ .reply = fs.Reply.fail(tag, e) }; } +/// A refusal that says why. 9P2000 has no numeric error — error(5) is +/// `Rerror tag[2] ename[s]`, a string — and acme names its refusals in words +/// ("permission denied", "file does not exist", fsys.c). A server that only +/// answers bare errnos throws away the one channel the protocol gives it for +/// explaining itself, which matters most here: a move has seven different +/// reasons to say no, and EPERM tells the user none of them. +/// +/// `errno` is still carried, because the reader may be a Linux mount rather +/// than a person: 9ns maps the *text* back to an errno by case-insensitive +/// substring (enameToErrno in 9ns/src/nine.zig, first match wins). So each +/// message below is worded to keep the phrase that carries its errno — +/// "not permitted" for EPERM, "invalid" for EINVAL, "exists" for EEXIST, +/// "no such" for ENOENT — and to avoid phrases that would silently retarget +/// it ("permission" and "denied" map to EACCES, "read-only" to EROFS, and +/// the bare substring "fid" to EBADF). +fn failWhy(tag: u64, e: u16, ename: []const u8) Answer { + return .{ .reply = .{ .tag = tag, .status = .err, .errno = e, .ename = ename } }; +} + /// The daemon's state. One instance serves every connection; `handle` is /// called with `mutex` held (the serve handler in main.zig holds it across /// handle and reply, because `bytes` point into the shared buffers). @@ -606,24 +629,99 @@ fn fdOf(rc: usize) ?i32 { /// is never served: it is the one escape hatch around the pinned roots. /// `O_PATH` opens the name itself, so a fifo or device never blocks and /// never has its open side effects run. -fn statIn(h: *Harness, r: Root, rel_path: []const u8) ?Io.Dir.Stat { +/// A stat plus the file's identity on disk. +const Stat = struct { + st: Io.Dir.Stat, + /// The qid path to report, or 0 when the kernel would not say. + /// + /// intro(5) is strict about what a qid path means: "The path is an + /// integer unique among all files in the hierarchy. If a file is deleted + /// and recreated with the same name in the same directory, the old and + /// new path components of the qids should be different" — and two files + /// are the same file if and only if their qids match. So the path has to + /// be a property of the *file*, stable for as long as it exists. + /// + /// The backend's own node id cannot supply that: it is a slot in the path + /// table, and a slot freed by its last release comes back with a new + /// generation (the guard that stops a forged node id resolving). A client + /// that walks, stats and clunks — `9p stat` does exactly this — saw a + /// different qid path for the same unchanged file every single time, and + /// by the protocol's own rule that means a different file. + /// + /// So this is u9fs's answer instead, from the same fstat the attr already + /// needs: + /// + /// memmove(p, &st->st_ino, ...); *(dev_t*)q ^= st->st_dev; -- u9fs.c + /// + /// The device is mixed in because an inode number is only unique within + /// its filesystem, and the five pinned roots need not share one. The + /// engine keeps resolving requests by node id, which still recycles; only + /// the qid the client sees is stabilised (fs.Attr.path exists for exactly + /// this split, and the engine uses it for nothing else). + qid_path: u64, +}; + +fn statIn(h: *Harness, r: Root, rel_path: []const u8) ?Stat { const fd = openIn(h, r, rel_path, .{ .PATH = true, .CLOEXEC = true }) orelse return null; const f: Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; defer f.close(h.io); const st = f.stat(h.io) catch return null; if (st.kind == .sym_link) return null; // never served: an escape hatch - return st; + return .{ .st = st, .qid_path = identityOf(fd) }; +} + +/// (dev, ino) of an open descriptor, folded into one integer. 0 when statx +/// declines to answer, which the caller reads as "fall back to the node id". +fn identityOf(fd: i32) u64 { + var sx: linux.Statx = undefined; + const want: linux.STATX = .{ .INO = true }; + const rc = linux.statx(fd, "", linux.AT.EMPTY_PATH, want, &sx); + if (linux.errno(rc) != .SUCCESS or !sx.mask.INO) return 0; + const dev = (@as(u64, sx.dev_major) << 32) | sx.dev_minor; + // The inode carries the entropy, so it stays unshifted and the device is + // folded over its top bits: two roots on one filesystem keep distinct + // inodes, and the same inode on two filesystems stops colliding. + return sx.ino ^ (dev *% 0x9E37_79B9_7F4A_7C15); +} + +/// The qid version of a file on disk, the way u9fs derives it: +/// +/// qid.vers = st->st_mtime ^ (st->st_size << 8); -- u9fs.c +/// +/// intro(5) makes the qid the server's identity for a file — "two files on +/// the same server hierarchy are the same if and only if their qids are the +/// same" — and the version the part that "is incremented every time the file +/// is modified". A server that leaves it 0 is telling every client that +/// nothing it serves ever changes, which is exactly wrong for this tree: its +/// whole point is transcripts that grow while you read them. The size shift +/// is what makes an append within the same second still change the version, +/// and a rewrite that keeps the size change it through the mtime. +fn qidVers(mtime_sec: i64, size: u64) u32 { + return @truncate(@as(u64, @bitCast(mtime_sec)) ^ (size << 8)); +} + +/// The qid version of text this server derives rather than reads: there is no +/// mtime behind it, so the content itself is the only honest version. +fn textVers(text: []const u8) u32 { + return @truncate(std.hash.Wyhash.hash(0, text)); } fn statAttr(h: *Harness, e: *Entry) ?fs.Attr { - const st = statIn(h, e.root, e.rel()) orelse return null; + const got = statIn(h, e.root, e.rel()) orelse return null; + const st = got.st; + const mtime = st.mtime.toSeconds(); + const size: u64 = if (st.kind == .directory) 0 else st.size; return .{ .name = basename(e.rel()), .node = h.entryNode(e), .dir = st.kind == .directory, - .size = if (st.kind == .directory) 0 else st.size, + // stat(5): "Directories and most files representing devices have a + // conventional length of 0." + .size = size, .mode = if (st.kind == .directory) 0o555 else 0o444, - .mtime = @truncate(@as(u64, @bitCast(st.mtime.toSeconds()))), + .mtime = @truncate(@as(u64, @bitCast(mtime))), + .version = qidVers(mtime, size), + .path = if (got.qid_path != 0) got.qid_path else null, }; } @@ -632,8 +730,44 @@ fn basename(rel_path: []const u8) []const u8 { return rel_path; } +/// Served as `/README`: what the tree is and how to read it. Static, so it +/// is the one "fact" that needs no buffer — `factText` returns it directly. +const readme_text = + \\9agents - every coding agent's state on this machine, as files. + \\Read-only: writes answer EPERM. Nothing is cached, so a transcript + \\grows while you cat it and a new session appears as soon as its + \\file lands. + \\ + \\ README this file + \\ pid uptime this daemon's pid; seconds since it started + \\ active/ what is running right now + \\ claude/ projects/ history skills/ + \\ codex/ sessions/ session-index history + \\ omp/ hermes/ dsh/ each harness's own directory, raw + \\ skills/ claude/ codex/ omp/, in one place + \\ + \\active/<harness>/<pid>/ holds pid ppid started cwd name title + \\session via status transcript zmx. + \\ session its session id, empty when it did not resolve + \\ via how that id was found: registry, fd, dir or none + \\ status only harnesses that publish one have it + \\ transcript the live .jsonl; its mtime is the last activity + \\ zmx the zmx session it runs in, empty outside zmx + \\ + \\ ls active/*/* what is running + \\ cat active/claude/345104/session what it is + \\ tail -f active/claude/345104/transcript + \\ + \\Credentials are excluded by name and never listed, at any depth; + \\symlinks are never served. Writing a name into an agent's zmx file + \\moves it into that zmx session, and only when the daemon was + \\started with --allow-move; otherwise that write answers EPERM too. + \\ +; + /// A fact's text, rendered on demand. `buf` should be a small stack buffer. fn factText(h: *Harness, fact: Top, buf: []u8) []const u8 { + if (fact == .readme) return readme_text; // static: outlives any buffer var w = Io.Writer.fixed(buf); switch (fact) { .pid => w.print("{d}\n", .{h.pid}) catch {}, @@ -655,7 +789,7 @@ fn attrFor(h: *Harness, t: Target) ?fs.Attr { switch (top) { .root => return .{ .name = "/", .node = root, .dir = true, .mode = 0o555 }, .active => return .{ .name = "active", .node = topNode(.active), .dir = true, .mode = 0o555 }, - .pid, .uptime => return .{ + .pid, .uptime, .readme => return .{ .name = top.fileName(), .node = topNode(top), .size = factText(h, top, &fact_buf).len, @@ -670,9 +804,15 @@ fn attrFor(h: *Harness, t: Target) ?fs.Attr { .omp, .hermes, .dsh => { // The harness dir IS its mirror: stat the target. const m = top.mirror().?; - const st = statIn(h, m.root, m.rel) orelse return null; - if (st.kind != .directory) return null; - return .{ .name = top.fileName(), .node = topNode(top), .dir = true, .mode = 0o555 }; + const got = statIn(h, m.root, m.rel) orelse return null; + if (got.st.kind != .directory) return null; + return .{ + .name = top.fileName(), + .node = topNode(top), + .dir = true, + .mode = 0o555, + .path = if (got.qid_path != 0) got.qid_path else null, + }; }, } }, @@ -819,19 +959,29 @@ fn activeAttr(h: *Harness, a: Act) ?fs.Attr { const path = activePath(h, a) orelse return null; const st = Io.Dir.statFile(.cwd(), h.io, path, .{ .follow_symlinks = false }) catch return null; if (st.kind != .file) return null; + const mtime = st.mtime.toSeconds(); return .{ .name = a.file.fileName(), .node = activeNode(a), .size = st.size, .mode = 0o444, - .mtime = @truncate(@as(u64, @bitCast(st.mtime.toSeconds()))), + .mtime = @truncate(@as(u64, @bitCast(mtime))), + .version = qidVers(mtime, st.size), }; }, else => { const text = activeText(h, a) orelse return null; // Only `zmx` is ever writable, and only when a move is allowed. const mode: u16 = if (a.file == .zmx and h.allow_move) 0o644 else 0o444; - return .{ .name = a.file.fileName(), .node = activeNode(a), .size = text.len, .mode = mode }; + return .{ + .name = a.file.fileName(), + .node = activeNode(a), + .size = text.len, + .mode = mode, + // Derived, not read: `status` flipping busy->idle keeps its + // size, so only the text can carry the change. + .version = textVers(text), + }; }, } } @@ -1085,22 +1235,22 @@ fn spawnZmx(h: *Harness, l: *const active.Live, name: []const u8, resume_value: /// A move: kill the agent and bring it back inside a zmx session of the /// name written. Refusals come before anything is destroyed. fn moveToZmx(h: *Harness, req: fs.Req, a: Act) Answer { - if (!h.allow_move) return fail(req.tag, E.PERM); + if (!h.allow_move) return failWhy(req.tag, E.PERM, "moves are not permitted: start 9agents with --allow-move"); const name = std.mem.trim(u8, req.data, " \t\r\n"); - if (!active.legalZmxName(name)) return fail(req.tag, E.INVAL); + if (!active.legalZmxName(name)) return failWhy(req.tag, E.INVAL, "invalid zmx name: letters, digits, dot, dash and underscore only"); const l = h.live.at(a.slot, a.gen) orelse return fail(req.tag, E.NOENT); // Nothing is killed that has nowhere to come back to. - if (l.via == .none or l.session.len == 0) return fail(req.tag, E.PERM); - if (l.cwd.len == 0) return fail(req.tag, E.PERM); + if (l.via == .none or l.session.len == 0) return failWhy(req.tag, E.PERM, "no session resolved: not permitted, there would be nothing to resume"); + if (l.cwd.len == 0) return failWhy(req.tag, E.PERM, "no working directory known for this agent: not permitted"); const resume_value: []const u8 = switch (l.kind) { // omp resumes by the transcript it wrote, the others by id. .omp => l.transcript.slice(), .claude, .codex, .hermes => l.session.slice(), // dsh has no resume form worth guessing at. - .dsh => return fail(req.tag, E.PERM), + .dsh => return failWhy(req.tag, E.PERM, "dsh has no resume form: moving it is not permitted"), }; - if (resume_value.len == 0) return fail(req.tag, E.PERM); + if (resume_value.len == 0) return failWhy(req.tag, E.PERM, "nothing to resume from: not permitted"); // Already there: setting a value it already has changes nothing. var have: [active.text_capacity]u8 = undefined; @@ -1109,14 +1259,14 @@ fn moveToZmx(h: *Harness, req: fs.Req, a: Act) Answer { return .{ .reply = .{ .tag = req.tag, .written = @intCast(req.data.len) } }; } } - if (zmxPosted(h, name)) return fail(req.tag, E.EXIST); + if (zmxPosted(h, name)) return failWhy(req.tag, E.EXIST, "a live zmx session of that name already exists"); // The slot could have gone stale between the scan and this write. - if (!active.stillAlive(sourcesOf(h), l)) return fail(req.tag, E.NOENT); + if (!active.stillAlive(sourcesOf(h), l)) return failWhy(req.tag, E.NOENT, "the agent exited before the move began: no such process"); // Everything below this line destroys something. var snapshot = l.*; - if (!killAndWait(h, snapshot.pid)) return fail(req.tag, E.IO); - if (!spawnZmx(h, &snapshot, name, resume_value)) return fail(req.tag, E.IO); + if (!killAndWait(h, snapshot.pid)) return failWhy(req.tag, E.IO, "the agent did not exit when signalled; nothing was started"); + if (!spawnZmx(h, &snapshot, name, resume_value)) return failWhy(req.tag, E.IO, "could not start zmx; the agent has already been stopped"); for (0..post_ticks) |_| { if (zmxPosted(h, name)) { rescan(h); @@ -1125,7 +1275,7 @@ fn moveToZmx(h: *Harness, req: fs.Req, a: Act) Answer { napOneTick(h); } rescan(h); - return fail(req.tag, E.IO); + return failWhy(req.tag, E.IO, "zmx did not post the session in time; the agent may still return"); } // ---- dispatch ------------------------------------------------------------------ @@ -1157,7 +1307,7 @@ fn setattrReq(h: *Harness, req: fs.Req, t: Target) Answer { }, else => {}, } - return fail(req.tag, E.PERM); + return failWhy(req.tag, E.PERM, "9agents serves a view, not a store: writing is not permitted"); } /// The tree answers EPERM to every write but one: a zmx session name @@ -1167,7 +1317,7 @@ fn writeReq(h: *Harness, req: fs.Req, t: Target) Answer { .act => |a| if (a.file == .zmx) return moveToZmx(h, req, a), else => {}, } - return fail(req.tag, E.PERM); + return failWhy(req.tag, E.PERM, "9agents serves a view, not a store: writing is not permitted"); } fn attrReply(h: *Harness, tag: u64, t: Target) Answer { @@ -1236,7 +1386,7 @@ fn lookup(h: *Harness, req: fs.Req, t: Target) Answer { } return activeReply(h, req, .{ .file = .harness_dir, .kind = k }, name); }, - .pid, .uptime => return fail(req.tag, E.NOTDIR), + .pid, .uptime, .readme => return fail(req.tag, E.NOTDIR), }, .path => |e| { const rel_path = e.rel(); @@ -1331,8 +1481,8 @@ fn open(h: *Harness, req: fs.Req, t: Target) Answer { }; const rw = req.omode & 3; if (!writable) { - if (rw == cloud9.owrite or rw == cloud9.ordwr) return fail(req.tag, E.PERM); - if (req.omode & cloud9.otrunc != 0) return fail(req.tag, E.PERM); + if (rw == cloud9.owrite or rw == cloud9.ordwr) return failWhy(req.tag, E.PERM, "9agents serves a view, not a store: writing is not permitted"); + if (req.omode & cloud9.otrunc != 0) return failWhy(req.tag, E.PERM, "truncation is not permitted here"); } return .{ .reply = .{ .tag = req.tag, .handle = 1 } }; } @@ -1351,7 +1501,7 @@ fn release(h: *Harness, req: fs.Req, t: Target) Answer { fn read(h: *Harness, req: fs.Req, t: Target) Answer { switch (t) { .top => |top| switch (top) { - .pid, .uptime => { + .pid, .uptime, .readme => { const text = factText(h, top, &fact_buf); return window(req, text); }, @@ -1435,7 +1585,7 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer { st.add(h.entryNode(e), a.dir, m.name); } }, - .pid, .uptime => return fail(req.tag, E.NOTDIR), + .pid, .uptime, .readme => return fail(req.tag, E.NOTDIR), .active => { rescan(h); for (std.enums.values(active.Kind)) |k| { @@ -1450,7 +1600,7 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer { .ok => {}, .gone => return fail(req.tag, E.NOENT), .failed => return fail(req.tag, E.IO), - .overflow => return fail(req.tag, E.NFILE), + .overflow => return failWhy(req.tag, E.NFILE, "directory has more entries than this server can list at once"), } }, }, @@ -1462,7 +1612,7 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer { .ok => {}, .gone => return fail(req.tag, E.NOENT), .failed => return fail(req.tag, E.IO), - .overflow => return fail(req.tag, E.NFILE), + .overflow => return failWhy(req.tag, E.NFILE, "directory has more entries than this server can list at once"), } }, .act => |a| return activeReaddir(h, req, a), @@ -1499,7 +1649,7 @@ fn listDir(h: *Harness, st: *Staging, r: Root, dir_rel: []const u8) Listing { var child_buf: [rel_capacity + 256]u8 = undefined; const child_rel = joinRel(&child_buf, dir_rel, entry.name) orelse continue; const cst = statIn(h, r, child_rel) orelse continue; - kind = cst.kind; + kind = cst.st.kind; } if (kind == .sym_link) continue; // never served // A cap reached is an error, never a short listing: a directory @@ -1725,11 +1875,34 @@ test "tree: facts at the root" { // The root lists the facts and the five harness dirs. const listing = handle(rig.h, .{ .tag = 6, .op = .readdir, .node = root, .off = 0, .size = msize }); try testing.expect(listing.reply.status == .ok); - for ([_][]const u8{ "pid", "uptime", "claude", "codex", "omp", "hermes", "dsh", "skills" }) |name| { + for ([_][]const u8{ "README", "pid", "uptime", "claude", "codex", "omp", "hermes", "dsh", "skills" }) |name| { try testing.expect(stageHas(listing.bytes, name)); } } +test "tree: README is served at the root, whole and read-only" { + var rig: Rig = .{ .dir = undefined }; + try rig.start(); + defer rig.end(); + + const rd = rig.lookupName(1, root, "README"); + try testing.expect(rd.reply.status == .ok); + try testing.expect(!rd.reply.attr.dir); + try testing.expectEqual(readme_text.len, rd.reply.attr.size); + + // Read it back whole and compare: a truncated or empty README would + // otherwise pass every check above. + const got = handle(rig.h, .{ .tag = 2, .op = .read, .node = rd.reply.attr.node, .off = 0, .size = msize }); + try testing.expect(got.reply.status == .ok); + try testing.expectEqualStrings(readme_text, got.bytes); + + // It is a file, and it is read-only like everything else here. + const as_dir = handle(rig.h, .{ .tag = 3, .op = .readdir, .node = rd.reply.attr.node, .off = 0, .size = msize }); + try testing.expectEqual(E.NOTDIR, as_dir.reply.errno); + const wr = handle(rig.h, .{ .tag = 4, .op = .write, .node = rd.reply.attr.node, .off = 0, .size = 1 }); + try testing.expectEqual(E.PERM, wr.reply.errno); +} + fn stageHas(staged: []const u8, name: []const u8) bool { var i: usize = 0; while (i + 10 <= staged.len) { @@ -1852,6 +2025,49 @@ test "tree: paths compose only from the pinned roots" { try testing.expect(rig.lookupName(8, topNode(.pid), "x").reply.errno == E.NOTDIR); } +test "tree: a file's qid path survives release, and its version tracks content" { + var rig: Rig = .{ .dir = undefined }; + try rig.start(); + defer rig.end(); + try rig.put(".claude/projects/p1/session-x.jsonl", "{}\n"); + + const projects = rig.lookupName(1, topNode(.claude), "projects"); + const dir = rig.lookupName(2, projects.reply.attr.node, "p1"); + const first = rig.lookupName(3, dir.reply.attr.node, "session-x.jsonl"); + try testing.expect(first.reply.status == .ok); + const qid_path = first.reply.attr.path.?; + const version = first.reply.attr.version; + + // Give every reference back, so the table slot is free to recycle. + for ([_]u64{ first.reply.attr.node, dir.reply.attr.node, projects.reply.attr.node }) |n| { + _ = handle(rig.h, .{ .tag = 4, .op = .release, .node = n }); + } + + // Walk to it again from scratch. intro(5): two files are the same file if + // and only if their qids are the same — so the same unchanged file must + // come back with the same qid path, whatever the node id does. Before the + // qid path came from the file itself, the recycled slot's new generation + // made this a different file on every walk. + const p2 = rig.lookupName(5, topNode(.claude), "projects"); + const d2 = rig.lookupName(6, p2.reply.attr.node, "p1"); + const again = rig.lookupName(7, d2.reply.attr.node, "session-x.jsonl"); + try testing.expect(again.reply.status == .ok); + try testing.expectEqual(qid_path, again.reply.attr.path.?); + try testing.expectEqual(version, again.reply.attr.version); + + // Two different files never share a qid path. + try rig.put(".claude/projects/p1/session-y.jsonl", "{}\n"); + const other = rig.lookupName(8, d2.reply.attr.node, "session-y.jsonl"); + try testing.expect(other.reply.attr.path.? != qid_path); + + // And a change to the content moves the version, which is what tells a + // caching client the transcript it is holding has grown. + try rig.put(".claude/projects/p1/session-x.jsonl", "{}\n{\"more\":1}\n"); + const grown = rig.lookupName(9, d2.reply.attr.node, "session-x.jsonl"); + try testing.expectEqual(qid_path, grown.reply.attr.path.?); + try testing.expect(grown.reply.attr.version != version); +} + test "tree: node ids — same file equal, distinct files differ, ids recycle" { var rig: Rig = .{ .dir = undefined }; try rig.start(); @@ -1950,7 +2166,7 @@ comptime { // Every static mount target must survive the exclusion rules; the // daemon's own skeleton may never be filtered out from under it. for (named_mounts) |m| { - if (excludedPath(m.mount.rel)) @compileError("a 9harness mount target is excluded by name"); + if (excludedPath(m.mount.rel)) @compileError("a 9agents mount target is excluded by name"); } } @@ -2185,7 +2401,7 @@ test "active: the daemon never lists the process tree it lives in" { try rig.start(); defer rig.end(); try rig.fakeProc(.{ .pid = 1040, .comm = "claude", .argv = "claude\x00" }); - try rig.fakeProc(.{ .pid = 1041, .comm = "9harness", .ppid = 1040, .argv = "9harness\x00" }); + try rig.fakeProc(.{ .pid = 1041, .comm = "9agents", .ppid = 1040, .argv = "9agents\x00" }); try rig.fakeProc(.{ .pid = 1042, .comm = "claude", .argv = "claude\x00" }); const act = rig.lookupName(14, root, "active"); diff --git a/9harness/test/e2e.sh b/9agents/test/e2e.sh index 254d999..fa41bef 100755 --- a/9harness/test/e2e.sh +++ b/9agents/test/e2e.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# End-to-end suite for 9harness: the read-only, fresh-from-disk 9P view of +# End-to-end suite for 9agents: the read-only, fresh-from-disk 9P view of # every harness's state. # -# Usage: bash 9harness/test/e2e.sh <9harness> [<9ns>] (zig build 9harness-itest) +# Usage: bash 9agents/test/e2e.sh <9agents> [<9ns>] (zig build 9agents-itest) # # Part A always runs, entirely on fixtures: a fake home with fixture roots # pinned by --root, a scratch XDG_RUNTIME_DIR registry, plan9port's 9p as @@ -12,20 +12,20 @@ # visible at once, writes answer EPERM, and --unix/--fd listen forms work. # # Part B (the money shot) runs against the REAL roots and the REAL -# registry only when the `harness` name is free, and only reads: the posted +# registry only when the `agents` name is free, and only reads: the posted # name in /run/user/<uid>/9p, 9p walks of the live ~/.claude, a real # transcript byte-identical through the tree, the 9ns --mntgen mount of -# /mnt/9p/harness, and the live ~/.claude/.credentials.json unreachable. +# /mnt/9p/agents, and the live ~/.claude/.credentials.json unreachable. # It is skipped (not failed) when a live daemon already owns the name. # # Exit 0 on success (or when the machine cannot run a part), 1 on failure. set -u -H9=$(realpath "${1:?path to 9harness}") +H9=$(realpath "${1:?path to 9agents}") HARNESS_TMP_XDG="${XDG_RUNTIME_DIR:-}" [ $# -ge 2 ] && [ -n "$2" ] && NS=$(realpath "$2") P9P=/usr/lib/plan9/bin/9p -TMP=$(mktemp -d "${TMPDIR:-/tmp}/9harness.XXXXXX") +TMP=$(mktemp -d "${TMPDIR:-/tmp}/9agents.XXXXXX") PIDS=() FAILED=0 PASSED=0 @@ -33,6 +33,10 @@ PASSED=0 cleanup() { for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done rm -rf "$TMP" + # part B posts into the *real* registry under a scratch name; a crash + # between the post and the unpost would otherwise leave a socket there. + [ -n "${REAL_SOCKET:-}" ] && rm -f "$REAL_SOCKET" + return 0 } trap cleanup EXIT @@ -106,51 +110,51 @@ mkfile "$FX/dsh/.credentials.yaml" 'STAY OUT' start_daemon --root "claude=$FX/claude" --root "codex=$FX/codex" \ --root "omp=$FX/omp" --root "hermes=$FX/hermes" --root "dsh=$FX/dsh" \ - --name harness -wait_posted "$REG/harness" || { fail "the daemon posts as harness" "$(cat "$TMP/daemon.log")"; exit 1; } + --name agents +wait_posted "$REG/agents" || { fail "the daemon posts as agents" "$(cat "$TMP/daemon.log")"; exit 1; } # 1. The posted name is listed. -expect_contains "posted name listed in the registry" harness "$(ls "$REG")" +expect_contains "posted name listed in the registry" agents "$(ls "$REG")" # 2. The roots walk; a real transcript reads back byte-identical. -expect_contains "ls / shows the roots" claude "$(p9 "$REG/harness" ls /)" -expect_contains "ls / shows codex" codex "$(p9 "$REG/harness" ls /)" -expect_contains "ls / shows skills" skills "$(p9 "$REG/harness" ls /)" -p9 "$REG/harness" read /claude/projects/-tmp-proj/session-abc.jsonl > "$TMP/via-9p.jsonl" 2>"$TMP/read.err" \ +expect_contains "ls / shows the roots" claude "$(p9 "$REG/agents" ls /)" +expect_contains "ls / shows codex" codex "$(p9 "$REG/agents" ls /)" +expect_contains "ls / shows skills" skills "$(p9 "$REG/agents" ls /)" +p9 "$REG/agents" read /claude/projects/-tmp-proj/session-abc.jsonl > "$TMP/via-9p.jsonl" 2>"$TMP/read.err" \ || fail "transcript read through the tree" "$(cat "$TMP/read.err")" cmp -s "$TMP/via-9p.jsonl" "$FX/claude/projects/-tmp-proj/session-abc.jsonl" \ && pass "transcript byte-identical through the tree (cmp)" \ || fail "transcript byte-identical through the tree (cmp)" "differs" -expect_eq "history file reads" "$(cat "$FX/claude/history.jsonl")" "$(p9 "$REG/harness" read /claude/history)" +expect_eq "history file reads" "$(cat "$FX/claude/history.jsonl")" "$(p9 "$REG/agents" read /claude/history)" expect_eq "skills union mirrors the harness tree" "$(cat "$FX/claude/skills/revu/SKILL.md")" \ - "$(p9 "$REG/harness" read /skills/claude/revu/SKILL.md)" + "$(p9 "$REG/agents" read /skills/claude/revu/SKILL.md)" # 3. A credentials-shaped file is unreachable anywhere in the tree. -PROJ_LS=$(p9 "$REG/harness" ls /claude/projects/-tmp-proj) +PROJ_LS=$(p9 "$REG/agents" ls /claude/projects/-tmp-proj) for bad in .credentials.json auth.json token.bin; do expect_missing "credentials-shaped $bad not listed" "$bad" "$PROJ_LS" - p9 "$REG/harness" read "/claude/projects/-tmp-proj/$bad" >/dev/null 2>&1 \ + p9 "$REG/agents" read "/claude/projects/-tmp-proj/$bad" >/dev/null 2>&1 \ && fail "credentials-shaped $bad unreachable" "read succeeded" \ || pass "credentials-shaped $bad unreachable" done -expect_missing "hermes auth.json not listed" auth.json "$(p9 "$REG/harness" ls /hermes)" -expect_missing "omp config.yml not listed" config.yml "$(p9 "$REG/harness" ls /omp)" -expect_missing "dsh .credentials.yaml not listed" credentials.yaml "$(p9 "$REG/harness" ls /dsh)" +expect_missing "hermes auth.json not listed" auth.json "$(p9 "$REG/agents" ls /hermes)" +expect_missing "omp config.yml not listed" config.yml "$(p9 "$REG/agents" ls /omp)" +expect_missing "dsh .credentials.yaml not listed" credentials.yaml "$(p9 "$REG/agents" ls /dsh)" sleep 0.3 -expect_contains "hermes logs still served" logs "$(p9 "$REG/harness" ls /hermes)" +expect_contains "hermes logs still served" logs "$(p9 "$REG/agents" ls /hermes)" # 3b. A file at the very top of a mirror root: its relative path is the # bare name, the one case a join onto an empty directory path gets wrong. -expect_contains "a file at the top of a mirror root is listed" state.db "$(p9 "$REG/harness" ls /hermes)" +expect_contains "a file at the top of a mirror root is listed" state.db "$(p9 "$REG/agents" ls /hermes)" expect_eq "a file at the top of a mirror root reads back" "$(cat "$FX/hermes/state.db")" \ - "$(p9 "$REG/harness" read /hermes/state.db)" + "$(p9 "$REG/agents" read /hermes/state.db)" # 3c. A directory bigger than the listing caps fails loudly. A short # listing is indistinguishable from a small directory, so the daemon must # never answer one: the read errors and the client sees it. mkdir -p "$FX/dsh/wide" seq 1 1100 | while read -r i; do : > "$FX/dsh/wide/f$(printf %05d "$i")"; done -if p9 "$REG/harness" ls /dsh/wide > "$TMP/wide.out" 2>"$TMP/wide.err"; then +if p9 "$REG/agents" ls /dsh/wide > "$TMP/wide.out" 2>"$TMP/wide.err"; then fail "an over-cap directory fails instead of truncating" "listed $(wc -l < "$TMP/wide.out") entries" else pass "an over-cap directory fails instead of truncating ($(head -c 80 "$TMP/wide.err"))" @@ -161,17 +165,17 @@ rm -rf "$FX/dsh/wide" printf '{"type":"assistant","message":"third line"}\n' >> "$FX/claude/projects/-tmp-proj/session-abc.jsonl" expect_eq "append after start is visible immediately" \ "$(cat "$FX/claude/projects/-tmp-proj/session-abc.jsonl")" \ - "$(p9 "$REG/harness" read /claude/projects/-tmp-proj/session-abc.jsonl)" + "$(p9 "$REG/agents" read /claude/projects/-tmp-proj/session-abc.jsonl)" mkdir -p "$FX/claude/projects/-tmp-proj2" mkfile "$FX/claude/projects/-tmp-proj2/session-new.jsonl" '{"new":true} ' -expect_contains "new session dir appears at once" -tmp-proj2 "$(p9 "$REG/harness" ls /claude/projects)" +expect_contains "new session dir appears at once" -tmp-proj2 "$(p9 "$REG/agents" ls /claude/projects)" # 5. The facts and the write refusal. -DAEMON_PID_TEXT=$(p9 "$REG/harness" read /pid) +DAEMON_PID_TEXT=$(p9 "$REG/agents" read /pid) expect_eq "pid fact answers the daemon's pid" "$DAEMON_PID" "$DAEMON_PID_TEXT" -[ -n "$(p9 "$REG/harness" read /uptime)" ] && pass "uptime fact answers" || fail "uptime fact answers" "empty" -printf 'x' | p9 "$REG/harness" write /pid >/dev/null 2>&1 \ +[ -n "$(p9 "$REG/agents" read /uptime)" ] && pass "uptime fact answers" || fail "uptime fact answers" "empty" +printf 'x' | p9 "$REG/agents" write /pid >/dev/null 2>&1 \ && fail "write answers EPERM" "write succeeded" \ || pass "write answers EPERM" @@ -206,7 +210,7 @@ fi kill "$DAEMON_PID" 2>/dev/null wait "$DAEMON_PID" 2>/dev/null sleep 0.2 -[ -S "$REG/harness" ] && fail "SIGTERM unposts the name" "socket still there" || pass "SIGTERM unposts the name" +[ -S "$REG/agents" ] && fail "SIGTERM unposts the name" "socket still there" || pass "SIGTERM unposts the name" # ============================================================================ echo "# part C: /active, the derived view" @@ -294,14 +298,21 @@ echo "# part B: the real roots, the real registry (read-only)" # ============================================================================ export XDG_RUNTIME_DIR="${HARNESS_TMP_XDG:-/run/user/$(id -u)}" REAL_REG="$XDG_RUNTIME_DIR/9p" -REAL_SOCKET="$REAL_REG/harness" +# A scratch name, not `agents`: the whole point of part B is to read the real +# roots through the real registry, and that has nothing to do with which name +# the tree is posted under. Taking `agents` would mean this suite could only +# run while the machine's own 9agents.service was stopped — so it would either +# be skipped on any machine that actually uses the daemon, or fight it. +REAL_NAME="agents-itest-$$" +REAL_SOCKET="$REAL_REG/$REAL_NAME" if [ -e "$REAL_SOCKET" ]; then - echo "SKIP: a live daemon already owns the posted name `harness`" + # $$ collided with a leftover socket from a crashed run of this suite. + echo "SKIP: scratch name $REAL_NAME is already taken" else HOME_DIR=$(getent passwd "$(id -u)" | cut -d: -f6) - start_daemon --name harness + start_daemon --name "$REAL_NAME" if wait_posted "$REAL_SOCKET"; then - expect_contains "posted name listed in the real registry" harness "$(ls "$REAL_REG")" + expect_contains "posted name listed in the real registry" "$REAL_NAME" "$(ls "$REAL_REG")" expect_contains "ls / shows the claude root" claude "$(p9 "$REAL_SOCKET" ls /)" # A real transcript, byte-identical through the tree. REAL_T=$(ls "$HOME_DIR/.claude/projects"/*/*.jsonl 2>/dev/null | head -n 1 || true) @@ -323,10 +334,10 @@ else expect_missing "no credentials leaked into /claude" credentials "$CLAUDE_LS" # The money shot: the mntgen mount every interactive fish sees. if [ -n "$NS" ]; then - OUT=$(timeout 60 "$NS" --mntgen -- sh -c 'ls /mnt/9p/harness && head -c 200 /mnt/9p/harness/claude/history' 2>"$TMP/mntgen.err") + OUT=$(timeout 60 "$NS" --mntgen -- sh -c "ls /mnt/9p/$REAL_NAME && head -c 200 /mnt/9p/$REAL_NAME/claude/history" 2>"$TMP/mntgen.err") RC=$? if [ $RC -eq 0 ]; then - expect_contains "mntgen mount lists the harness tree" claude "$OUT" + expect_contains "mntgen mount lists the agents tree" claude "$OUT" expect_contains "mntgen mount reads claude/history" claude "$OUT" else fail "mntgen money shot (exit $RC)" "$(cat "$TMP/mntgen.err")" @@ -156,7 +156,7 @@ pub fn build(b: *std.Build) void { const is_linux = target.result.os.tag == .linux; const want_9proc = b.option(bool, "9proc", "Build the 9proc library module and demo (default: target is Linux)") orelse is_linux; const want_9ns = b.option(bool, "9ns", "Build the 9ns FUSE mount CLI (default: target is Linux; Linux only)") orelse is_linux; - const want_9harness = b.option(bool, "9harness", "Build the harness fs daemon (default: target is Linux; Linux only)") orelse is_linux; + const want_9agents = b.option(bool, "9agents", "Build the coding-agents fs daemon (default: target is Linux; Linux only)") orelse is_linux; if (want_9ns and !is_linux) { std.debug.print("error: -D9ns=true needs a Linux target (got {s})\n", .{@tagName(target.result.os.tag)}); std.process.exit(1); @@ -207,21 +207,21 @@ pub fn build(b: *std.Build) void { programs_itest.dependOn(p.itest_step); } - // The harness fs daemon: a read-only 9P view of every harness's - // state, posted by default as `harness` (Linux only: it posts into + // The coding-agents fs daemon: a read-only 9P view of every harness's + // state, posted by default as `agents` (Linux only: it posts into // $XDG_RUNTIME_DIR/9p through cloud9.post). - if (want_9harness and !is_linux) { - std.debug.print("error: -D9harness=true needs a Linux target (got {s})\n", .{@tagName(target.result.os.tag)}); + if (want_9agents and !is_linux) { + std.debug.print("error: -D9agents=true needs a Linux target (got {s})\n", .{@tagName(target.result.os.tag)}); std.process.exit(1); } - const harness_build = @import("9harness/build.zig"); - const harness: ?harness_build.Artifacts = if (want_9harness) harness_build.add(b, .{ + const agents_build = @import("9agents/build.zig"); + const agents: ?agents_build.Artifacts = if (want_9agents) agents_build.add(b, .{ .target = target, .optimize = optimize, .cloud9 = module, .ns = if (ns) |p| p.exe else null, }) else null; - if (harness) |p| { + if (agents) |p| { programs_test.dependOn(p.test_step); programs_itest.dependOn(p.itest_step); } diff --git a/build.zig.zon b/build.zig.zon index d042fe3..b275168 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,11 +7,11 @@ // published package cannot build: build.zig @imports each program's // build fragment unconditionally, so a program missing from this // list is a FileNotFound for every consumer, while building fine - // from a checkout. 9harness was exactly that until a pardes build + // from a checkout. 9agents was exactly that until a pardes build // caught it. .paths = .{ "build.zig", "build.zig.zon", "src", "web", "test", "docs", "README.md", - "9ns", "9proc", "9harness", + "9ns", "9proc", "9agents", }, } |
