# pi-spark 中文使用指南

[返回 README](../README.md) · [架构与故障模型](./architecture.md) · [参与贡献](../CONTRIBUTING.md) · [安全策略](../SECURITY.md)

## 1. pi-spark 是什么

pi-spark 是一个可嵌入的、实验性的 **Agent 分布式执行引擎**。调用方提交持久 run，Control Plane 将其写入 PostgreSQL，匹配 executor ID 和标签的 Worker 主动领取任务，再通过租约、心跳与 fencing epoch 安全地提交结果。

一个 run 是当前的基本执行单元。上层应用可以通过 REST 组织自己的业务流程，也可以把 `SparkWorker` 和自定义 executor driver 直接嵌入现有服务。

| 组件 | 作用 |
| --- | --- |
| Control Plane | 持久化 run、幂等键、路由、租约、重试、取消与事件 |
| Worker | 注册能力、主动领取 run、维护租约并驱动 executor |
| Driver SDK | 定义可扩展的 executor 接口和 JSON-safe 结果边界 |
| Pi Extension | 通过 Pi 公共 API 提交任务，并把终态结果回注原会话 |
| CLI | 提交、查询、取消 run，以及查看 Worker 注册记录 |

> [!IMPORTANT]
> 项目仍处于实验阶段，首个稳定版本之前 API 可能调整。Control Plane 内置 client scope、run owner 隔离和每 Worker 独立身份，但不负责 TLS 终止与限流。跨机器流量必须经过可信 TLS/mTLS 网关或 service mesh。

## 2. 环境要求

- Node.js 24 或更高版本
- npm
- Docker 与 Docker Compose（运行 E2E 和默认本地栈时需要）
- PostgreSQL 17（不使用 Compose 时自行提供）
- Pi CLI（只有加载 Pi Extension 时需要）
- `@earendil-works/pi-coding-agent` 与模型 Provider 凭证（只有运行真实 Pi executor 的 Worker 需要）

从 npm 安装 CLI、Worker 或可嵌入的 Control Plane：

```bash
npm install --global @anrans001/pi-spark-cli
npm install @anrans001/pi-spark-worker @anrans001/pi-spark-driver-sdk
npm install @anrans001/pi-spark-control-plane
```

公开 package 仅支持 ESM。安装后可分别执行 `pi-spark --help`、
`pi-spark-worker --help` 和 `pi-spark-control-plane --help`。

从源码开发、构建和测试：

```bash
npm ci
npm run build
npm test
```

源码 checkout 完成构建后，CLI、Control Plane、Worker 和 Extension 的入口位于各 package 的 `dist` 目录。

## 3. Docker 快速上手

### 3.1 运行完整 E2E

```bash
npm run test:docker
npm run docker:down
```

测试栈包含：

- PostgreSQL
- Control Plane
- 标签为 `slot=a` 的 `worker-a`
- 标签为 `slot=b` 的 `worker-b`
- E2E verifier

`npm run docker:down` 会删除当前 checkout 专属的 PostgreSQL 与凭证 volume、测试栈 run 历史以及 `.cache/docker-auth` 中生成的本地凭证。

`test:docker` 会先重建专用 Compose 数据库 volume，再在缺失时于 `.cache/docker-auth` 生成随机、本地专用的注册表和 token 文件；已有完整有效 bundle 时会直接复用。随后它会分别验证全新数据库和从 legacy v1 schema 到当前 schema 的真实升级，包括匿名访问、角色隔离、Worker 冒充防护、旧数据 owner 回填、全局幂等约束迁移、run owner 隔离以及原有调度可靠性。claim 测试会在上游 `200` 已提交后从 TCP 边界主动丢弃响应，并验证并发重放、参数冲突与 stale fencing。

### 3.2 保留服务进行手动测试

```bash
npm run docker:up

export PI_SPARK_CLIENT_TOKEN_FILE="$PWD/.cache/docker-auth/client-primary.token"
node packages/cli/dist/index.js workers
node packages/cli/dist/index.js submit \
  "hello from pi-spark" \
  --kind mock \
  --label slot=a \
  --payload '{"delayMs":300}' \
  --idempotency-key demo:hello:001 \
  --json
```

从输出中复制 run ID，然后查询或取消：

```bash
node packages/cli/dist/index.js status <run-id>
node packages/cli/dist/index.js cancel <run-id>
```

