From 9bdd65c46954d060082c393fe4cba62d7ad11ac5 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Tue, 29 Sep 2026 19:28:08 +0200 Subject: [PATCH 1/2] Fetch the Node pty and smalltalk from their original-experiment repositories compoundingtech/pty and compoundingtech/smalltalk now hold the Rust rewrites, and the Node packages convoy builds against moved to pty-original-experiment and smalltalk-original-experiment. The flake inputs and the Test workflow's sibling checkouts now name those repositories. The locked revs and narHashes are unchanged. --- .github/workflows/test.yml | 4 +- flake.lock | 8 ++-- flake.nix | 4 +- notes/NATIVE-LAUNCH-PLAN.md | 85 ------------------------------------- 4 files changed, 8 insertions(+), 93 deletions(-) delete mode 100644 notes/NATIVE-LAUNCH-PLAN.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 54eb666..7c71371 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,11 +27,11 @@ jobs: path: convoy - uses: actions/checkout@v4 with: - repository: compoundingtech/pty + repository: compoundingtech/pty-original-experiment path: pty - uses: actions/checkout@v4 with: - repository: compoundingtech/smalltalk + repository: compoundingtech/smalltalk-original-experiment path: smalltalk - uses: actions/setup-node@v4 diff --git a/flake.lock b/flake.lock index 6642c7d..02ad215 100644 --- a/flake.lock +++ b/flake.lock @@ -44,13 +44,13 @@ "lastModified": 1784556029, "narHash": "sha256-52SqV7ANR/r+gqGNo/e2JUmQImpmCgmMUScd0KZlTW0=", "owner": "compoundingtech", - "repo": "pty", + "repo": "pty-original-experiment", "rev": "0a9be8c06bd9e3f0c1acbb34784762075ef53eb3", "type": "github" }, "original": { "owner": "compoundingtech", - "repo": "pty", + "repo": "pty-original-experiment", "type": "github" } }, @@ -78,13 +78,13 @@ "lastModified": 1784582866, "narHash": "sha256-ZJN8bZafY/ntKtvVYWxP9JQFUbeTYrhdzf3SA3hpwAs=", "owner": "compoundingtech", - "repo": "smalltalk", + "repo": "smalltalk-original-experiment", "rev": "850266f8551335771b2d922a7456e7246621c737", "type": "github" }, "original": { "owner": "compoundingtech", - "repo": "smalltalk", + "repo": "smalltalk-original-experiment", "type": "github" } }, diff --git a/flake.nix b/flake.nix index feca2d8..09db668 100644 --- a/flake.nix +++ b/flake.nix @@ -5,10 +5,10 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; - pty.url = "github:compoundingtech/pty"; + pty.url = "github:compoundingtech/pty-original-experiment"; pty.inputs.nixpkgs.follows = "nixpkgs"; - smalltalk.url = "github:compoundingtech/smalltalk"; + smalltalk.url = "github:compoundingtech/smalltalk-original-experiment"; smalltalk.inputs.nixpkgs.follows = "nixpkgs"; smalltalk.inputs.flake-utils.follows = "flake-utils"; # One pty in the closure: convoy links the same build smalltalk was built against. diff --git a/notes/NATIVE-LAUNCH-PLAN.md b/notes/NATIVE-LAUNCH-PLAN.md deleted file mode 100644 index e3071dd..0000000 --- a/notes/NATIVE-LAUNCH-PLAN.md +++ /dev/null @@ -1,85 +0,0 @@ -# Native launch — convoy owns `add`/`cos` launch (milestone-0 plan) - -**Goal:** `convoy add`/`cos` fully own agent launch — **no `st launch` shell-out**. Agents come back -`--resume ` (not `--fresh`). The native-launch capstone green by morning. **NO `convoy.toml`** — -derive everything from the agent folder + the pty registry. - -## Where we are (the stopgap this replaces) -`convoy add` today = `st launch --fresh` (writes wiring) + convoy's native `spawnDaemon` -(`spawnFromPtyFile`). Two debts: (1) `--fresh` → agents don't resume; (2) still leans on `st launch`'s -write-logic, including the bits that broke post-cutover (the `claude --print` session-id bootstrap -hangs; its pty registration no-ops). - -## Already owned — do NOT rebuild -persona resolution (`Personas`), permission-mode / permanent / transport derivation (`Role`/`AgentSpec`), -the native spawn (`host.ts` `spawnDaemon`), enriched PATH (`exec.ts`), `convoy up` (reconcile / respawn -via `pty restart -y` / flapping-cap). All green (e2e 14/0, 45 units). - -## Reimplement natively (`src/launch.ts`) — scrutinized to the minimum an agent needs -1. **session-id / `--resume` — THE fix.** `/.claude-session-id`: - - **Existing** (migrating agent) → command uses `--resume ` (its jsonl already exists → resumes). - - **New** (fresh agent) → mint a UUID, write `.claude-session-id`; create the session WITHOUT the - hanging `claude --print` bootstrap — use `claude --session-id ` if it creates the jsonl, else - let claude create it on first `--resume ` (I'll pin the exact behavior with a 2-line test - first thing in the build). - - **Respawn/relaunch ALWAYS `--resume `** — the sid persists in `.claude-session-id` and the - jsonl exists after first run. This is the `--fresh`→`--resume` fix the capstone needs. -2. **the claude session command builder.** The unattended auto-poker - (`(sleep N && pty send key:return) × k &`) that dismisses claude's first-launch TUI - gates, then `exec claude --permission-mode --resume `. (Reimplements st launch's builder.) -3. **pty.toml write.** Serialize the per-agent manifest (`prefix`, `[sessions.claude]` command+tags+env, - `[sessions.ding]`) with `smol-toml`. It's the record convoy spawns from + `convoy up` respawns from - (`pty restart` uses stored metadata). Note: this is **pty's** format, not a `convoy.toml`. -4. **ding-sidecar spawn.** `spawnDaemon` the `st ding -claude --identity ` session - (role=ding, strategy=permanent, ptyfile.session=ding). `st ding` stays a smalltalk runtime binary - (bus-side) — convoy just spawns + supervises it. -5. **spawn + tags.** `spawnDaemon` the claude session + the ding sidecar (host.ts, already works). Tags: - role, strategy=permanent, st.network, ptyfile/ptyfile.session; env: ST_AGENT + enriched PATH. - -## The handoff-to-smalltalk — the ONE decision for your review -An agent also needs the smalltalk-specific wiring to work on the bus: `PERSONA.md`, `DING-BUS.md`, -`CLAUDE.md` `@`-imports, and `.claude/settings.local.json` **hooks** (SessionStart boot-ritual, -PreCompact flush, StopFailure ding) — and those hooks **reference smalltalk's scripts by absolute path** -(`…/smalltalk/examples/claude-code/hooks/{session-start,pre-compact,stop-failure}.sh`). These are -smalltalk artifacts, so "remove st launch" forces a choice: - -- **A — import smalltalk's `cmdLaunch` (RECOMMENDED).** Call it as a library (smalltalk-claude is - exposing it) to write the wiring correctly, passing `--fresh` so IT skips the hanging bootstrap; then - **convoy owns the spawn + the session-id/`--resume` + the ding-sidecar.** Minimal convoy code, reuses - smalltalk's tested writes, no duplicated hook/template drift. "Native" = a function call, not a - shell-out; convoy still owns launch (drives it, controls spawn + resume). -- **B — fully reimplement the writes.** convoy writes persona/ding-bus/hooks itself, resolving - smalltalk's hook-script paths + vendoring the DING-BUS template. Zero smalltalk-launch dependency, but - duplicates smalltalk artifacts (drift risk) + more code (~+0.5 day). - -**Recommendation: A** — convoy owns launch (spawn + session-id/resume + ding-sidecar + the removal of -the shell-out) while not duplicating smalltalk's hook/persona/ding artifacts. It's the smallest correct -surface. If you want zero smalltalk-launch coupling, say B and I'll vendor. - -## st-launch-shell removal -Delete the `st launch --fresh` shell-out from `launchSpec` (src/commands.ts). Replace with the cmdLaunch -import (A) or convoy's native writers (B). Keep `st` for the bus (`message`/`agents`/`status`) and -`st ding` as the sidecar runtime binary. Coordinate the retirement of `st launch`'s user surface with -smalltalk-claude. - -## Verification (the DONE bar) -- `convoy add worker` → agent boots ding-only, **resumes** (`--resume`), registers permanent, hostable. -- Kill under a running `convoy up` → respawns (`--resume`, command preserved). -- evals **native-launch capstone** green (coordinate the re-run) + e2e-convoy-up 14/0 + demo-smoke 10/0 - + vitest all green vs the TS binary. -- New unit tests: session-id resolution (new→create / existing→`--resume`) + the command builder. - -## Effort -- **Option A: ~0.5 day** (session-id/resume + command builder + cmdLaunch wiring + ding-sidecar + - st-launch removal + tests). -- **Option B: ~1 day** (+ native persona/ding-bus/hooks writers + vendoring smalltalk artifacts). -- Decision-INDEPENDENT + startable now: the session-id/`--resume` fix + the ding-sidecar (convoy-native - in both). A/B only changes step 3's write-logic + the handoff. Gated on: smalltalk exposing - `cmdLaunch` (A) — coordinate with smalltalk-claude. - -## Build order (overnight) -- **M1** — session-id/`--resume` (native, decision-independent): the capstone's resume fix + tests. -- **M2** — ding-sidecar spawn (native). -- **M3** — the write-logic per your A/B call (A: cmdLaunch import; B: native writers). -- **M4** — remove the `st launch` shell + handoff coordination with smalltalk-claude. -- **M5** — guardrails + the native-launch capstone (with evals) → green. Ping when green = migrate-ready. From d405f536e317d9284eb6b764c31cc5e05649ce00 Mon Sep 17 00:00:00 2001 From: Nathan Herald Date: Tue, 29 Sep 2026 19:28:08 +0200 Subject: [PATCH 2/2] Replace private machine names and home paths, and check CI for them Tests, fixtures and comments used the fleet's real machines as hosts, session prefixes and bus-id prefixes. They now use example-mac and example-linux. Placeholder home paths now use /home/example and /Users/example. notes/NATIVE-LAUNCH-PLAN.md was a dated build plan and handoff. The launch design it proposed is in context/spec.md and the transports spec; the part never built, resuming the harness conversation across respawn, is now a roadmap entry. scripts/check-private-names.py, the same check as in pty, pty-relay, pty-layout and transplant, now runs on every pull request with a self-test that proves it still catches each kind of name. --- .github/workflows/private-names.yml | 17 ++ LAYOUT.md | 2 +- ...ntity-length-is-derived-from-the-socket.md | 2 +- context/roadmap.md | 19 +++ scripts/check-private-names.py | 148 ++++++++++++++++++ src/agent-file.test.ts | 10 +- src/agent-file.ts | 2 +- src/agent-spec-file.test.ts | 10 +- src/agent-spec.test.ts | 4 +- src/agent-spec.ts | 8 +- src/catalog.test.ts | 14 +- src/catalog.ts | 2 +- src/commands.test.ts | 38 ++--- src/commands.ts | 2 +- src/fabric-sync.test.ts | 6 +- src/fabric-sync.ts | 2 +- src/harness.test.ts | 22 +-- src/identity.test.ts | 2 +- src/install-cli.test.ts | 8 +- src/job.test.ts | 4 +- src/launch.test.ts | 68 ++++---- src/launch.ts | 6 +- src/reconcile.test.ts | 38 ++--- src/spec-format.test.ts | 26 +-- src/up-decoupling.test.ts | 2 +- 25 files changed, 323 insertions(+), 139 deletions(-) create mode 100644 .github/workflows/private-names.yml create mode 100755 scripts/check-private-names.py diff --git a/.github/workflows/private-names.yml b/.github/workflows/private-names.yml new file mode 100644 index 0000000..aa7345b --- /dev/null +++ b/.github/workflows/private-names.yml @@ -0,0 +1,17 @@ +name: Private names + +on: + pull_request: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: The check catches what it should + run: python3 scripts/check-private-names.py --self-test + - name: No private machine, person, or agent names + run: python3 scripts/check-private-names.py diff --git a/LAYOUT.md b/LAYOUT.md index eedd798..92481c7 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -13,7 +13,7 @@ A convoy **network** is a named home for one crew. Networks live under `$XDG_STA ``` $XDG_STATE_HOME/convoy// smalltalk/ # the bus — SYNCED across machines (st sync over fabric) - ./ # one folder per agent, hostname-prefixed (e.g. silber.cos-claude) + ./ # one folder per agent, hostname-prefixed (e.g. example-mac.cos-claude) inbox/ archive/ status # messages + the liveness heartbeat (the status file's mtime) context/ # the agent's working memory (cold-boot state) now.md decisions/ diff --git a/context/.decisions/0002-identity-length-is-derived-from-the-socket.md b/context/.decisions/0002-identity-length-is-derived-from-the-socket.md index 8c535b5..15f3510 100644 --- a/context/.decisions/0002-identity-length-is-derived-from-the-socket.md +++ b/context/.decisions/0002-identity-length-is-derived-from-the-socket.md @@ -17,7 +17,7 @@ network's path, the prefix, the longest suffix convoy appends, and the separators. This means the bound is **contextual**. A network at `/n` genuinely affords -longer agent names than one at `/home/user/.local/state/convoy/default`. A fixed +longer agent names than one at `/home/example/.local/state/convoy/default`. A fixed constant is either wrong for short paths (needlessly restrictive) or wrong for long ones (accepts names that cannot bind). diff --git a/context/roadmap.md b/context/roadmap.md index e5de770..076f055 100644 --- a/context/roadmap.md +++ b/context/roadmap.md @@ -66,3 +66,22 @@ which machine issued it. one machine. - Promotion target: the supervision subsystem, or a dedicated child node for cross-machine convergence. + +## Resuming the harness conversation across respawn + +A respawn is a cold boot today: recovery replays the session manifest, and the +agent rebuilds its context from its bus folder and its boot ritual. Keeping the +harness's conversation would make a respawn a continuation instead. The shape is +a conversation id kept in the agent's workspace: the first launch mints it and +starts the harness with it, and every later launch resumes it. + +The hazard is resuming a conversation whose original process still holds it. +That fails at once, and under a supervisor it becomes a tight respawn loop. So +resume depends on the liveness rule in the +[supervision spec](./03-supervision/spec.md): a session reported gone is +respawned only when its process is gone too. + +- Trigger: a respawned agent re-acts on work its previous run already handled, + or the boot ritual's cost dominates short restarts. +- Promotion target: restart durability in the transports subsystem spec, and + death and recovery in the supervision subsystem spec. diff --git a/scripts/check-private-names.py b/scripts/check-private-names.py new file mode 100755 index 0000000..f1bc39f --- /dev/null +++ b/scripts/check-private-names.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Keep private machine, person, and agent names out of this public repository. + +Fails when a tracked file names one of the fleet's machines, a home directory +under /home or /Users, a person/ ID, or an internal agent or mission ID +(agent/fleet/, mission/fleet/, mission-run/fleet/). Write invented +examples instead: web1.example.com, /home/example, /Users/example, +person/alex, agent/example/worker. + +Machine names are listed as SHA-256 digests of the lowercase name, so this +file does not publish the names it keeps out. To add a machine, append the +output of `printf %s NAME | shasum -a 256`. + +A line that must stay as it is goes in .private-names-allow: one regular +expression per line, searched in `path:text of the line`. Blank lines and +lines starting with # are ignored. + +Run it from anywhere inside the repository: python3 scripts/check-private-names.py +With --self-test it checks its own patterns instead of the repository. +""" + +import hashlib +import re +import subprocess +import sys +from pathlib import Path + +MACHINE_DIGESTS = { + "a994ffcab684f9e2136b0681c20d2a5ef5f23a5cb7b64ab8934d15a888cc4ea3", + "2c619e6895b513481a07dae6c4b5781282ef1eee7a7f16744fafe7dbbd3e5ca2", + "46b6312339466d3b206325f6f402e1fae56cd65f117c779e3b9259833ffbcdf0", + "9fe430e5274621be518924ed90d2355f39eeb1d4d98fb00ea0022f3a26ba5595", + "5881d66b8738b483578c8479c59f09856a5447b47bbd2660cff7dc92e1607a2d", + "294aa8d75483b8331e3ba6a7f24aea15202747f36de65197e7bc6194880b2558", + "151ec47d45b057450da03010df462bf604183571812f9ba66598b9fb8f8b692c", +} + +ALLOW_FILE = ".private-names-allow" + +WORD = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") +HOME = re.compile(r"/(?:home|Users)/([A-Za-z0-9._-]+)") +PERSON = re.compile(r"(? { const af = parseAgentFile(` identity = "convoy-claude" role = "worker" -host = "silber" +host = "example-mac" workspace = "/repos/convoy" harness = "codex" transport = "mcp" @@ -16,7 +16,7 @@ strategy = "permanent" expect(af).toEqual({ identity: "convoy-claude", role: "worker", - host: "silber", + host: "example-mac", workspace: "/repos/convoy", harness: "codex", transport: "mcp", @@ -30,7 +30,7 @@ strategy = "permanent" }); it("LOWERCASES a hand-authored capitalized host (else it silently never matches the host-filter)", () => { - expect(parseAgentFile(`identity="wk"\nrole="worker"\nhost="Silber"\n`).host).toBe("silber"); + expect(parseAgentFile(`identity="wk"\nrole="worker"\nhost="Example-Mac"\n`).host).toBe("example-mac"); }); it("throws on a missing required field", () => { @@ -117,7 +117,7 @@ describe("agentFileToSpec — compile intent → AgentSpec", () => { it("threads supervisor → spec.supervisor so a crash-ding reaches the parent (dropping it was the regression)", () => { // The declarative flow (convoy up launches from the catalog) must carry the declared supervisor through // to the spec — it becomes the session's convoy.spawner tag, which crashDingTargets pages on a crash. - expect(agentFileToSpec({ ...base, supervisor: "silber.cd-sup" }, { networkRoot: "/net" }).supervisor).toBe("silber.cd-sup"); + expect(agentFileToSpec({ ...base, supervisor: "example-mac.cd-sup" }, { networkRoot: "/net" }).supervisor).toBe("example-mac.cd-sup"); // no supervisor declared → null (launch then falls back to the launching ST_AGENT). expect(agentFileToSpec(base, { networkRoot: "/net" }).supervisor).toBeNull(); }); @@ -125,7 +125,7 @@ describe("agentFileToSpec — compile intent → AgentSpec", () => { describe("agentFileToToml — serialize (what convoy add authors)", () => { it("round-trips through parse: write then read yields the same AgentFile", () => { - const af: AgentFile = { identity: "cos-claude", role: "chief-of-staff", host: "silber", workspace: "/repos/cos", harness: "codex", transport: "mcp", persona: "/p.md", strategy: "permanent" }; + const af: AgentFile = { identity: "cos-claude", role: "chief-of-staff", host: "example-mac", workspace: "/repos/cos", harness: "codex", transport: "mcp", persona: "/p.md", strategy: "permanent" }; expect(parseAgentFile(agentFileToToml(af))).toEqual(af); }); diff --git a/src/agent-file.ts b/src/agent-file.ts index e0fff8f..daf91ec 100644 --- a/src/agent-file.ts +++ b/src/agent-file.ts @@ -163,7 +163,7 @@ export function parseAgentFile(text: string, format: SpecFormat = "toml", idCont const host = str("host"); // LOWERCASE the host on read: `convoy up`'s host-filter compares against `shortHostname()` (lowercased), and // the bus id / bus folder are `.` (lowercased). A hand-authored capitalized host (e.g. from - // `hostname -s` → "Silber") would otherwise SILENTLY never match this machine → the agent never launches, no + // `hostname -s` → "Example-Mac") would otherwise SILENTLY never match this machine → the agent never launches, no // error. Normalizing here keeps host-filter + bus-id + bus-folder consistent for hand-authored files too. if (host) af.host = host.toLowerCase(); const workspace = str("workspace"); diff --git a/src/agent-spec-file.test.ts b/src/agent-spec-file.test.ts index a313cbc..6529546 100644 --- a/src/agent-spec-file.test.ts +++ b/src/agent-spec-file.test.ts @@ -6,19 +6,19 @@ const MIN = `identity = "fabric"\nrole = "worker"\nsupervisor = "cos"\n`; describe("the spec's agent-level fields", () => { it("parses supervisor, prefix, bin, and env", () => { - const af = parseAgentFile(`${MIN}prefix = "silber"\nbin = "/opt/wrap/claude"\n[env]\nCODEX_HOME = "$HOME/.codex-fabric"\n`); + const af = parseAgentFile(`${MIN}prefix = "example-mac"\nbin = "/opt/wrap/claude"\n[env]\nCODEX_HOME = "$HOME/.codex-fabric"\n`); expect(af.supervisor).toBe("cos"); - expect(af.prefix).toBe("silber"); + expect(af.prefix).toBe("example-mac"); expect(af.bin).toBe("/opt/wrap/claude"); expect(af.env?.["CODEX_HOME"]).toBe("$HOME/.codex-fabric"); }); it("carries pty task blocks — the agent is the job, its ptys are the tasks", () => { - const af = parseAgentFile(`${MIN}[pty.agent]\ncommand = "exec claude"\nkeep = true\n[pty.agent.env]\nST_AGENT = "silber.fabric"\n[pty.ding]\ncommand = "st ding silber.fabric"\n`); + const af = parseAgentFile(`${MIN}[pty.agent]\ncommand = "exec claude"\nkeep = true\n[pty.agent.env]\nST_AGENT = "example-mac.fabric"\n[pty.ding]\ncommand = "st ding example-mac.fabric"\n`); expect(Object.keys(af.pty ?? {}).sort()).toEqual(["agent", "ding"]); expect(af.pty?.["agent"]?.command).toBe("exec claude"); expect(af.pty?.["agent"]?.keep).toBe(true); - expect(af.pty?.["agent"]?.env?.["ST_AGENT"]).toBe("silber.fabric"); + expect(af.pty?.["agent"]?.env?.["ST_AGENT"]).toBe("example-mac.fabric"); }); it("reads render.file as a list whether written once or many times", () => { @@ -37,7 +37,7 @@ describe("the spec's agent-level fields", () => { }); it("round-trips the new fields through the TOML writer", () => { - const af = parseAgentFile(`${MIN}bin = "/opt/wrap/claude"\nprefix = "silber"\n[env]\nCLAUDE_CONFIG_DIR = "$HOME/.claude-fabric"\n`); + const af = parseAgentFile(`${MIN}bin = "/opt/wrap/claude"\nprefix = "example-mac"\n[env]\nCLAUDE_CONFIG_DIR = "$HOME/.claude-fabric"\n`); const again = parseAgentFile(agentFileToToml(af)); expect(again).toEqual(af); }); diff --git a/src/agent-spec.test.ts b/src/agent-spec.test.ts index 6d2dd00..14aade1 100644 --- a/src/agent-spec.test.ts +++ b/src/agent-spec.test.ts @@ -61,8 +61,8 @@ describe("AgentSpec (ported from AgentSpecTests.swift)", () => { }); it("session-id: .; agentShort strips the harness suffix; prefix defaults to hostname", () => { - expect(sessionId(spec({ identity: "convoy-claude", prefix: "silber" }))).toBe("silber.convoy"); - expect(sessionId(spec({ identity: "app-web-claude", prefix: "silber" }))).toBe("silber.app-web"); + expect(sessionId(spec({ identity: "convoy-claude", prefix: "example-mac" }))).toBe("example-mac.convoy"); + expect(sessionId(spec({ identity: "app-web-claude", prefix: "example-mac" }))).toBe("example-mac.app-web"); expect(sessionId(spec({ identity: "bare", prefix: "h" }))).toBe("h.bare"); expect(agentShort("cos-codex")).toBe("cos"); expect(specPrefix(spec({ prefix: "custom" }))).toBe("custom"); diff --git a/src/agent-spec.ts b/src/agent-spec.ts index 257a8e4..793a547 100644 --- a/src/agent-spec.ts +++ b/src/agent-spec.ts @@ -62,12 +62,12 @@ export function specPermissionMode(_s: AgentSpec): PermissionMode { return "bypassPermissions"; } /** Short hostname (no domain), LOWERCASED — the default session-id prefix per Nathan's naming decision. - * Lowercased so it matches pty's id charset + the validated live ids (`silber`, not `Silber.local`). */ + * Lowercased so it matches pty's id charset + the validated live ids (`example-mac`, not `Example-Mac.local`). */ export function shortHostname(): string { const h = hostname(); return (h.split(".")[0] || h).toLowerCase(); } -/** The session-id prefix: `--prefix` override, else the short hostname (e.g. `silber`). */ +/** The session-id prefix: `--prefix` override, else the short hostname (e.g. `example-mac`). */ export function specPrefix(s: AgentSpec): string { return s.prefix ?? shortHostname(); } @@ -75,13 +75,13 @@ export function specPrefix(s: AgentSpec): string { export function agentShort(identity: string): string { return identity.replace(HARNESS_SUFFIX_RE, ""); } -/** The pinned pty session id for the claude session: `.` (e.g. `silber.convoy`). +/** The pinned pty session id for the claude session: `.` (e.g. `example-mac.convoy`). * The ding session appends `.ding`. Stable across respawns so ding + name refs never drift. */ export function sessionId(s: AgentSpec): string { return `${specPrefix(s)}.${agentShort(s.identity)}`; } /** The bus identity (`ST_AGENT`): the short-host prefix + the FULL identity, keeping the harness suffix - * (e.g. `silber.convoy-claude`). Host-prefixed so each machine's agents are distinct bus folders that + * (e.g. `example-mac.convoy-claude`). Host-prefixed so each machine's agents are distinct bus folders that * sync as a clean cross-machine union; the host is read back from this prefix (no separate host file). */ export function busAgentId(s: AgentSpec): string { return `${specPrefix(s)}.${s.identity}`; diff --git a/src/catalog.test.ts b/src/catalog.test.ts index e0a286d..1b64deb 100644 --- a/src/catalog.test.ts +++ b/src/catalog.test.ts @@ -26,7 +26,7 @@ describe("readCatalog — discovery slurps the tree", () => { it("finds specs at any depth, in every format, and reads identity from CONTENT not the filename", () => { const d = net({ "flat.toml": `identity = "alpha"\nrole = "worker"\nsupervisor = "cos"\n`, - "silber/beta/agent.kdl": `identity "beta"\nrole "worker"\nsupervisor "cos"\n`, + "example-mac/beta/agent.kdl": `identity "beta"\nrole "worker"\nsupervisor "cos"\n`, "a/b/c/anything.json": JSON.stringify({ identity: "gamma", role: "worker", supervisor: "cos" }), }); const { entries, errors } = readCatalog(d); @@ -84,19 +84,19 @@ describe("readCatalog — discovery slurps the tree", () => { describe("path segments supply DEFAULTS; content wins", () => { it("fills host from the path when the file is silent", () => { - const d = net({ "silber/alpha/agent.toml": `identity = "alpha"\nrole = "worker"\nsupervisor = "cos"\n` }); + const d = net({ "example-mac/alpha/agent.toml": `identity = "alpha"\nrole = "worker"\nsupervisor = "cos"\n` }); const { entries, warnings } = readCatalog(d); - expect(entries[0]?.af.host).toBe("silber"); + expect(entries[0]?.af.host).toBe("example-mac"); expect(warnings.filter((w) => w.warning.includes("host"))).toEqual([]); }); it("prefers the file over the path and WARNS on a mismatch, rather than erroring or silently obeying the path", () => { - const d = net({ "silber/alpha/agent.toml": `identity = "beta"\nrole = "worker"\nsupervisor = "cos"\nhost = "other"\n` }); + const d = net({ "example-mac/alpha/agent.toml": `identity = "beta"\nrole = "worker"\nsupervisor = "cos"\nhost = "other"\n` }); const { entries, warnings } = readCatalog(d); expect(entries[0]?.af.identity).toBe("beta"); expect(entries[0]?.af.host).toBe("other"); expect(warnings.map((w) => w.warning).join("\n")).toMatch(/path says identity "alpha".*declares "beta"/s); - expect(warnings.map((w) => w.warning).join("\n")).toMatch(/path says host "silber".*declares "other"/s); + expect(warnings.map((w) => w.warning).join("\n")).toMatch(/path says host "example-mac".*declares "other"/s); }); it("treats a role-named directory as grouping, not an identity claim", () => { @@ -114,7 +114,7 @@ describe("path segments supply DEFAULTS; content wins", () => { describe("pathDefaults", () => { it("reads the two segments nearest the file, and nothing from a flat layout", () => { - expect(pathDefaults("/c", "/c/silber/fabric/agent.toml")).toEqual({ host: "silber", identity: "fabric" }); + expect(pathDefaults("/c", "/c/example-mac/fabric/agent.toml")).toEqual({ host: "example-mac", identity: "fabric" }); expect(pathDefaults("/c", "/c/fabric.toml")).toEqual({}); expect(pathDefaults("/c", "/c/fabric/agent.toml")).toEqual({ identity: "fabric" }); }); @@ -122,6 +122,6 @@ describe("pathDefaults", () => { describe("newAgentSpecPath — convoy authors the spec's recommended layout", () => { it("writes catalog///agent.toml", () => { - expect(newAgentSpecPath("/n", "silber", "fabric")).toBe(join("/n", "catalog", "silber", "fabric", "agent.toml")); + expect(newAgentSpecPath("/n", "example-mac", "fabric")).toBe(join("/n", "catalog", "example-mac", "fabric", "agent.toml")); }); }); diff --git a/src/catalog.ts b/src/catalog.ts index 825893f..aad1970 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -5,7 +5,7 @@ // keeps whatever turns out to be an agent spec. Two consequences are the point: // // - IDENTITY COMES FROM CONTENT. The filename is not the identity. A flat `catalog/fabric.toml`, a -// spec'd `catalog/silber/fabric/agent.kdl`, and a `catalog/anything.json` that happens to declare +// spec'd `catalog/example-mac/fabric/agent.kdl`, and a `catalog/anything.json` that happens to declare // `identity = "fabric"` are the same agent. Renaming a file does not rename an agent. // - PATH SEGMENTS ONLY SUPPLY DEFAULTS. A directory named for a host fills in `host` when the file // omits it. When both are present and DISAGREE, the content wins and discovery warns — because the diff --git a/src/commands.test.ts b/src/commands.test.ts index 61b6bb9..12a617c 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -69,7 +69,7 @@ describe("retireInCatalog — `convoy remove` decommission = set retired=true (u it("resolves a HOST-PREFIXED bus id (`.`, what `convoy ls` shows) by stripping the prefix", () => { const catalog = tmpCatalog(); writeAgentFile(agentFilePath(catalog, "evals-codex"), { identity: "evals-codex", role: "worker" }); - const r = retireInCatalog(catalog, "silber.evals-codex"); + const r = retireInCatalog(catalog, "example-mac.evals-codex"); expect(r?.path).toBe(agentFilePath(catalog, "evals-codex")); expect(readAgentFile(agentFilePath(catalog, "evals-codex")).retired).toBe(true); }); @@ -115,17 +115,17 @@ describe("resolveNetworkRoot (no-leak: pty scope follows the bus scope)", () => // network dir, so `convoy add` wrote the catalog under `/smalltalk/catalog/` (unsynced) — silently // never syncing/launching. The fallback now strips the trailing `smalltalk` to recover ``. it("strips the bus-root `smalltalk` segment: ST_ROOT=/smalltalk falls back to , not the bus root", () => { - process.env["ST_ROOT"] = "/home/state/convoy/default/smalltalk"; - expect(resolveNetworkRoot(null)).toBe("/home/state/convoy/default"); + process.env["ST_ROOT"] = "/home/example/.local/state/convoy/default/smalltalk"; + expect(resolveNetworkRoot(null)).toBe("/home/example/.local/state/convoy/default"); }); it("ACCEPTANCE (exact repro): with ST_ROOT=/smalltalk, `convoy add`'s catalog path lands in /catalog/, NOT /smalltalk/catalog/", () => { // cmdAdd computes `agentFilePath(catalogDir(resolveNetworkRoot(--network)), identity)`; drive that exact // chain with the reported ambient env (no --network → null) and assert where the file would be written. - process.env["ST_ROOT"] = "/home/state/convoy/default/smalltalk"; + process.env["ST_ROOT"] = "/home/example/.local/state/convoy/default/smalltalk"; const network = resolveNetworkRoot(null); const path = agentFilePath(catalogDir(network), "demo"); - expect(path).toBe("/home/state/convoy/default/catalog/demo.toml"); // the SYNCED catalog + expect(path).toBe("/home/example/.local/state/convoy/default/catalog/demo.toml"); // the SYNCED catalog expect(path).not.toContain("/smalltalk/catalog/"); // never the unsynced bus-root subtree }); }); @@ -138,7 +138,7 @@ describe("networkDirOfStRoot — recover the network dir from a bus root (invers expect(networkDirOfStRoot("/n/default")).toBe("/n/default"); }); it("a network literally NAMED smalltalk still resolves: /smalltalk/smalltalk → /smalltalk", () => { - expect(networkDirOfStRoot("/home/convoy/smalltalk/smalltalk")).toBe("/home/convoy/smalltalk"); + expect(networkDirOfStRoot("/home/example/convoy/smalltalk/smalltalk")).toBe("/home/example/convoy/smalltalk"); }); }); @@ -238,7 +238,7 @@ describe("convoy env / shell — network env exports (footgun-proof targeting)", it("hostPrefixedIdentity: a bare id gets the short-host prefix; an already-prefixed one passes through", () => { expect(hostPrefixedIdentity("cvw-claude")).toBe(`${shortHostname()}.cvw-claude`); // matches the bus folder - expect(hostPrefixedIdentity("silber.cvw-claude")).toBe("silber.cvw-claude"); // already prefixed → unchanged + expect(hostPrefixedIdentity("example-mac.cvw-claude")).toBe("example-mac.cvw-claude"); // already prefixed → unchanged }); it("resolveNetworkEnv derives {networkDir, stRoot=/smalltalk, ptyRoot=/pty} from a REAL network dir", () => { @@ -332,16 +332,16 @@ describe("convoy ls --tree — spawn-parentage forest + remote section", () => { const A = (identity: string, status = "available"): Agent => ({ identity, status: status as never, name: null, lastActivity: null, inbox: null }); it("agentForest: nests children under their spawner; cos-tier root first; non-local → remote", () => { - const agents = [A("cos-claude"), A("sup-claude"), A("worker-claude"), A("app-apple-claude"), A("hetz-demo")]; + const agents = [A("cos-claude"), A("sup-claude"), A("worker-claude"), A("app-apple-claude"), A("example-linux-demo")]; const local = new Map([ ["cos-claude", { spawner: undefined, tier: "cos" }], ["sup-claude", { spawner: "cos-claude", tier: undefined }], ["worker-claude", { spawner: "sup-claude", tier: undefined }], ["app-apple-claude", { spawner: undefined, tier: undefined }], // no spawner → a root (flat, pre-#48) - // hetz-demo has NO local session → remote + // example-linux-demo has NO local session → remote ]); const { roots, remote } = agentForest(agents, local); - expect(remote.map((a) => a.identity)).toEqual(["hetz-demo"]); + expect(remote.map((a) => a.identity)).toEqual(["example-linux-demo"]); expect(roots.map((r) => r.agent.identity)).toEqual(["cos-claude", "app-apple-claude"]); // cos-tier sorts first const cos = roots.find((r) => r.agent.identity === "cos-claude")!; expect(cos.children.map((c) => c.agent.identity)).toEqual(["sup-claude"]); @@ -350,7 +350,7 @@ describe("convoy ls --tree — spawn-parentage forest + remote section", () => { it("agentForest: a spawner that isn't a local agent → the child is a ROOT (Phase 1 has no cross-machine parent), never dropped", () => { const agents = [A("wk-claude")]; - const local = new Map([["wk-claude", { spawner: "hetz-sup", tier: undefined }]]); + const local = new Map([["wk-claude", { spawner: "example-linux-sup", tier: undefined }]]); expect(agentForest(agents, local).roots.map((r) => r.agent.identity)).toEqual(["wk-claude"]); }); @@ -381,12 +381,12 @@ describe("cross-machine liveness (item 2) — readAgentPresence + shortHost", () it("readAgentPresence: status MTIME from //status + host from the folder-name prefix (.)", () => { const root = mkdtempSync(join(tmpdir(), "convoy-pres-")); try { - mkdirSync(join(root, "hetz.hetz-codex"), { recursive: true }); - writeFileSync(join(root, "hetz.hetz-codex", "status"), "available\n"); - const p = readAgentPresence(root, "hetz.hetz-codex"); + mkdirSync(join(root, "example-linux.example-linux-codex"), { recursive: true }); + writeFileSync(join(root, "example-linux.example-linux-codex", "status"), "available\n"); + const p = readAgentPresence(root, "example-linux.example-linux-codex"); expect(typeof p.statusMtime).toBe("number"); expect(p.statusMtime).toBeGreaterThan(0); - expect(p.host).toBe("hetz"); // derived from the folder-name prefix, NOT a host file + expect(p.host).toBe("example-linux"); // derived from the folder-name prefix, NOT a host file } finally { rmSync(root, { recursive: true, force: true }); } @@ -405,10 +405,10 @@ describe("cross-machine liveness (item 2) — readAgentPresence + shortHost", () }); it("shortHost: first dot-label, lowercased (for display + same-host comparison)", () => { - expect(shortHost("hetz.example.com")).toBe("hetz"); - expect(shortHost("silber")).toBe("silber"); - expect(shortHost("HETZ.local")).toBe("hetz"); - expect(shortHost(" hetz ")).toBe("hetz"); + expect(shortHost("example-linux.example.com")).toBe("example-linux"); + expect(shortHost("example-mac")).toBe("example-mac"); + expect(shortHost("EXAMPLE-LINUX.local")).toBe("example-linux"); + expect(shortHost(" example-linux ")).toBe("example-linux"); }); }); diff --git a/src/commands.ts b/src/commands.ts index c37998d..9d3f0a8 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -181,7 +181,7 @@ export function resolveNetworkEnv(args: string[]): { networkDir: string; stRoot: return { networkDir, stRoot: l.stRoot, ptyRoot: l.ptyRoot }; } -/** Host-prefix a bare identity (`cvw-claude` → `silber.cvw-claude`) so a human acting-as an agent gets +/** Host-prefix a bare identity (`cvw-claude` → `example-mac.cvw-claude`) so a human acting-as an agent gets * an ST_AGENT that MATCHES the host-prefixed bus folder (`/smalltalk/./`). An identity * that already carries a host prefix (contains a `.`) passes through unchanged. */ export function hostPrefixedIdentity(id: string): string { diff --git a/src/fabric-sync.test.ts b/src/fabric-sync.test.ts index 1889495..eb2db48 100644 --- a/src/fabric-sync.test.ts +++ b/src/fabric-sync.test.ts @@ -8,9 +8,9 @@ const throwingExec: any = async () => { throw new Error("ENOENT: fabric not foun describe("fabric-sync — convoy DECLARES its catalog to `fabric sync` (does not sync itself)", () => { it("catalogSyncName: convoy-catalog-, distinct per network, same across machines", () => { - expect(catalogSyncName("/home/x/.local/state/convoy/default")).toBe("convoy-catalog-default"); - expect(catalogSyncName("/home/x/.local/state/convoy/staging")).toBe("convoy-catalog-staging"); // distinct per network → no collision on one box - expect(catalogSyncName("/anywhere/default")).toBe("convoy-catalog-default"); // path differs Mac vs hetz; NAME is the shared key + expect(catalogSyncName("/home/example/.local/state/convoy/default")).toBe("convoy-catalog-default"); + expect(catalogSyncName("/home/example/.local/state/convoy/staging")).toBe("convoy-catalog-staging"); // distinct per network → no collision on one box + expect(catalogSyncName("/anywhere/default")).toBe("convoy-catalog-default"); // path differs Mac vs Linux; NAME is the shared key }); it("fabricSyncAddArgv: the exact `fabric sync add` declaration — folder=abs catalog, shared name, peers=*, policy=catalog, include=*.toml", () => { diff --git a/src/fabric-sync.ts b/src/fabric-sync.ts index b5b8bea..1c23349 100644 --- a/src/fabric-sync.ts +++ b/src/fabric-sync.ts @@ -19,7 +19,7 @@ export function catalogSyncName(networkDir: string): string { } /** The `fabric sync add` argv convoy runs to declare a network's catalog: folder = the ABSOLUTE catalog dir, - * the shared per-network `--name`, `--peers *` (follows peers.toml — a newly-trusted machine like hetz is + * the shared per-network `--name`, `--peers *` (follows peers.toml — a newly-trusted machine like example-linux is * auto-included, entry never changes), `--policy catalog`, `--include *.toml` (only agent files sync — guards * against a stray non-agent file in the dir). Pure → unit-testable without running fabric. */ export function fabricSyncAddArgv(networkDir: string): string[] { diff --git a/src/harness.test.ts b/src/harness.test.ts index 1117a90..a456c20 100644 --- a/src/harness.test.ts +++ b/src/harness.test.ts @@ -63,12 +63,12 @@ describe("credential selection is per-harness, not claude-shaped", () => { // LOCK — passes on main. The reported gap ("a codex agent has no way to select an account") was already // closed for the DECLARATIVE path by the verbatim `env` spread. Pinned so it stays closed. it("LOCK: a codex agent file's CODEX_HOME reaches the launched session, and not the ding sidecar", () => { - const af = parseAgentFile(`${MIN}harness = "codex"\n[env]\nCODEX_HOME = "/home/u/.codex-alt"\n`); + const af = parseAgentFile(`${MIN}harness = "codex"\n[env]\nCODEX_HOME = "/home/example/.codex-alt"\n`); const dir = tmp(); writePtyToml(dir, agentFileToSpec(af, { networkRoot: null })); const toml = readFileSync(join(dir, ".convoy", "pty.toml"), "utf8"); - expect(toml).toContain('CODEX_HOME = "/home/u/.codex-alt"'); + expect(toml).toContain('CODEX_HOME = "/home/example/.codex-alt"'); // Exactly once: the harness session. The ding sidecar is `st ding` and must not carry credentials. expect(toml.match(/CODEX_HOME/g)?.length).toBe(1); expect(toml.split("[sessions.ding.env]")[1] ?? "").not.toContain("CODEX_HOME"); @@ -78,14 +78,14 @@ describe("credential selection is per-harness, not claude-shaped", () => { // `--config-dir` on a codex session set a variable codex does not read. The flag reported success and // selected nothing — the failure mode is a session silently running as the WRONG ACCOUNT. it("--config-dir on a codex session sets CODEX_HOME, never CLAUDE_CONFIG_DIR", () => { - const env = harnessEnv(spec({ harness: "codex", configDir: "/home/u/.codex-alt" })); - expect(env["CODEX_HOME"]).toBe("/home/u/.codex-alt"); + const env = harnessEnv(spec({ harness: "codex", configDir: "/home/example/.codex-alt" })); + expect(env["CODEX_HOME"]).toBe("/home/example/.codex-alt"); expect(env["CLAUDE_CONFIG_DIR"]).toBeUndefined(); }); it("--config-dir on a claude session still sets CLAUDE_CONFIG_DIR (unchanged)", () => { - const env = harnessEnv(spec({ harness: "claude", configDir: "/home/u/.claude-alt" })); - expect(env["CLAUDE_CONFIG_DIR"]).toBe("/home/u/.claude-alt"); + const env = harnessEnv(spec({ harness: "claude", configDir: "/home/example/.claude-alt" })); + expect(env["CLAUDE_CONFIG_DIR"]).toBe("/home/example/.claude-alt"); expect(env["CODEX_HOME"]).toBeUndefined(); }); @@ -93,13 +93,13 @@ describe("credential selection is per-harness, not claude-shaped", () => { // CODEX_HOME never became `configDir` — which is what pre-trust keys on. The env var reached the // process (see the LOCK test) but convoy did not KNOW the config was relocated. it("a codex spec's CODEX_HOME is lifted to configDir, so convoy knows the config moved", () => { - const af = parseAgentFile(`${MIN}harness = "codex"\n[env]\nCODEX_HOME = "/home/u/.codex-alt"\n`); - expect(agentFileToSpec(af, { networkRoot: null }).configDir).toBe("/home/u/.codex-alt"); + const af = parseAgentFile(`${MIN}harness = "codex"\n[env]\nCODEX_HOME = "/home/example/.codex-alt"\n`); + expect(agentFileToSpec(af, { networkRoot: null }).configDir).toBe("/home/example/.codex-alt"); }); it("a claude spec still lifts CLAUDE_CONFIG_DIR, and does not read the other harness's var", () => { - const af = parseAgentFile(`${MIN}[env]\nCLAUDE_CONFIG_DIR = "/home/u/.claude-alt"\nCODEX_HOME = "/nope"\n`); - expect(agentFileToSpec(af, { networkRoot: null }).configDir).toBe("/home/u/.claude-alt"); + const af = parseAgentFile(`${MIN}[env]\nCLAUDE_CONFIG_DIR = "/home/example/.claude-alt"\nCODEX_HOME = "/nope"\n`); + expect(agentFileToSpec(af, { networkRoot: null }).configDir).toBe("/home/example/.claude-alt"); }); // FAILS ON MAIN: codexConfigPath() took no argument and always returned ~/.codex/config.toml, so a codex @@ -215,7 +215,7 @@ describe("a harness declares what it does NOT support", () => { // (The CLI refuses it outright; this pins the launch layer so the two cannot disagree.) it("a configDir on opencode/pi injects nothing rather than guessing a variable", () => { for (const h of ["opencode", "pi"] as const) { - const env = harnessEnv(spec({ harness: h, configDir: "/home/u/whatever" })); + const env = harnessEnv(spec({ harness: h, configDir: "/home/example/whatever" })); expect(env["CLAUDE_CONFIG_DIR"]).toBeUndefined(); expect(env["CODEX_HOME"]).toBeUndefined(); expect(Object.keys(env)).toEqual(["ST_AGENT"]); diff --git a/src/identity.test.ts b/src/identity.test.ts index 9581877..834cf8f 100644 --- a/src/identity.test.ts +++ b/src/identity.test.ts @@ -39,7 +39,7 @@ describe("identityErrors — one grammar, owned by the bus", () => { describe("identity length — derived from pty's socket, not a taste bound", () => { it("budgets exactly what pty leaves after PTY_ROOT, the prefix, and the .ding.sock suffix", () => { const root = "/n/pty"; - const prefix = "silber"; + const prefix = "example-mac"; const budget = identityByteBudget(root, prefix); // The longest path convoy can produce for this identity must fit pty's limit exactly at the boundary. const at = "x".repeat(budget); diff --git a/src/install-cli.test.ts b/src/install-cli.test.ts index 90d8c6b..08e3daf 100644 --- a/src/install-cli.test.ts +++ b/src/install-cli.test.ts @@ -16,12 +16,12 @@ describe("toolSources", () => { describe("defaultBinDir / dirOnPath / pathHint (portable, env-driven)", () => { it("defaultBinDir honors CONVOY_BIN_DIR, else ~/.local/bin", () => { expect(defaultBinDir({ CONVOY_BIN_DIR: "/opt/bin" })).toBe("/opt/bin"); - expect(defaultBinDir({ HOME: "/home/x" })).toBe("/home/x/.local/bin"); + expect(defaultBinDir({ HOME: "/home/example" })).toBe("/home/example/.local/bin"); }); it("dirOnPath matches an exact PATH segment", () => { - expect(dirOnPath("/home/x/.local/bin", { PATH: "/usr/bin:/home/x/.local/bin:/bin" })).toBe(true); - expect(dirOnPath("/home/x/.local/bin", { PATH: "/usr/bin:/bin" })).toBe(false); - expect(dirOnPath("/home/x/.local", { PATH: "/home/x/.local/bin" })).toBe(false); // not a prefix match + expect(dirOnPath("/home/example/.local/bin", { PATH: "/usr/bin:/home/example/.local/bin:/bin" })).toBe(true); + expect(dirOnPath("/home/example/.local/bin", { PATH: "/usr/bin:/bin" })).toBe(false); + expect(dirOnPath("/home/example/.local", { PATH: "/home/example/.local/bin" })).toBe(false); // not a prefix match }); it("pathHint is shell-specific (never assumes zsh)", () => { expect(pathHint("/b", { SHELL: "/usr/bin/fish" })).toMatch(/fish_add_path/); diff --git a/src/job.test.ts b/src/job.test.ts index 125f43b..2ecff56 100644 --- a/src/job.test.ts +++ b/src/job.test.ts @@ -44,12 +44,12 @@ describe("isValidJobId", () => { describe("writeCompletion / readCompletion", () => { it("round-trips the event and creates the jobs dir atomically", () => { const n = net(); - const path = writeCompletion(n, { job: "default", status: "ok", message: "done", by: "silber.sup", ts: 123 }); + const path = writeCompletion(n, { job: "default", status: "ok", message: "done", by: "example-mac.sup", ts: 123 }); expect(path).toBe(completionPath(n, "default")); expect(existsSync(jobsDir(n))).toBe(true); // no leftover temp file from the atomic write expect(readFileSync(path, "utf8")).toMatch(/"status": "ok"/); - expect(readCompletion(n, "default")).toEqual({ job: "default", status: "ok", message: "done", by: "silber.sup", ts: 123 }); + expect(readCompletion(n, "default")).toEqual({ job: "default", status: "ok", message: "done", by: "example-mac.sup", ts: 123 }); }); it("returns null when no event exists yet", () => { diff --git a/src/launch.test.ts b/src/launch.test.ts index 4eba497..5f9323c 100644 --- a/src/launch.test.ts +++ b/src/launch.test.ts @@ -52,28 +52,28 @@ describe("native launch command builders (cold-start boot-prompt)", () => { }); it("dingCommand: st ding --identity ", () => { - expect(dingCommand("convoy-claude", "silber.convoy")).toBe("st ding silber.convoy --identity convoy-claude"); + expect(dingCommand("convoy-claude", "example-mac.convoy")).toBe("st ding example-mac.convoy --identity convoy-claude"); }); it("dingCommand: bakes --root into the command line when a network root is given (restart-proof)", () => { - expect(dingCommand("convoy-claude", "silber.convoy", "/Users/x/.local/state/convoy")).toBe( - "st ding silber.convoy --identity convoy-claude --root /Users/x/.local/state/convoy", + expect(dingCommand("convoy-claude", "example-mac.convoy", "/Users/example/.local/state/convoy")).toBe( + "st ding example-mac.convoy --identity convoy-claude --root /Users/example/.local/state/convoy", ); // no root → no flag (unchanged behavior; falls back to ST_ROOT env / install default) - expect(dingCommand("convoy-claude", "silber.convoy", null)).toBe("st ding silber.convoy --identity convoy-claude"); + expect(dingCommand("convoy-claude", "example-mac.convoy", null)).toBe("st ding example-mac.convoy --identity convoy-claude"); }); it("dingCommand: service='rust' swaps the binary to `ding` (identical args); 'node'/undefined → `st ding`", () => { // rust ding = drop-in: same positional session-id, same --identity/--root — only the prefix changes. - expect(dingCommand("convoy-claude", "silber.convoy", "/net/smalltalk", "rust")).toBe( - "ding silber.convoy --identity convoy-claude --root /net/smalltalk", + expect(dingCommand("convoy-claude", "example-mac.convoy", "/net/smalltalk", "rust")).toBe( + "ding example-mac.convoy --identity convoy-claude --root /net/smalltalk", ); // node + undefined both → `st ding` (the default; every existing user is unchanged). - expect(dingCommand("convoy-claude", "silber.convoy", "/net/smalltalk", "node")).toBe( - "st ding silber.convoy --identity convoy-claude --root /net/smalltalk", + expect(dingCommand("convoy-claude", "example-mac.convoy", "/net/smalltalk", "node")).toBe( + "st ding example-mac.convoy --identity convoy-claude --root /net/smalltalk", ); - expect(dingCommand("convoy-claude", "silber.convoy", "/net/smalltalk")).toBe( - "st ding silber.convoy --identity convoy-claude --root /net/smalltalk", + expect(dingCommand("convoy-claude", "example-mac.convoy", "/net/smalltalk")).toBe( + "st ding example-mac.convoy --identity convoy-claude --root /net/smalltalk", ); }); }); @@ -89,7 +89,7 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => { personaOverride: null, workingDir: null, permanentOverride: null, - prefix: "silber", + prefix: "example-mac", configDir: null, model: null, bin: null, @@ -102,11 +102,11 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => { try { writePtyToml(dir, spec()); const toml = readFileSync(join(dir, ".convoy", "pty.toml"), "utf8"); - expect(toml).toContain('id = "silber.convoy"'); - expect(toml).toContain('id = "silber.convoy.ding"'); - expect(toml).toContain('prefix = "silber.convoy"'); + expect(toml).toContain('id = "example-mac.convoy"'); + expect(toml).toContain('id = "example-mac.convoy.ding"'); + expect(toml).toContain('prefix = "example-mac.convoy"'); expect(toml).toContain("exec claude --permission-mode bypassPermissions"); - expect(toml).toContain("st ding silber.convoy --identity silber.convoy-claude"); // host-prefixed bus id + expect(toml).toContain("st ding example-mac.convoy --identity example-mac.convoy-claude"); // host-prefixed bus id expect(toml).not.toContain("pty send"); // poker gone expect(toml).not.toContain("--resume"); // cold start } finally { @@ -120,7 +120,7 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => { writePtyToml(dir, spec({ networkRoot: "/net/convoy" })); const toml = readFileSync(join(dir, ".convoy", "pty.toml"), "utf8"); // ding command carries --root (the bus root = /smalltalk) so a pty-restart can't drop it - expect(toml).toContain("st ding silber.convoy --identity silber.convoy-claude --root /net/convoy/smalltalk"); + expect(toml).toContain("st ding example-mac.convoy --identity example-mac.convoy-claude --root /net/convoy/smalltalk"); // --root is a ding-only concern; the harness (claude) command must not get it expect(toml).not.toContain("exec claude --permission-mode bypassPermissions --root"); // env carries ST_ROOT = the bus root (/smalltalk), not the network dir @@ -138,14 +138,14 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => { writeNetworkConfig(net, { name: "ournet" }); writePtyToml(dir, spec({ networkRoot: net })); expect(readFileSync(join(dir, ".convoy", "pty.toml"), "utf8")).toContain( - `st ding silber.convoy --identity silber.convoy-claude --root ${stRootOf(net)}`, + `st ding example-mac.convoy --identity example-mac.convoy-claude --root ${stRootOf(net)}`, ); // network chooses rust → the sidecar swaps to `ding` (same args), fully — no `st ding` left. writeNetworkConfig(net, { name: "ournet", ding: "rust" }); writePtyToml(dir, spec({ networkRoot: net })); const toml = readFileSync(join(dir, ".convoy", "pty.toml"), "utf8"); - expect(toml).toContain(`ding silber.convoy --identity silber.convoy-claude --root ${stRootOf(net)}`); + expect(toml).toContain(`ding example-mac.convoy --identity example-mac.convoy-claude --root ${stRootOf(net)}`); expect(toml).not.toContain("st ding"); } finally { rmSync(net, { recursive: true, force: true }); @@ -170,8 +170,8 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => { expect(harness["PTY_REAP_ON_EXIT"]).toBe("false"); expect(ding["PTY_REAP_ON_EXIT"]).toBe("false"); // the derived wiring ALWAYS wins — a network env can't repoint ST_AGENT. - expect(harness["ST_AGENT"]).toBe("silber.convoy-claude"); - expect(ding["ST_AGENT"]).toBe("silber.convoy-claude"); + expect(harness["ST_AGENT"]).toBe("example-mac.convoy-claude"); + expect(ding["ST_AGENT"]).toBe("example-mac.convoy-claude"); // a per-agent env key overrides the network default (harness); the ding has no per-agent env → network value. expect(harness["SHARED"]).toBe("agent-wins"); expect(ding["SHARED"]).toBe("net"); @@ -209,7 +209,7 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => { try { writePtyToml(dir, spec({ role: "chief-of-staff", identity: "cos-claude" })); const toml = readFileSync(join(dir, ".convoy", "pty.toml"), "utf8"); - expect(toml).toContain('id = "silber.cos"'); + expect(toml).toContain('id = "example-mac.cos"'); expect(toml).toContain("first-run interview"); } finally { rmSync(dir, { recursive: true, force: true }); @@ -225,8 +225,8 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => { expect(toml).toContain("exec codex --dangerously-bypass-approvals-and-sandbox"); expect(toml).not.toContain("[sessions.claude]"); expect(toml).not.toContain("exec claude"); - expect(toml).toContain('id = "silber.vauban"'); // agentShort strips the -codex suffix - expect(toml).toContain("st ding silber.vauban --identity silber.vauban-codex"); // host-prefixed bus id + expect(toml).toContain('id = "example-mac.vauban"'); // agentShort strips the -codex suffix + expect(toml).toContain("st ding example-mac.vauban --identity example-mac.vauban-codex"); // host-prefixed bus id } finally { rmSync(dir, { recursive: true, force: true }); } @@ -323,10 +323,10 @@ describe("discoverSmalltalkDir via ST_BIN (the off-PATH hooks discovery — Joha describe("regenerateDingRoot (heal pre-#43 pty.tomls for cold-start durability)", () => { // A PRE-#43 pty.toml: ding command has NO --root, root lives only in [sessions.ding.env]. - const preToml = `prefix = "silber.evals" + const preToml = `prefix = "example-mac.evals" [sessions.claude] -id = "silber.evals" +id = "example-mac.evals" command = "exec claude --permission-mode bypassPermissions --resume ABC-123-RESUME" [sessions.claude.tags] @@ -340,8 +340,8 @@ ST_ROOT = "/net/convoy" PTY_ROOT = "/net/convoy/pty" [sessions.ding] -id = "silber.evals.ding" -command = "st ding silber.evals --identity evals-claude" +id = "example-mac.evals.ding" +command = "st ding example-mac.evals --identity evals-claude" [sessions.ding.tags] role = "ding" @@ -361,10 +361,10 @@ PTY_ROOT = "/net/convoy/pty" writeFileSync(join(dir, ".convoy", "pty.toml"), preToml); const r = regenerateDingRoot(dir); expect(r).not.toBeNull(); - expect(r?.before).toBe("st ding silber.evals --identity evals-claude"); - expect(r?.after).toBe("st ding silber.evals --identity evals-claude --root /net/convoy"); + expect(r?.before).toBe("st ding example-mac.evals --identity evals-claude"); + expect(r?.after).toBe("st ding example-mac.evals --identity evals-claude --root /net/convoy"); const out = readFileSync(join(dir, ".convoy", "pty.toml"), "utf8"); - expect(out).toContain('command = "st ding silber.evals --identity evals-claude --root /net/convoy"'); + expect(out).toContain('command = "st ding example-mac.evals --identity evals-claude --root /net/convoy"'); // harness block untouched — --resume + boot prompt survive byte-for-byte expect(out).toContain('command = "exec claude --permission-mode bypassPermissions --resume ABC-123-RESUME"'); } finally { @@ -628,15 +628,15 @@ describe("convoy add clean-worktree — pty.toml + settings + context, EVERY aut // DECLARATIVE flow: `convoy up` (the host) does the launch, so ST_AGENT is the HOST — the declared // supervisor must WIN, else a worker's crash never pages its actual parent (the regression). - process.env["ST_AGENT"] = "hetz.convoy-up-host"; - writeAgentFiles(repo, { ...spec(repo, persona, netRoot), supervisor: "silber.cd-sup" }); + process.env["ST_AGENT"] = "example-linux.convoy-up-host"; + writeAgentFiles(repo, { ...spec(repo, persona, netRoot), supervisor: "example-mac.cd-sup" }); const toml = readFileSync(join(repo, ".convoy", "pty.toml"), "utf8"); - expect(toml).toContain('"convoy.spawner" = "silber.cd-sup"'); // the declared parent, NOT the host + expect(toml).toContain('"convoy.spawner" = "example-mac.cd-sup"'); // the declared parent, NOT the host expect(toml).not.toContain("convoy-up-host"); // IMPERATIVE `convoy run` (no declared supervisor) → falls back to the launching ST_AGENT (the runner). writeAgentFiles(repo, { ...spec(repo, persona, netRoot), supervisor: null }); - expect(readFileSync(join(repo, ".convoy", "pty.toml"), "utf8")).toContain('"convoy.spawner" = "hetz.convoy-up-host"'); + expect(readFileSync(join(repo, ".convoy", "pty.toml"), "utf8")).toContain('"convoy.spawner" = "example-linux.convoy-up-host"'); } finally { if (savedSt === undefined) delete process.env["SMALLTALK_DIR"]; else process.env["SMALLTALK_DIR"] = savedSt; diff --git a/src/launch.ts b/src/launch.ts index 822abe4..7af9886 100644 --- a/src/launch.ts +++ b/src/launch.ts @@ -184,7 +184,7 @@ export function provisionContext(memberDir: string, identity: string): string | * `convoy add`, from their ST_AGENT) is stamped on the HARNESS session so a crash-ding reaches this agent's * ACTUAL supervisor, not the whole permanent crew (see up.ts crashDingTargets). */ export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: string | null }): void { - const busId = busAgentId(spec); // the host-prefixed bus identity, e.g. silber.convoy-claude + const busId = busAgentId(spec); // the host-prefixed bus identity, e.g. example-mac.convoy-claude const root = spec.networkRoot; // the network DIR; ST_ROOT is /smalltalk (the bus), PTY_ROOT is /pty // Per-network config from /convoy.toml (unset fields → defaults): the ding SERVICE (node `st ding` // vs rust `ding`) baked into the sidecar command, and a network-wide agent ENV merged into every session @@ -192,8 +192,8 @@ export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: st const netCfg = root ? readNetworkConfig(root) : null; const dingService = netCfg?.ding; const networkEnv = netCfg?.env ?? {}; - const harnessId = sessionId(spec); // e.g. silber.convoy (agentShort strips the -claude/-codex suffix) - const dingId = `${harnessId}.ding`; // e.g. silber.convoy.ding + const harnessId = sessionId(spec); // e.g. example-mac.convoy (agentShort strips the -claude/-codex suffix) + const dingId = `${harnessId}.ding`; // e.g. example-mac.convoy.ding const permanent = specPermanent(spec); const stTag = root ? { "st.network": root } : {}; // Crash-ding targeting tags (read by up.ts crashDingTargets), HARNESS session only — never the ding sidecar diff --git a/src/reconcile.test.ts b/src/reconcile.test.ts index 6ff1409..b925e35 100644 --- a/src/reconcile.test.ts +++ b/src/reconcile.test.ts @@ -14,23 +14,23 @@ const entry = (af: AgentFile): CatalogEntry => ({ af, path: `/cat/${af.identity} describe("agentBusId", () => { it("compiles .; host defaults to thisHost", () => { - expect(agentBusId({ identity: "wk", role: "worker", host: "boxb" }, "silber")).toBe("boxb.wk"); - expect(agentBusId({ identity: "wk", role: "worker" }, "silber")).toBe("silber.wk"); + expect(agentBusId({ identity: "wk", role: "worker", host: "boxb" }, "example-mac")).toBe("boxb.wk"); + expect(agentBusId({ identity: "wk", role: "worker" }, "example-mac")).toBe("example-mac.wk"); }); }); describe("reconcilePlan — desired (catalog) vs actual (sessions), host-filtered", () => { - const thisHost = "silber"; + const thisHost = "example-mac"; it("HOST-FILTER: an agent whose host != this machine is skipped (another host's up launches it)", () => { - const other = entry({ identity: "remote", role: "worker", host: "hetzner" }); + const other = entry({ identity: "remote", role: "worker", host: "example-linux" }); const plan = reconcilePlan([other], [], thisHost, resolve); expect(plan.otherHost).toEqual([other]); expect(plan.launch).toEqual([]); }); it("LAUNCH: active, this host, no live session → launch", () => { - const a = entry({ identity: "wk", role: "worker", host: "silber" }); + const a = entry({ identity: "wk", role: "worker", host: "example-mac" }); const plan = reconcilePlan([a], [], thisHost, resolve); expect(plan.launch).toEqual([a]); expect(plan.adopt).toEqual([]); @@ -38,14 +38,14 @@ describe("reconcilePlan — desired (catalog) vs actual (sessions), host-filtere it("ADOPT: active, this host, a live session exists → adopt (leave it, don't re-launch)", () => { const a = entry({ identity: "wk", role: "worker" }); // host omitted → this host - const plan = reconcilePlan([a], [sess("silber.wk")], thisHost, resolve); + const plan = reconcilePlan([a], [sess("example-mac.wk")], thisHost, resolve); expect(plan.adopt.map((x) => x.entry)).toEqual([a]); expect(plan.launch).toEqual([]); }); it("RE-LAUNCH: a gone session with a DEAD pid → launch (permanent re-launch from the catalog)", () => { const a = entry({ identity: "wk", role: "worker", strategy: "permanent" }); - const dead = sess("silber.wk", { status: "exited" as never, pid: null }); + const dead = sess("example-mac.wk", { status: "exited" as never, pid: null }); const plan = reconcilePlan([a], [dead], thisHost, resolve); expect(plan.launch).toEqual([a]); expect(plan.adopt).toEqual([]); @@ -53,10 +53,10 @@ describe("reconcilePlan — desired (catalog) vs actual (sessions), host-filtere it("ADOPT-ALIVE: a session reported 'gone' but whose pid is ALIVE → adopt, never re-launch (the CPU-spike guard)", () => { const a = entry({ identity: "wk", role: "worker" }); - const goneButAlive = sess("silber.wk", { status: "vanished" as never, pid: 4321 }); // pid alive (process.kill(4321,0) — EPERM/OK) + const goneButAlive = sess("example-mac.wk", { status: "vanished" as never, pid: 4321 }); // pid alive (process.kill(4321,0) — EPERM/OK) const plan = reconcilePlan([a], [goneButAlive], thisHost, resolve); // pid 4321 likely not ours → processAlive returns true on EPERM, false on ESRCH. Use the current process pid to be deterministic: - const self = sess("silber.wk", { status: "vanished" as never, pid: process.pid }); + const self = sess("example-mac.wk", { status: "vanished" as never, pid: process.pid }); const plan2 = reconcilePlan([a], [self], thisHost, resolve); expect(plan2.adopt.map((x) => x.entry)).toEqual([a]); expect(plan2.launch).toEqual([]); @@ -65,7 +65,7 @@ describe("reconcilePlan — desired (catalog) vs actual (sessions), host-filtere it("TEARDOWN: retired, this host, WITH a live session → tear down (decommission)", () => { const a = entry({ identity: "wk", role: "worker", retired: true }); - const plan = reconcilePlan([a], [sess("silber.wk")], thisHost, resolve); + const plan = reconcilePlan([a], [sess("example-mac.wk")], thisHost, resolve); expect(plan.teardown.map((x) => x.entry)).toEqual([a]); expect(plan.launch).toEqual([]); expect(plan.adopt).toEqual([]); @@ -93,7 +93,7 @@ describe("reconcilePlan — desired (catalog) vs actual (sessions), host-filtere it("BATCH still running: adopt wins over the done guard (a running batch job is left alone, not re-run)", () => { const a = entry({ identity: "job1", role: "worker", strategy: "batch" }); - const plan = reconcilePlan([a], [sess("silber.job1")], thisHost, resolve, () => true); + const plan = reconcilePlan([a], [sess("example-mac.job1")], thisHost, resolve, () => true); expect(plan.adopt.map((x) => x.entry)).toEqual([a]); expect(plan.done).toEqual([]); expect(plan.launch).toEqual([]); @@ -107,9 +107,9 @@ describe("reconcilePlan — desired (catalog) vs actual (sessions), host-filtere }); it("ACCEPTANCE (cos's E2E shape): 2 this-host agents + 1 other-host → launch both mine, skip the other", () => { - const mine1 = entry({ identity: "a1", role: "worker", host: "silber" }); - const mine2 = entry({ identity: "a2", role: "supervisor", host: "silber" }); - const theirs = entry({ identity: "b1", role: "worker", host: "hetzner" }); + const mine1 = entry({ identity: "a1", role: "worker", host: "example-mac" }); + const mine2 = entry({ identity: "a2", role: "supervisor", host: "example-mac" }); + const theirs = entry({ identity: "b1", role: "worker", host: "example-linux" }); const plan = reconcilePlan([mine1, mine2, theirs], [], thisHost, resolve); expect(plan.launch).toEqual([mine1, mine2]); expect(plan.otherHost).toEqual([theirs]); @@ -118,7 +118,7 @@ describe("reconcilePlan — desired (catalog) vs actual (sessions), host-filtere describe("dingHealthPlan — agent-centric ding recovery (reconcile-recreates-missing/unhealthy-ding, #82)", () => { const PF = "/w/.convoy/pty.toml"; - const dingDef: PtySessionDef = { shortName: "ding", id: "silber.wk.ding", displayName: "wk-ding", command: "st ding silber.wk --identity silber.wk-claude --root /net" }; + const dingDef: PtySessionDef = { shortName: "ding", id: "example-mac.wk.ding", displayName: "wk-ding", command: "st ding example-mac.wk --identity example-mac.wk-claude --root /net" }; const declaresDing = (): PtySessionDef | null => dingDef; const declaresNoDing = (): PtySessionDef | null => null; const alive = (...pids: number[]) => (pid: number | null): boolean => pid !== null && pids.includes(pid); @@ -130,8 +130,8 @@ describe("dingHealthPlan — agent-centric ding recovery (reconcile-recreates-mi if (o.session) tags["ptyfile.session"] = o.session; return { name: o.name ?? role, cwd: null, command: "", args: [], status: (o.status ?? "running") as never, exitedAt: null, exitCode: null, pid: o.pid ?? 100, tags }; } - const harness = (o: Partial[1]> = {}): SupervisedSession => ss("agent", { name: "silber.wk", ptyfile: PF, session: "claude", pid: 100, ...o }); - const ding = (o: Partial[1]> = {}): SupervisedSession => ss("ding", { name: "silber.wk.ding", ptyfile: PF, session: "ding", pid: 200, ...o }); + const harness = (o: Partial[1]> = {}): SupervisedSession => ss("agent", { name: "example-mac.wk", ptyfile: PF, session: "claude", pid: 100, ...o }); + const ding = (o: Partial[1]> = {}): SupervisedSession => ss("ding", { name: "example-mac.wk.ding", ptyfile: PF, session: "ding", pid: 200, ...o }); it("HEALTHY: harness alive + ding session alive → no action", () => { const plan = dingHealthPlan([harness(), ding()], declaresDing, alive(100, 200)); @@ -141,7 +141,7 @@ describe("dingHealthPlan — agent-centric ding recovery (reconcile-recreates-mi it("MISSING: harness alive, NO ding session at all (killed + GC'd) → heal, staleDing=null", () => { const plan = dingHealthPlan([harness()], declaresDing, alive(100)); expect(plan).toHaveLength(1); - expect(plan[0]!.harness.name).toBe("silber.wk"); + expect(plan[0]!.harness.name).toBe("example-mac.wk"); expect(plan[0]!.staleDing).toBeNull(); expect(plan[0]!.dingDef).toBe(dingDef); }); @@ -181,7 +181,7 @@ describe("dingHealthPlan — agent-centric ding recovery (reconcile-recreates-mi }); it("harness without a ptyfile tag → skipped (nothing to replay from)", () => { - const noPf = ss("agent", { name: "silber.wk", session: "claude", pid: 100 }); // no ptyfile + const noPf = ss("agent", { name: "example-mac.wk", session: "claude", pid: 100 }); // no ptyfile const plan = dingHealthPlan([noPf], declaresDing, alive(100)); expect(plan).toEqual([]); }); diff --git a/src/spec-format.test.ts b/src/spec-format.test.ts index 0c32f15..0aef09b 100644 --- a/src/spec-format.test.ts +++ b/src/spec-format.test.ts @@ -9,7 +9,7 @@ describe("decodeSpecText — three formats, one canonical object", () => { identity = "fabric-claude" role = "worker" supervisor = "cos" -host = "silber" +host = "example-mac" workspace = "/repos/fabric" retired = false @@ -21,23 +21,23 @@ command = "exec claude" tags = { role = "agent" } [pty.agent.env] -ST_AGENT = "silber.fabric-claude" +ST_AGENT = "example-mac.fabric-claude" [pty.ding] -command = "st ding silber.fabric" +command = "st ding example-mac.fabric" `; const JSON_TEXT = JSON.stringify({ identity: "fabric-claude", role: "worker", supervisor: "cos", - host: "silber", + host: "example-mac", workspace: "/repos/fabric", retired: false, env: { CLAUDE_CONFIG_DIR: "$HOME/.claude-fabric" }, pty: { - agent: { command: "exec claude", tags: { role: "agent" }, env: { ST_AGENT: "silber.fabric-claude" } }, - ding: { command: "st ding silber.fabric" }, + agent: { command: "exec claude", tags: { role: "agent" }, env: { ST_AGENT: "example-mac.fabric-claude" } }, + ding: { command: "st ding example-mac.fabric" }, }, }); @@ -45,7 +45,7 @@ command = "st ding silber.fabric" identity "fabric-claude" role "worker" supervisor "cos" -host "silber" +host "example-mac" workspace "/repos/fabric" retired #false @@ -57,12 +57,12 @@ pty "agent" { command "exec claude" tags role="agent" env { - ST_AGENT "silber.fabric-claude" + ST_AGENT "example-mac.fabric-claude" } } pty "ding" { - command "st ding silber.fabric" + command "st ding example-mac.fabric" } `; @@ -146,19 +146,19 @@ describe("the PUBLISHED spec's own examples parse", () => { agent "fabric-claude" { role "worker" supervisor "cos" - host "silber" + host "example-mac" workspace "/repos/fabric" transport "ding" retired #false - prefix "silber.fabric" + prefix "example-mac.fabric" pty "agent" { - id "silber.fabric-claude" + id "example-mac.fabric-claude" command #"exec claude --permission-mode bypassPermissions 'cold-start: run boot ritual, then stand by'"# cwd "." tags role="agent" "st.network"="$CONVOY_NET" env { - ST_AGENT "silber.fabric-claude" + ST_AGENT "example-mac.fabric-claude" ST_ROOT "$CONVOY_NET/smalltalk" } } diff --git a/src/up-decoupling.test.ts b/src/up-decoupling.test.ts index 1a8d1ea..8b281cb 100644 --- a/src/up-decoupling.test.ts +++ b/src/up-decoupling.test.ts @@ -1,7 +1,7 @@ // THE DECISIVE TEST (Nathan mandate, convoy incident 2026-07-22) — the permanent regression guard for // the Nomad decoupling invariant: STOPPING OR CRASHING `convoy up` MUST NOT KILL ITS AGENTS. // -// The incident: a `convoy up` restart mid-cutover self-severed and took the whole hetz fleet down (exit +// The incident: a `convoy up` restart mid-cutover self-severed and took a whole host's agents down (exit // 143 across 11). The forensic question was a-vs-b: (a) a teardown MISUSE (someone ran `convoy down` / // a session kill), or (b) a real bug where the decoupling does not hold in practice. This test settles // it AND locks the answer in: it stands up real, detached agent daemons (via convoy's OWN production