Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ae58638
docs(specs): materialize durable specs for runner-scoped shared HOME
johnlanni Aug 8, 2026
86c247a
feat(runner): add runner-scoped runtime home storage foundations
johnlanni Aug 8, 2026
844ea1c
Revert "docs(specs): materialize durable specs for runner-scoped shar…
johnlanni Aug 8, 2026
2ab6cfb
docs(specs): materialize corrected durable specs for shared runtime HOME
johnlanni Aug 8, 2026
1f638c4
refactor(runner): track runtime homes in storage sidecar; drop migrat…
johnlanni Aug 8, 2026
5663ed5
feat(sandbox): bind per-job scratch dirs at fixed paths
johnlanni Aug 8, 2026
371bd20
feat(runner): dispatch jobs on the runner-scoped shared HOME
johnlanni Aug 8, 2026
1cace10
test(runner): cover shared-layout dispatch, fail-closed recording, an…
johnlanni Aug 8, 2026
4be8a2e
feat(cli): report runtime usage and evict caches in runner storage re…
johnlanni Aug 8, 2026
12add09
docs(runner): document the shared runtime layout and breaking cutover
johnlanni Aug 8, 2026
77783bd
test(storage): acceptance for shared-home Go cache reuse and fresh-ro…
johnlanni Aug 8, 2026
5528b5a
test(runner): prove dispatch on a fresh root never touches old-style …
johnlanni Aug 8, 2026
d7edc31
fix(runner): harden shared-home scratch recovery and cache eviction
johnlanni Aug 8, 2026
712df9b
fix(runner): make cache eviction handle read-only Go module caches
johnlanni Aug 8, 2026
1dbcf1b
fix(runner): close eviction revalidation and dirent-type edges
johnlanni Aug 8, 2026
5d3b858
fix(runner): merge acpx agent overrides per-agent under lock in the s…
johnlanni Aug 9, 2026
fb3ae03
test(runner): deterministic acpx override peer-remove regression
johnlanni Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions docs/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ is `1.1.2`:

Save this as `~/.acpx/config.json` for the Runner service user and keep the
version under operator configuration management. The runner copies only the
selected Codex override into each job's isolated ACPX home, so this pin applies
selected Codex override into the runner-scoped shared ACPX home, so this pin applies
inside bubblewrap without copying unrelated ACPX configuration. Hosts without
npm registry access should pre-cache the exact pinned package, for example
`npm cache add @agentclientprotocol/codex-acp@1.1.2`.
Expand Down Expand Up @@ -169,7 +169,7 @@ Useful runner options:
- `--gh-config-dir <path>` selects the host GitHub CLI config directory mirrored into the sandbox. By default the runner derives it from the host GitHub CLI environment.
- `--allow-cancel=false` disables `/cancel` intake.

Runner storage is anchored at the workspace root. The runner keeps a private `.storage` directory beside the managed clones: a sidecar inventory of session runtime roots (`.sessions/<hash>`) and PROCESS workspace pools (`.process-workspaces/<hash>`), an owner lock, and first-migration backups. One canonical root has exactly one destructive owner: `runner poll` and `runner serve` hold the owner lock for the process lifetime, and a second runner started against the same root fails with "stop the old runner before starting a new one". Always stop the old runner before starting a new one against the same state and workspace pair. `runner storage reconcile` also acquires the owner lock in both modes — `--dry-run` mutates nothing but is still exclusive with a live runner; only `runner preflight` is lock-free.
Runner storage is anchored at the workspace root. The runner keeps a private `.storage` directory beside the managed clones: a sidecar inventory of every managed physical resource — runner-scoped shared runtime homes (`.runner-home/<scope-hash>/`), per-job scratch directories (`.job-scratch/<job-id>/`), and PROCESS workspace pools (`.process-workspaces/<hash>/`), plus per-session runtime roots (`.sessions/<hash>/`) on roots created by older binaries — an owner lock, and first-migration backups. One canonical root has exactly one destructive owner: `runner poll` and `runner serve` hold the owner lock for the process lifetime, and a second runner started against the same root fails with "stop the old runner before starting a new one". Always stop the old runner before starting a new one against the same state and workspace pair. `runner storage reconcile` also acquires the owner lock in both modes — `--dry-run` mutates nothing but is still exclusive with a live runner; only `runner preflight` is lock-free.

Inspect or reclaim storage explicitly through the same engine the runner uses internally:

Expand All @@ -178,10 +178,25 @@ issue-spec runner storage reconcile --state <state.json> --workspace-root <path>
issue-spec runner storage reconcile --state <state.json> --workspace-root <path> --apply
```

`--dry-run` classifies every inventoried resource (`protected`, `retired_known`, `orphan_observed`, `rejected`) and reports `would_delete` actions without mutating anything. `--apply` deletes only eligible resources, and re-validates each one against freshly reloaded runner state immediately before removal. Before the first destructive pass on a root, the raw pre-migration runner state is preserved under `.storage/backups/state-first.json`; when that backup cannot be written, deletions are skipped for the pass. The command exits non-zero when the sidecar is report-only: a sidecar bound to a different canonical root identity, or written by a newer schema version, is inventoried read-only and is never mutated or deleted.
`--dry-run` classifies every inventoried resource (`protected`, `retired_known`, `orphan_observed`, `rejected`) and reports `would_delete` actions without mutating anything. `--apply` deletes only eligible resources, and re-validates each one against freshly reloaded runner state immediately before removal. Before the first destructive pass on a root, the raw pre-migration runner state is preserved under `.storage/backups/state-first.json`; when that backup cannot be written, deletions are skipped for the pass. The command exits non-zero when the sidecar is report-only: a sidecar bound to a different canonical root identity, or written by a newer schema version, is inventoried read-only and is never mutated or deleted. When shared runtime homes exist, the report adds a `runtime:` section with one line per home — protected identity/configuration bytes, rebuildable cache bytes, and unknown bytes — plus total job-scratch bytes. `--apply --evict-caches` additionally reconciles stale job scratch and evicts only the rebuildable cache directories of each recorded home, printing the reclaimed bytes.

PROCESS pool deletion is deliberately conservative. A retired pool is removed only when its owning clone is present and inspection proves the pool empty: no active leases, ownership markers, registered worktrees, or stray files. Uncertain pools are preserved with an operator remediation diagnostic and are never force-abandoned; inspect or recover them through the owning PROCESS (for example `issue-spec workflow workspace reconcile`) before removing anything manually. Runtime and pool deletion failures defer the owning workspace from grouped workspace cleanup for that pass so remediation evidence is not destroyed.

Every dispatched job runs with two runner-owned anchors below the workspace root:

- `.runner-home/<scope-hash>/` is the persistent runtime HOME shared by all jobs of one runtime scope (hostname, issue-backend profile realm, repository, and runner identity). It holds the agent-visible `home/`, `gh/`, `xdg/`, `codex/`, and `acpx-runtime/` directories plus a `scope.json` binding that fails closed if a different scope ever resolves to the same path. Sharing one HOME keeps package and build caches (the Go module and build caches, npm caches) warm across jobs of the same scope instead of rebuilding them per session, while host credential mirrors are refreshed atomically per dispatch so concurrent jobs never observe partial files.
- `.job-scratch/<job-id>/` is the job's disposable scratch: `TMPDIR`, `GOTMPDIR`, `XDG_DATA_HOME`, and `XDG_STATE_HOME` bind to fixed paths below `/tmp/issue-spec-scratch` inside bubblewrap (explicit unsafe mode exports the host paths directly). Scratch is removed when the job reaches a terminal state — completion, failure, or cancellation — and stale scratch is reclaimed by `runner storage reconcile --apply --evict-caches`.

Storage accounting treats a home's agent identity and configuration (including `~/.ssh`, `~/.gitconfig`, and the mirrored `gh`/`codex` configuration) as protected, never as eviction targets; only rebuildable caches (`~/.cache`, `~/.npm`, `~/go/pkg/mod`) are evicted, most rebuildable first.

Upgrading to the shared runtime layout is a breaking cutover, not an in-place upgrade. A binary with the shared layout never reads for import, modifies, or deletes a root created by an older binary, and sessions created before the cutover are not resumable afterwards — their acpx records live in the old root's per-session runtimes, which the new binary does not consult. Cut over with a fresh runner root:

1. Stop the old runner and let in-flight jobs finish or cancel them.
2. Optionally archive the old root (state file, `workspaces/`, and `.storage/`) for audit.
3. Start the new binary against a fresh, empty `--state`/`--workspace-root` pair — either new paths or the same paths after the old root has been moved away.
4. Verify with `runner preflight` and one `/new` command; pre-cutover sessions do not carry over, so follow-ups start new sessions.
5. Archive or delete the old root separately once the new root is proven.

On Linux, runner dispatch uses bubblewrap by default to keep coordinator filesystem writes inside the managed session clone and that session's PROCESS workspace pool while still allowing network access for GitHub, model, and package operations. Native children share that outer boundary; bubblewrap does not create a separate sandbox per child. Install bubblewrap or set `ISSUE_SPEC_BWRAP_PATH` / `--bwrap-path` when it is not on `PATH`. If bubblewrap is unavailable or unsupported, the runner fails preflight instead of silently running without isolation.

Use `--unsafe-no-sandbox` only as an explicit operator choice, including on macOS where bubblewrap is unavailable. There is no automatic fallback from sandboxed mode:
Expand Down
21 changes: 18 additions & 3 deletions docs/runner.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ fallback,而不是运维侧的版本策略:即使主机上的 `codex` CLI
```