健康检查：

```bash
curl -sS http://127.0.0.1:3000/healthz
```

默认 Compose 只把 Control Plane 映射到宿主机的 `127.0.0.1:3000`。

`docker:auth` 默认复用完整且相互匹配的本地凭证 bundle，并拒绝覆盖残缺或无效文件。确需轮换时，先执行 `npm run docker:stop`，再执行 `npm run docker:auth -- --force` 和 `npm run docker:up`，一起重建 Control Plane 与全部 Worker 容器。

## 4. CLI 使用

全局安装后查看完整帮助：

```bash
pi-spark --help
```

源码 checkout 中也可以直接运行：

```bash
node packages/cli/dist/index.js --help
```

支持四个命令：

```text
submit
status
cancel
workers
```

### 4.1 提交任务

```bash
node packages/cli/dist/index.js submit <task> [options]
```

| 参数 | 说明 |
| --- | --- |
| `--kind <executor-id>` | executor ID，CLI 默认 `mock` |
| `--payload <json>` | 额外 payload，必须是 JSON 对象 |
| `--label <key=value>` | Worker 必须匹配的标签；可重复，也可用逗号分隔 |
| `--max-attempts <n>` | 最大尝试次数，默认 3，协议上限 20 |
| `--idempotency-key <key>` | 安全重试提交所用的稳定 key |
| `--parent-run-id <uuid>` | 可选的父 run 关联字段 |
| `--parent-session-ref <ref>` | 可选的上游 session 引用 |
| `--url <url>` | 指定 Control Plane 地址 |
| `--json` | 输出机器可读 JSON |

`executor-id` 是开放的路由键，例如 `mock`、`pi`、`codex` 或 `acme/code-review.v1`。ID 最长 128 个字符，只能使用小写字母、数字、`.`、`_`、`/`、`-`，且首尾必须是字母或数字。

完整示例：

```bash
node packages/cli/dist/index.js submit \
  "analyze this task" \
  --kind mock \
  --payload '{"delayMs":1000,"failAttempts":1}' \
  --label slot=a \
  --max-attempts 3 \
  --idempotency-key demo:analysis:001 \
  --json
```

提交语义：

- 未指定 `--idempotency-key` 时，CLI 每次生成新 key，因此每次运行都会创建新的 run。
- 同一 key 搭配相同请求会返回已有 run；同一 key 搭配不同请求会返回 HTTP 409。
- CLI 会用命令行中的 `<task>` 覆盖 `--payload` 内的 `prompt`。
- `parentRunId` 是供上层应用使用的关联字段，不会改变单个 run 的执行语义。

### 4.2 地址解析顺序

CLI 按以下优先级选择 Control Plane 地址：

```text
--url
PI_SPARK_URL
CONTROL_PLANE_URL
http://127.0.0.1:3000
```

Worker 读取 `CONTROL_PLANE_URL`。

CLI 的每次业务请求还必须配置且只能配置一种 client token 来源：

```text
PI_SPARK_CLIENT_TOKEN
PI_SPARK_CLIENT_TOKEN_FILE（绝对路径）
```

项目刻意不提供 `--token` 参数，避免凭证进入 shell history 和进程参数列表。

`docker:up` 会先在未读取凭证时构建镜像，再停止旧应用容器，只为用户请求的服务及其依赖创建 service-scoped 凭证卷。宿主脚本只通过 stdin 把所需字节交给一个短生命周期、禁网、只保留 `CAP_CHOWN` 的物化容器；物化完成后，卷目录属于 UID/GID 1000、权限为 `0500`，其中每个普通文件权限为 `0400`。业务容器只读挂载自己的卷，并继续以非 root `node` 用户、只读根文件系统、零 capability 运行。

宿主机 canonical 注册表和 token 始终是 `0600`。明文不会写进 Compose YAML、镜像层、容器环境变量、命令参数或日志；本地栈运行期间，当前 checkout 专属的 service-scoped Docker volume 中会存在第二份明文，`npm run docker:down` 会随 volume 一起删除。在同一台宿主机上，生命周期命令会串行化并发操作、校验 volume 归属 label，并把检查通过的本地 Unix/npipe Docker endpoint 固定到整个操作周期。远端 Docker daemon 会被拒绝；应在每台 Docker 宿主机本地执行生命周期命令，让远程 Worker 通过 Control Plane 协议协同。自定义凭证文件必须位于 Docker build context 之外。该交接只服务于本地 Compose 小型注册表，大小上限为 64 KiB；生产环境应使用 Swarm、Kubernetes projected/CSI secret 或其他编排平台原生 secret。若要轮换本地 bundle，依次执行 `npm run docker:stop`、`npm run docker:auth -- --force`、`npm run docker:up`；后者会先替换全部凭证卷，再重建 Control Plane 与 Worker，避免新旧凭证混跑。

## 5. 运行双 Worker 路由示例

[two-worker-routing](../examples/two-worker-routing) 展示其他项目如何直接调用 REST API：等待两个 Worker、并发提交任务，然后验证标签路由结果。

```bash
npm run docker:up
PI_SPARK_CLIENT_TOKEN_FILE="$PWD/.cache/docker-auth/client-primary.token" \
  npm run example:two-worker-routing
npm run docker:down
```

也可以让示例连接另一个 Control Plane：

```bash
PI_SPARK_URL=http://127.0.0.1:3000 \
PI_SPARK_CLIENT_TOKEN_FILE=/absolute/path/to/client.token \
EXAMPLE_TIMEOUT_MS=60000 \
  npm run example:two-worker-routing
```

目标 Control Plane 需要已经注册名为 `worker-a`（`slot=a`）和 `worker-b`（`slot=b`）的 Worker。

## 6. 通过 REST API 集成

提交一个 run：

```bash
(
  set -eu
  umask 077
  curl_header="$(mktemp)"
  trap 'rm -f "$curl_header"' EXIT
  {
    printf '%s' 'authorization: Bearer '
    cat "$PWD/.cache/docker-auth/client-primary.token"
    printf '\n'
  } >"$curl_header"

  curl -sS -X POST http://127.0.0.1:3000/v1/runs \
    --header "@$curl_header" \
    -H 'content-type: application/json' \
    -d '{
      "idempotencyKey": "demo:api:001",
      "task": {
        "kind": "mock",
        "payload": {
          "prompt": "hello",
          "delayMs": 100
        }
      },
      "placement": {
        "labels": {
          "slot": "a"
        }
      },
      "maxAttempts": 3
    }'
)
```

子 shell 不会把 bearer 留在导出的环境变量或 `curl` 参数中；退出时，`EXIT` trap 会删除权限为 `0600` 的临时 header 文件。

新建 run 返回 HTTP 201；完全相同的幂等请求返回已有 run 和 HTTP 200。

面向业务调用方的接口：

| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/healthz` | 数据库就绪检查 |
| `POST` | `/v1/runs` | 提交 run |
| `GET` | `/v1/runs?limit=100` | 列出最近 run，`limit` 范围 1–500 |
| `GET` | `/v1/runs/:id` | 查询 run |
| `GET` | `/v1/runs/:id/events` | 查询持久事件 |
| `POST` | `/v1/runs/:id/retry` | 手工重试 `FAILED` run |
| `POST` | `/v1/runs/:id/cancel` | 请求取消未终态 run |
| `GET` | `/v1/workers` | 列出 Worker 注册记录 |

以下接口属于 Worker 调度协议，不应由普通业务客户端调用：

```text
POST /v1/workers/register
PUT  /v1/workers/claim-requests/:claimRequestId
POST /v1/runs/:id/heartbeat
POST /v1/runs/:id/complete
POST /v1/runs/:id/fail
```

`claimRequestId` 由 Worker 为每个 claim slot 生成 UUID。网络异常、可重试 HTTP 错误和空队列 `204` 都继续使用同一个 ID；claim 返回且首次 heartbeat 确认仍持有 lease 后才生成下一个 ID 并启动 driver。Control Plane 会把成功 claim 的请求指纹、run 和 lease epoch 原子写入数据库；响应丢失后的重放只返回仍然有效的同一 lease，不递增 `attempt`，也不重复写 `run.claimed`。

首次 heartbeat 的确认重试使用单调时钟，预算固定为一个 `LEASE_DURATION_MS`，不会被重放响应中的过期时间延长。确认期间的 claim 请求、heartbeat 和退避都会裁剪到剩余预算；跨越 deadline 才返回的确认也不会启动 driver。预算耗尽后 Worker 会丢弃该 claim ID，让最后一次可能已成功续期的 lease 自然过期，避免一个尚未启动 driver 的 run 被无限续租。成功 receipt 持久化在 PostgreSQL 中，但待确认 ID 只保存在 Worker 进程内；Worker 重启后不会恢复该 ID，而是让旧 lease 过期后重新调度。

旧的 `POST /v1/workers/claim` 只用于兼容旧 Worker。升级必须按 Control Plane 优先的顺序进行：先升级并确认全部 Control Plane 副本都支持新的 `PUT` 接口，再滚动升级 Worker。新 Worker 如果命中旧 Control Plane，会在 `404` 后 fail closed，绝不会回退到非幂等的旧接口。回滚时顺序相反，先回滚 Worker，再回滚 Control Plane。

### 6.1 身份、scope 与 owner

Bearer token 格式为 `ps1.<kid>.<base64url-secret>`。Control Plane 只加载 secret 的 SHA-256 摘要，不保存明文 token。client principal 可配置以下 scope：

```text
runs:submit
runs:read / runs:read:any
runs:cancel / runs:cancel:any
runs:retry / runs:retry:any
workers:read
```

没有 `:any` 的读写只作用于 token 所属 `subject` 的 run。幂等键也按 `(subject, idempotencyKey)` 隔离，因此不同应用可以安全复用同一个业务 key。`runs:submit` 必须同时配置 executor 和 placement label 策略。

生成新凭证时，让脚本把明文 token 写入权限为 `0600` 的独占文件；文件只能包含 token 本身，不能附带换行或空白。token 文件最好放在仓库和所有 Docker build context 之外，`.dockerignore` 只能作为纵深防护，不能充当 secret store。标准输出只包含可放进注册表的非明文片段：

```bash
npm run auth:create -- app-a-2026-07 /absolute/path/outside/repository/app-a.token
```

注册表结构示例：

```json
{
  "version": 1,
  "credentials": [
    {
      "id": "app-a-2026-07",
      "secretSha256": "<auth:create 输出的摘要>",
      "principal": {
        "kind": "client",
        "subject": "app-a",
        "scopes": ["runs:submit", "runs:read", "runs:cancel"],
        "submitPolicy": {
          "allowedExecutors": ["pi"],
          "requiredLabels": { "team": "app-a" },
          "allowedLabels": { "team": ["app-a"], "environment": ["dev"] }
        }
      }
    },
    {
      "id": "worker-a-2026-07",
      "secretSha256": "<另一个 auth:create 摘要>",
      "principal": {
        "kind": "worker",
        "subject": "worker-a",
        "workerId": "worker-a",
        "capabilityCeiling": {
          "executors": ["pi"],
          "labels": { "team": "app-a", "environment": "dev" },
          "maxConcurrency": 2
        }
      }
    }
  ]
}
```

Worker principal 还必须配置唯一 `workerId` 和 `capabilityCeiling`。Worker 注册的 executor 只能是上限的子集，标签必须完全一致，并发不能超过上限。多个不同 `kid` 可以指向同一个 principal，用于先加新 key、迁移进程、再删除旧 key 的平滑轮换。

### 6.2 从数据库 schema v1 或 v2 升级到当前版本

从 v1 升级时，owner migration 是 Control Plane 的停机升级，不能让新旧二进制混跑。升级前先停止全部 Control Plane 实例，再只启动一个当前版本实例执行迁移；迁移过程会锁定并重写 `runs`，同时把全局幂等唯一约束替换为按 owner 隔离的约束。迁移完成后才能启动其余当前版本实例。

历史 v1 run 会获得一个内部空 owner sentinel。认证 subject 不允许为空，因此不能把普通 client credential 配成这个 sentinel 来冒充历史 owner。读取、取消或重试这些历史 run，只能分别使用具有 `runs:read:any`、`runs:cancel:any` 或 `runs:retry:any` 的运维凭证。

已有 v2 部署升级 v3 时可以先滚动 Control Plane；schema 初始化由数据库 advisory lock 串行化。此阶段继续运行旧 Worker，等所有 Control Plane 副本升级并通过健康检查后，再滚动 Worker。不能让新 Worker 请求仍可能落到旧 Control Plane。

## 7. 嵌入自定义 Executor Driver

自定义 driver 适合把 Codex、Claude Code、内部构建器或领域任务执行器接入 pi-spark。Control Plane 只按 executor ID 做路由，不需要了解具体 agent 或工具。

先安装 Worker Runtime 与 Driver SDK：

```bash
npm install @anrans001/pi-spark-worker @anrans001/pi-spark-driver-sdk
```

完整的源码运行示例见 [custom executor worker](../examples/custom-executor)。

实现 `@anrans001/pi-spark-driver-sdk` 的 `ExecutorDriver`，再注入 `SparkWorker`：

```ts
import { ExecutorError, type ExecutorDriver } from "@anrans001/pi-spark-driver-sdk";
import { loadConfig, SparkWorker } from "@anrans001/pi-spark-worker";