将该内容保存为 Runner 服务用户的 `~/.acpx/config.json`,并将版本纳入运维配置管理。
runner 只会把选中的 Codex override 复制到每个任务隔离的 ACPX home,因此该固定版本会在
runner 只会把选中的 Codex override 复制到 runner 作用域共享的 ACPX home,因此该固定版本会在
bubblewrap 内生效,而不会携带其他无关的 ACPX 配置。无法访问 npm registry 的主机应预先
缓存精确的固定版本,例如 `npm cache add @agentclientprotocol/codex-acp@1.1.2`。

Expand Down Expand Up @@ -156,7 +156,7 @@ issue-spec runner poll \
- `--gh-config-dir <path>` 选择要镜像进沙箱的宿主 GitHub CLI 配置目录。默认情况下 runner 会从宿主 GitHub CLI 环境推导。
- `--allow-cancel=false` 关闭 `/cancel` intake。

Runner 的存储以 workspace root 为锚点。runner 在受管克隆旁边维护一个私有的 `.storage` 目录:session runtime (`.sessions/<hash>`)与 PROCESS workspace pool(`.process-workspaces/<hash>`)的 sidecar 清单、owner 锁,以及首次迁移备份。一个规范化 root 只有一个具备破坏性的 owner:`runner poll` 与 `runner serve` 在进程生命周期内持有 owner 锁,在同一 root 上启动第二个 runner 会失败并提示「stop the old runner before starting a new one」。在同一 state 与 workspace 对上启动新 runner 之前,务必先停止旧 runner。`runner storage reconcile` 在两种模式下也会获取 owner 锁——`--dry-run` 不做任何修改,但仍与存活 runner 互斥;只有 `runner preflight` 不加锁。
Runner 的存储以 workspace root 为锚点。runner 在受管克隆旁边维护一个私有的 `.storage` 目录:覆盖所有受管物理资源的 sidecar 清单——runner 作用域共享 runtime home(`.runner-home/<scope-hash>/`)、按 job 划分的 scratch 目录(`.job-scratch/<job-id>/`)、PROCESS workspace pool(`.process-workspaces/<hash>/`),以及旧版本二进制创建的 root 上的 per-session runtime 根(`.sessions/<hash>/`)——以及 owner 锁与首次迁移备份。一个规范化 root 只有一个具备破坏性的 owner:`runner poll` 与 `runner serve` 在进程生命周期内持有 owner 锁,在同一 root 上启动第二个 runner 会失败并提示「stop the old runner before starting a new one」。在同一 state 与 workspace 对上启动新 runner 之前,务必先停止旧 runner。`runner storage reconcile` 在两种模式下也会获取 owner 锁——`--dry-run` 不做任何修改,但仍与存活 runner 互斥;只有 `runner preflight` 不加锁。

可以通过 runner 内部使用的同一引擎显式检查或回收存储:

Expand All @@ -165,10 +165,25 @@ issue-spec runner storage reconcile --state <state.json> --workspace-root <path>
issue-spec runner storage reconcile --state <state.json> --workspace-root <path> --apply
```

`--dry-run` 对清单中的每个资源分类(`protected`、`retired_known`、`orphan_observed`、`rejected`)并报告 `would_delete` 动作,不做任何修改。`--apply` 只删除符合条件的资源,并在删除前立即对照重新加载的 runner 状态重新校验每一项。在对某个 root 执行首次破坏性 pass 之前,原始的迁移前 runner 状态会被保存到 `.storage/backups/state-first.json`;如果该备份无法写入,本次 pass 会跳过删除。当 sidecar 处于 report-only 状态时命令以非零码退出:绑定到不同规范化 root 身份、或由更新 schema 版本写入的 sidecar 只会被只读清点,绝不会被修改或删除。
`--dry-run` 对清单中的每个资源分类(`protected`、`retired_known`、`orphan_observed`、`rejected`)并报告 `would_delete` 动作,不做任何修改。`--apply` 只删除符合条件的资源,并在删除前立即对照重新加载的 runner 状态重新校验每一项。在对某个 root 执行首次破坏性 pass 之前,原始的迁移前 runner 状态会被保存到 `.storage/backups/state-first.json`;如果该备份无法写入,本次 pass 会跳过删除。当 sidecar 处于 report-only 状态时命令以非零码退出:绑定到不同规范化 root 身份、或由更新 schema 版本写入的 sidecar 只会被只读清点,绝不会被修改或删除。当存在共享 runtime home 时,报告会增加 `runtime:` 小节:每个 home 一行——受保护的 identity/配置字节数、可重建 cache 字节数与未知字节数——以及 job scratch 总字节数。`--apply --evict-caches` 还会 reconcile 过期 job scratch,并只驱逐每个已记录 home 中可重建的 cache 目录,打印回收的字节数。

PROCESS pool 的删除刻意保持保守。仅当所属 clone 存在且 inspection 证明 pool 为空时才会删除已退役的 pool:没有活跃 lease、ownership marker、已注册 worktree 或残留文件。不确定的 pool 会被保留并给出运维处置诊断,绝不会被强制放弃;请通过所属 PROCESS(例如 `issue-spec workflow workspace reconcile`)检查或恢复后,再手动移除任何内容。runtime 与 pool 的删除失败会把所属 workspace 从本次分组 workspace 清理中 defer,以免破坏处置证据。

每个被分发的 job 都使用 workspace root 下两个由 runner 拥有的锚点:

- `.runner-home/<scope-hash>/` 是同一 runtime 作用域(hostname、issue 后端 profile realm、仓库与 runner 身份)下所有 job 共享的持久 runtime HOME。它包含 agent 可见的 `home/`、`gh/`、`xdg/`、`codex/`、`acpx-runtime/` 目录,以及一个 `scope.json` 绑定;若不同的作用域解析到同一路径,绑定会 fail-closed。共享同一个 HOME 可以让包与构建缓存(Go module 与构建缓存、npm 缓存)在同一作用域的后续 job 中保持温热,而不是每个 session 重建;宿主凭据镜像在每次分发时原子刷新,并发 job 绝不会读到写了一半的文件。
- `.job-scratch/<job-id>/` 是该 job 的一次性 scratch:`TMPDIR`、`GOTMPDIR`、`XDG_DATA_HOME` 与 `XDG_STATE_HOME` 在 bubblewrap 内绑定到 `/tmp/issue-spec-scratch` 下的固定路径(显式 unsafe 模式直接导出宿主路径)。scratch 在 job 进入终态——完成、失败或取消——时被移除;残留的 scratch 由 `runner storage reconcile --apply --evict-caches` 回收。

存储记账把 home 中的 agent identity 与配置(包括 `~/.ssh`、`~/.gitconfig` 以及镜像来的 `gh`/`codex` 配置)视为受保护对象,绝不作为驱逐目标;只有可重建的 cache(`~/.cache`、`~/.npm`、`~/go/pkg/mod`)会被驱逐,且按最容易重建的优先。

升级到共享 runtime 布局是一次破坏性切换,不是原地升级。带有共享布局的二进制绝不会读取导入、修改或删除旧版本二进制创建的 root;切换前创建的 session 在切换后不可恢复——它们的 acpx 记录保存在旧 root 的 per-session runtime 中,新二进制不会查阅。请使用全新的 runner root 完成切换:

1. 停止旧 runner,让在途 job 跑完或先取消它们。
2. 如需审计,可先行归档旧 root(state 文件、`workspaces/` 与 `.storage/`)。
3. 以全新的空 `--state`/`--workspace-root` 对启动新二进制——要么换新路径,要么先把旧 root 移走再用原路径。
4. 用 `runner preflight` 和一条 `/new` 命令验证;切换前的 session 不会迁移,后续对话请开新 session。
5. 新 root 验证通过后,再单独归档或删除旧 root。

在 Linux 上,runner 分发默认使用 bubblewrap,把 coordinator 的文件系统写入限制在受管 session clone 与该 session 的 PROCESS workspace pool 内,同时仍允许 GitHub、model 与包操作的网络访问。原生 child 共享这一外层边界;bubblewrap 不会为每个 child 创建独立 sandbox。当 bubblewrap 不在 `PATH` 上时,请安装它或设置 `ISSUE_SPEC_BWRAP_PATH` / `--bwrap-path`。若 bubblewrap 不可用或不受支持,runner 会让 preflight 失败,而不是在没有隔离的情况下静默运行。

只有作为显式的运维选择时才使用 `--unsafe-no-sandbox`;macOS 上没有 bubblewrap 时也必须显式指定,不存在从 sandbox 模式自动降级:
Expand Down
Loading