type EchoPayload = { prompt: string };

const echoDriver: ExecutorDriver<EchoPayload> = {
  manifest: {
    id: "echo",
    apiVersion: 1,
    displayName: "Echo",
  },

  parsePayload(payload) {
    if (
      payload === null ||
      typeof payload !== "object" ||
      !("prompt" in payload) ||
      typeof payload.prompt !== "string"
    ) {
      throw new ExecutorError({
        code: "INVALID_PAYLOAD",
        message: "payload.prompt must be a string",
        retryable: false,
      });
    }
    return { prompt: payload.prompt };
  },

  async execute(input, context, signal) {
    if (signal.aborted) throw signal.reason;
    return {
      executor: "echo",
      workerId: context.workerId,
      prompt: input.payload.prompt,
    };
  },
};

const config = loadConfig({
  ...process.env,
  WORKER_EXECUTORS: "echo",
});
const shutdown = new AbortController();

await new SparkWorker(config, {
  drivers: [echoDriver],
  includeBuiltIns: false,
}).run(shutdown.signal);
```

接入时需要注意：

- `WORKER_EXECUTORS` 只声明当前 Worker 允许对外提供的 driver ID。
- 声明的 ID 必须已经注册；重复注册或声明未注册 ID 会让 Worker 启动失败。
- `execute` 返回值必须是 plain JSON 数据：UTF-8 编码最多 1,000,000 字节、深度最多 64、遍历值最多 100,000；循环引用、自定义原型、隐藏属性、Symbol、`BigInt` 和非有限数字都会在结算前被拒绝。
- Driver 只接收任务输入、Worker 上下文和 `AbortSignal`；租约、心跳、fencing 和结算由 Worker Runtime 管理。
- `initialize`/`dispose` 生命周期 hook 同样接收 `AbortSignal`，并受 `DRIVER_LIFECYCLE_TIMEOUT_MS` 限制；`dispose` 必须能清理只完成部分初始化的状态。
- `ExecutorError.details` 只有在 JSON-safe 且不超过 64,000 字节、深度 32、遍历值 10,000 时才会持久化，否则 Worker 会写入省略标记。
- Driver 的加载由可信部署配置决定。提交任务只能选择已注册 ID，不能要求 Worker 动态导入任意模块。
- 对外部系统产生副作用时，应使用 `input.idempotencyKey` 或等价业务 key 实现幂等与对账。

`mock` 和 `pi` 是内置 driver。嵌入时可以保留内置 driver，也可以通过 `includeBuiltIns: false` 只运行应用自己的 driver。

## 8. 在 Pi 中加载 Extension

使用 Pi 原生包管理器安装 Extension，并确保存在兼容 Worker：

```bash
pi install npm:@anrans001/pi-spark-pi-extension@0.1.0

PI_SPARK_URL=http://127.0.0.1:3000 \
PI_SPARK_CLIENT_TOKEN_FILE=/absolute/path/to/client.token \
  pi
```

项目级安装使用：

```bash
pi install npm:@anrans001/pi-spark-pi-extension@0.1.0 -l --approve
```

只在当前进程临时加载、不修改 Pi settings：

```bash
PI_SPARK_URL=http://127.0.0.1:3000 \
PI_SPARK_CLIENT_TOKEN_FILE=/absolute/path/to/client.token \
  pi -e npm:@anrans001/pi-spark-pi-extension@0.1.0
```

仓库中的 [Pi 原生 Extension Demo](../examples/pi-native-extension) 会使用
精确版本的已发布 npm 包启动隔离的交互式 Pi，只暴露三个 Spark 工具，
并连接默认本地 Docker Control Plane。它不会引用 workspace 中的 `dist`。

Extension 会注册三个工具：

```text
spark_delegate({ task, kind?, payload?, labels?, maxAttempts? })
spark_status({ runId })
spark_cancel({ runId })
```

CLI 的默认 executor 是 `mock`，`spark_delegate` 的默认 executor 是 `pi`，默认 Compose Worker 只启用 `mock`。因此测试默认栈时需要显式指定：

```text
spark_delegate({
  task: "test remote execution",
  kind: "mock",
  labels: { slot: "a" }
})
```

`spark_delegate` 会立即返回持久 run ID 并结束当前工具回合。Extension 在后台轮询，任务结束后通过 follow-up 消息回注结果；它还会在 `session_start` 恢复未完成 run 的轮询。结果交付采用 at-least-once 语义和稳定 message ID。

Extension 会在每个 session 内固定使用当时读取的 client credential，并在每次 `session_start` 重新读取环境变量或 token 文件、重建 client。轮换 client token 后，请新建或重启 Pi session；此前因 401/403 停止的未完成 run 轮询会使用新凭证恢复。

## 9. 启动真实 Pi Worker

通用 Docker image 不打包 Pi SDK 和模型凭证。请在专用 Worker image 或部署目录中安装 Worker 与可选 peer：

```bash
npm install \
  @anrans001/pi-spark-worker \
  @earendil-works/pi-coding-agent@0.80.10
```

启动示例：

```bash
CONTROL_PLANE_URL=http://127.0.0.1:3000 \
PI_SPARK_WORKER_TOKEN_FILE=/absolute/path/to/pi-worker-01.token \
WORKER_ID=pi-worker-01 \
WORKER_LABELS='region=local,team=dev' \
WORKER_EXECUTORS=pi \
WORKER_CONCURRENCY=1 \
PI_WORKSPACE_ROOT=/absolute/path/to/workspaces \
PI_ALLOWED_TOOLS=read,bash \
ANTHROPIC_API_KEY=... \
  npx pi-spark-worker
```

Pi executor payload：

```json
{
  "prompt": "检查 project-a 并总结 API",
  "cwd": "project-a",
  "tools": ["read", "bash"],
  "thinkingLevel": "high"
}
```

约束：

- `prompt` 必填。
- `tools` 必须是字符串数组，而且只能选择 `PI_ALLOWED_TOOLS` 中的子集；Worker 默认不允许任何 Pi tool。
- `cwd` 必须指向真实存在的目录。
- `PI_WORKSPACE_ROOT` 是启用 Pi executor 的必填项，`cwd` 必须位于该根目录内，符号链接不能绕过检查。
- 每个 run 使用独立的内存 Pi session。

`PI_WORKSPACE_ROOT` 只限制初始工作目录，并不是完整的进程、文件系统或网络沙箱。生产环境仍应使用独立身份、容器、只读挂载、资源配额、网络出口策略和短期凭证。

## 10. 不使用 Compose 启动服务

先安装服务 package、启动 PostgreSQL，然后显式设置环境变量。`.env.example` 是配置参考，服务不会自动加载 `.env`。

```bash
npm install @anrans001/pi-spark-control-plane @anrans001/pi-spark-worker
```

Control Plane：

```bash
DATABASE_URL=postgresql://pi_spark:pi_spark@127.0.0.1:5432/pi_spark \
PI_SPARK_AUTH_CONFIG_FILE=/absolute/path/to/pi-spark-auth-registry.json \
HOST=127.0.0.1 \
PORT=3000 \
  npx pi-spark-control-plane
```

Worker：

```bash
CONTROL_PLANE_URL=http://127.0.0.1:3000 \
PI_SPARK_WORKER_TOKEN_FILE=/absolute/path/to/worker-local.token \
WORKER_ID=worker-local \
WORKER_LABELS='region=local,team=dev' \
WORKER_EXECUTORS=mock \
WORKER_CONCURRENCY=2 \
  npx pi-spark-worker
```

Control Plane 会在启动时执行幂等 schema SQL。

## 11. 跨机器部署

Worker 使用主动 pull 模型，不需要开放 Worker 入站端口；每台 Worker 只需要能够访问 Control Plane。

```mermaid
flowchart LR
    APP["应用 / Pi Extension"] --> GW["可信私网或认证 TLS/mTLS 入口"]
    GW --> CP["Control Plane"]
    CP <--> PG[("PostgreSQL")]
    WA["机器 A · Worker"] --> GW
    WB["机器 B · Worker"] --> GW
```

部署步骤：

1. 在服务端部署 PostgreSQL 和 Control Plane。
2. 创建 client 与每台 Worker 各自的凭证，把摘要和授权策略写入 Control Plane 注册表；不要复用 Worker token。
3. 通过可信私网暴露 Control Plane；跨安全域时，在前面增加 TLS/mTLS 网关并确保其日志会脱敏 `Authorization`。
4. 为每台 Worker 配置唯一 `WORKER_ID`、token、明确标签、executor 和隔离工作区；这些值必须处于注册表的 capability ceiling 内。
5. 把 `CONTROL_PLANE_URL` 指向私网入口并启动 Worker。
6. 使用具有 `workers:read` scope 的 client token 检查注册记录，再提交带标签的测试 run。

## 12. 环境变量参考

### Control Plane

| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `DATABASE_URL` | 无 | 必填 PostgreSQL 连接串 |
| `HOST` | `127.0.0.1` | HTTP 监听地址 |
| `PORT` | `3000` | HTTP 端口 |
| `PG_POOL_SIZE` | `10` | PostgreSQL 连接池大小 |
| `REAPER_INTERVAL_MS` | `5000` | 过期租约扫描间隔；`0` 可关闭 reaper |
| `REAPER_BATCH_SIZE` | `100` | 单次回收上限 |
| `SHUTDOWN_GRACE_MS` | `10000` | 优雅退出最长等待时间 |
| `PI_SPARK_AUTH_CONFIG_FILE` | 无 | 必填；静态认证注册表的绝对路径，文件上限 1 MiB |
| `PI_SPARK_PORT` | `3000` | 仅用于 Compose 映射宿主端口 |

### Worker

| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `CONTROL_PLANE_URL` | `http://127.0.0.1:3000` | Control Plane 地址 |
| `PI_SPARK_WORKER_TOKEN` | 无 | Worker bearer token；与 `_FILE` 二选一 |
| `PI_SPARK_WORKER_TOKEN_FILE` | 无 | Worker token 文件的绝对路径；与明文变量二选一 |
| `WORKER_ID` | `worker-<pid>` | Worker 唯一 ID，最长 128 字符 |
| `WORKER_LABELS` | `{}` | JSON 字符串对象，或逗号分隔的 `k=v`；value 必须是字符串 |
| `WORKER_EXECUTORS` | `mock` | 逗号分隔的已注册 executor ID |
| `WORKER_CONCURRENCY` | `1` | 并发数，上限 64 |
| `LEASE_DURATION_MS` | `30000` | 租约时长，范围 1000–300000 |
| `HEARTBEAT_INTERVAL_MS` | 租约约 1/3 | 必须小于租约时长 |
| `POLL_INTERVAL_MS` | `500` | 无任务时的轮询间隔 |
| `RECONNECT_BACKOFF_INITIAL_MS` | `500` | Control Plane 暂时不可达时的初始退避上限；实际使用 50%–100% equal jitter，范围 100–300000 |
| `RECONNECT_BACKOFF_MAX_MS` | `30000` | Control Plane 连续失败退避上限，范围 100–300000，且不能小于初始值 |
| `REQUEST_TIMEOUT_MS` | `5000` | Control Plane 请求超时；心跳间隔加两倍请求超时必须小于租约时长，为双向网络延迟预留预算 |
| `DRIVER_LIFECYCLE_TIMEOUT_MS` | `30000` | 单个 Driver 初始化或清理 hook 的超时，上限 300000 |
| `PI_WORKSPACE_ROOT` | 无 | 启用 Pi executor 时必填的工作区边界 |
| `PI_ALLOWED_TOOLS` | 空 | Pi executor 允许任务选择的逗号分隔 tool allowlist |

### CLI 与 Extension

| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `PI_SPARK_URL` | 无 | CLI/Extension 优先使用的 Control Plane 地址 |
| `CONTROL_PLANE_URL` | 无 | CLI/Extension 的地址 fallback |
| `PI_SPARK_CLIENT_TOKEN` | 无 | client bearer token；与 `_FILE` 二选一 |
| `PI_SPARK_CLIENT_TOKEN_FILE` | 无 | client token 文件的绝对路径；与明文变量二选一 |
| `PI_SPARK_POLL_INTERVAL_MS` | `2000` | Extension 轮询间隔；有效值至少 100 ms |

Pi SDK 使用 provider 的正常凭证，例如 `ANTHROPIC_API_KEY` 或 `OPENAI_API_KEY`。凭证只应存在于需要它的 Worker。

## 13. 执行与故障恢复语义

正常执行路径涉及以下状态：

```text
READY
RUNNING
SUCCEEDED
FAILED
CANCELLED
```

关键语义：

- 同一个 owner subject 下，同一个 `idempotencyKey` 对应同一个 run；请求内容冲突时返回 HTTP 409。
- Worker crash 后，租约过期会把可重试 run 重新放回 `READY`。
- Worker 对成功的空闲轮询继续使用 `POLL_INTERVAL_MS`；网络错误和可重试 HTTP 错误使用有上限的 equal-jitter 指数退避，任一成功响应会重置连续失败次数。
- 旧 Worker 的 token/epoch 失效后不能提交结果。
- `attempt` 统计获准执行的 lease 次数；Worker 可能在 driver 启动前失败，因此它不代表外部副作用已经发生。
- 运行中取消是协作式的，executor 应响应 `AbortSignal`。
- fencing 只能阻止旧结果提交，不能撤销已经发生的外部副作用。
- 外部写操作仍需自己的幂等键、receipt 或 reconciliation。

## 14. 清理与常见问题

只停止容器并保留数据库与当前凭证卷：

```bash
npm run docker:stop
```

不要用裸 `docker compose down` 作为安全清理：生命周期脚本使用 checkout 专属的 Compose project，且裸命令默认会保留包含第二份明文的 named volume。重新执行 `npm run docker:up` 会安全替换旧凭证卷；彻底清理请使用下面的 `docker:down`。

停止并删除测试数据：

```bash
npm run docker:down
```

### run 一直停在 `READY`

通常是没有 Worker 同时满足 executor ID 和全部标签。先检查：

```bash
node packages/cli/dist/index.js workers
node packages/cli/dist/index.js status <run-id>
```

尤其注意 Extension 默认提交 `pi`，而默认 Compose Worker 只支持 `mock`。

### Worker 启动时报 executor 未注册

确认 `WORKER_EXECUTORS` 中的每个 ID 都有对应 driver，并且注入 `SparkWorker` 的 driver ID 没有重复。

### Pi executor 报缺少 SDK

请在 Worker 的实际运行目录或 image 中安装 `@earendil-works/pi-coding-agent`，并确认 `WORKER_EXECUTORS` 包含 `pi`。

### `cwd is outside PI_WORKSPACE_ROOT`

确认 payload 的 `cwd` 指向允许根目录内真实存在的目录。检查会解析符号链接，不能依赖软链接越过边界。

### Worker 出现在列表中但没有领取任务

`/v1/workers` 返回注册记录。还应核对 Worker 进程日志、executor ID、标签、Control Plane 地址和网络连通性。

### 返回 `UNAUTHENTICATED` 或 `FORBIDDEN`

`UNAUTHENTICATED` 表示 token 缺失、格式错误、key 不在当前注册表或 secret 不匹配。`FORBIDDEN` 表示凭证有效，但缺少路由所需 scope，或 Worker 的 ID、标签、executor、并发超过授权上限。注册表变更后需要重启 Control Plane。Worker 注册或 claim 遇到确定性 4xx 会作为协议/配置错误退出；408、425、429 继续按网络故障退避，`CLAIM_REQUEST_STALE` 会丢弃已失效的 claim ID 后继续，`CLAIM_REQUEST_CONFLICT` 则直接退出。

## 15. 获取帮助与参与贡献

- 使用问题和可复现缺陷可通过 GitHub Issue 提交。
- 功能建议请说明使用场景、driver 类型和期望的兼容行为。
- 安全问题不要提交公开 Issue，请按照[安全策略](../SECURITY.md)私下报告。
- 准备代码变更前，请阅读[参与贡献](../CONTRIBUTING.md)。
