docs(spikes): OpenWorkflow and Pi SDK compatibility spikes + ADR-002/003 (W0-B)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
8b90d396b2
commit
ec023a8405
16 files changed
+2884
No files matched your search
@@ -0,0 +1,112 @@
|
||||
# ADR-002:持久任务引擎采用 OpenWorkflow 0.10.1(锁版本),封装在 `TaskEngine` 端口之后
|
||||
|
||||
- 状态:已采纳(2026-09-27,W0-B spike 后)
|
||||
- 关联:REQ §11、docs/plan/00-first-version-plan.md §1 与 §3.1-8、验证记录 `docs/spikes/w0b-openworkflow.md`、代码 `spikes/openworkflow/`
|
||||
|
||||
## 决定
|
||||
|
||||
v0.1 采用 **`[email protected]`(精确锁版本)+ `postgres@^3.4.9`**,表放在同库独立 schema `openworkflow`。所有调用都经过 `packages/tasks` 的 `TaskEngine` 端口,业务代码不 import `openworkflow`。**业务状态不放在引擎里**:引擎只负责"过程推进、定时唤醒、重试、租约";下载任务状态、授权、操作记录都在业务表里。
|
||||
|
||||
暂不实现自研租约任务表。下文附录给出备选设计,只在触发复议条件时启用(REQ §11.1:"只保留清晰的执行接口,不提前实现第二套引擎")。
|
||||
|
||||
## 验证结论摘要
|
||||
|
||||
| 项 | 结果 | 对接入的约束 |
|
||||
| --- | --- | --- |
|
||||
| (a) 表初始化 | `BackendPostgres.connect()` 自动幂等迁移 | 迁移放进统一迁移流程;启动时 `runMigrations:false` |
|
||||
| (b) kill -9 恢复 | 通过。已完成步骤不重跑,正在执行的步骤会在新 worker 上**重跑**(最长等 30s 租约到期) | 步骤体必须幂等 |
|
||||
| 租约丢失 | 引擎拒收过期 worker 的步骤结果,但**步骤体仍会执行完并产生副作用**,拿不到租约状态 | 业务写入要用操作账本做 fencing |
|
||||
| (c) 信号提前到达 | **丢失**。没有等待者时 `sendSignal` 直接返回 `[]`,不落库 | 授权、下载完成都**先写业务库**,信号只用来加速唤醒 |
|
||||
| (d) 取消 | run 变成 `canceled`,后续步骤不再执行;**进行中的步骤不中断**(没有 AbortSignal);已取消的 run 仍会"接收"信号 | 长步骤自己轮询业务侧取消标志;补偿策略显式化 |
|
||||
| (e) 重试 | 步骤级 `maximumAttempts` 生效,超过后 run 变成 `failed` | 默认 10 次偏多,按任务类型显式配置 |
|
||||
| (f) 幂等键 | 同 key 返回已有 run;**24h 窗口,不比较输入** | 幂等键由服务端按语义生成,长期去重靠业务表唯一约束 |
|
||||
| (g) 版本升级 | 旧版本 run 找不到对应 version 的 worker 时无限退避等待,不会失败;同一 version 改代码按步骤名重放,不做确定性检查 | 不兼容的改动必须升 version,worker 同时注册新旧版本 |
|
||||
| (h) 与 Kysely 共库 | 不同 schema,无冲突;引擎用自己的 postgres.js 连接池,**不能和业务写入同事务入队** | outbox |
|
||||
| (i) 双 worker 并发 | 60 个 run 执行 60 次,无重复(`SKIP LOCKED`) | — |
|
||||
| PG 重启 | 自动恢复 | — |
|
||||
|
||||
## 给 `packages/tasks` 的接入要求
|
||||
|
||||
### 1. 端口(业务只依赖它)
|
||||
|
||||
```ts
|
||||
export interface TaskEngine {
|
||||
/** 幂等:同 kind+idempotencyKey 返回同一 taskRunId。调用方负责先在业务事务里写 outbox。 */
|
||||
enqueue<K extends TaskKind>(kind: K, input: TaskInput<K>, opts: { idempotencyKey: string; availableAt?: Date }): Promise<{ taskRunId: string }>;
|
||||
/** 只做唤醒提示。等待方必须以业务库为准;信号丢了也不能影响正确性。 */
|
||||
signal(topic: SignalTopic, payload?: JsonValue): Promise<{ delivered: number }>;
|
||||
/** 停止后续步骤。不撤销已完成的外部动作,也不打断进行中的步骤。 */
|
||||
cancel(taskRunId: string, reason: string): Promise<void>;
|
||||
get(taskRunId: string): Promise<TaskRunView | null>; // 状态/attempts/error/当前步骤,只读投影
|
||||
start(opts: { roles: ("worker")[]; concurrency: number }): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`TaskKind` 对应 plan §3.1-8 的九类任务。每类任务在 `packages/tasks` 里定义 `{ name, version, retryPolicy, handler }`,handler 只能通过注入的业务服务读写 Kysely。
|
||||
|
||||
### 2. 必须遵守的模式
|
||||
|
||||
1. **Outbox 入队**:业务事务里写 `outbox_event(id, kind, idempotency_key, payload, status, attempts, next_attempt_at)`;分发器轮询 `pending` 行,调用 `enqueue`(引擎幂等键 = outbox 的 idempotency_key),成功后标记 `dispatched`。重复分发由 24h 引擎幂等和业务唯一约束兜底。
|
||||
2. **等待外部条件 = 查库 + 带超时的信号**。不要只等一个信号:
|
||||
```ts
|
||||
while (true) {
|
||||
const ok = await step.run({ name: `check-approval-${i}` }, () => approvals.findValid(planId)); // 业务库是唯一真相
|
||||
if (ok) break;
|
||||
await step.waitForSignal({ name: `wait-approval-${i}`, signal: `approval:${planId}`, timeout: "5m" }); // 信号只加速
|
||||
i++;
|
||||
}
|
||||
```
|
||||
注意:同一信号名同一时间只能有一个等待步骤;步骤名要带序号(引擎也会自动加 `:1` 之类的后缀)。每轮循环都消耗 step attempt 配额(单个 run 上限 1000),长期等待用 `timeout` 拉长间隔,例如下载完成用 10m。
|
||||
3. **副作用步骤走操作账本**(REQ §11.2):`asset_operation`/`download_task` 记录 `plan → attempt(started) → result`,步骤体开头先查账本:
|
||||
- 已有 result:直接返回,不重做;
|
||||
- 有 attempt 没有 result:先对账外部状态(qB 按 hash 或管理标签查询,文件按目标路径 + 大小/hash 核验),再决定收尾还是重做;
|
||||
- 提交业务状态时带 fencing 条件,例如 `UPDATE … WHERE id=? AND attempt_token=?`。attempt_token 在步骤开始时写入,重跑的新 attempt 会覆盖它,老 worker 的提交因此失败。
|
||||
4. **取消**:`TaskEngine.cancel` = 先写业务侧 `cancel_requested`,再调用引擎 cancel。长步骤(复制大文件)分块时检查这个标志,自己中止;补偿(暂停 qB、删临时文件)作为显式步骤或单独任务。
|
||||
5. **版本纪律**:handler 有不兼容改动时 `version` +1;worker 同时注册当前和上一个版本,直到旧版本的 run 清空(用 `listWorkflowRuns({ workflowName, status })` 查,并在 `/admin` 显示)。同版本内只允许在末尾追加步骤,或插入幂等步骤,不允许改已有步骤名。
|
||||
6. **重试策略显式化**:每类任务显式给出 `retryPolicy`。外部网络类 5 次,1s→60s;文件类 3 次。用尽后 run 变成 `failed`,由 `resolveDecision`/待处理中心接管。死信视图 = `status='failed'` 的 run 加业务上下文。
|
||||
7. **展示**:UI 的业务时间线读业务表,不读 `step_attempts`,因为里面会有停在 `running` 的孤儿行。
|
||||
8. **连接**:引擎用自己的 postgres.js 连接池(同一个 DATABASE_URL);Kysely 用 pg。两边的连接数都计入 PG `max_connections`。
|
||||
|
||||
### 3. 测试要求
|
||||
|
||||
- vitest 集成测试(testcontainers 或 docker PG):kill worker 后续跑、提前信号 + 查库路径、取消、重试上限、幂等 enqueue、双 worker。
|
||||
- 故障注入(REQ §11.1):qB 接收后断连、复制完成但结果未提交、DB 断连、重复投递、租约丢失(SIGSTOP)、运行中升级。断言对象是**业务账本和文件系统**,不是引擎 `completed`。
|
||||
|
||||
## 已知风险
|
||||
|
||||
- 0.x 版本,API 仍在变(0.1→0.10 一年内发布 25 个版本)。锁精确版本,升级前重跑 spike 用例。
|
||||
- 租约固定 30s,不可配置;worker 挂掉后最长约 30s 才会续跑。
|
||||
- 步骤体拿不到 AbortSignal 和租约状态,fencing 全靠业务层。
|
||||
- 信号不持久,同名只能有一个等待者。
|
||||
- 引擎自带的 idempotency 只有 24h。
|
||||
|
||||
## 复议条件(触发后启用附录中的自研表)
|
||||
|
||||
- 升级到新版本后恢复/重试语义回归,且短期修不好;
|
||||
- 需要与业务写入同事务入队,且 outbox 延迟不可接受;
|
||||
- 需要可配置租约或步骤级取消信号,但上游不支持。
|
||||
|
||||
## 附录:备选的自研租约任务表(本版不实现)
|
||||
|
||||
```sql
|
||||
create table task (
|
||||
id uuid primary key, kind text not null, handler_version int not null,
|
||||
idempotency_key text not null, input jsonb not null, state jsonb not null default '{}', -- 步骤游标 + 已完成步骤结果
|
||||
status text not null check (status in ('pending','running','waiting','succeeded','failed','canceled','dead')),
|
||||
attempts int not null default 0, max_attempts int not null,
|
||||
next_run_at timestamptz not null default now(), lease_owner text, lease_until timestamptz, lease_token uuid,
|
||||
wait_topic text, wait_until timestamptz, cancel_requested_at timestamptz,
|
||||
last_error jsonb, dead_letter_at timestamptz, created_at timestamptz default now(), updated_at timestamptz default now(),
|
||||
unique (kind, idempotency_key)
|
||||
);
|
||||
create index on task (status, next_run_at);
|
||||
create table task_signal (id bigserial primary key, topic text not null, payload jsonb, created_at timestamptz default now(), consumed_by uuid);
|
||||
-- 信号持久化:等待方进入 waiting 前先查未消费的 task_signal,解决"提前到达"
|
||||
-- outbox_event 同 §2.1
|
||||
```
|
||||
|
||||
- 认领:`UPDATE task SET status='running', lease_owner=$w, lease_token=gen_random_uuid(), lease_until=now()+$lease, attempts=attempts+1 WHERE id=(SELECT id FROM task WHERE status IN('pending','waiting') AND next_run_at<=now() AND cancel_requested_at IS NULL ORDER BY priority, next_run_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`。
|
||||
- 心跳:`UPDATE … SET lease_until=… WHERE id=$id AND lease_token=$tok`,影响 0 行说明租约已丢,立即中止并禁止提交。所有业务提交都带 `lease_token` 条件,在同一事务里完成。
|
||||
- 恢复算法:扫描 `status='running' AND lease_until<now()` 的行,`attempts<max_attempts` 时回到 `pending`(next_run_at 按退避计算),否则置为 `dead`(dead_letter_at=now());handler 按 `state` 里的步骤游标续跑,已完成步骤直接取结果。
|
||||
- 版本:`handler_version` 不匹配的任务只由声明兼容该版本的 worker 认领。
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADR-003:Pi 接入采用 pi-agent-core `AgentHarness`,封装在 `AgentRuntimePort` 之后
|
||||
|
||||
- 状态:已采纳(2026-09-27,W0-B spike 后)
|
||||
- 关联:REQ §10、§11.4;plan §1、§3.1-9;验证记录 `docs/spikes/w0b-pi.md`;代码 `spikes/pi/`
|
||||
|
||||
## 决定
|
||||
|
||||
1. 依赖锁精确版本:`@earendil-works/[email protected]`、`@earendil-works/[email protected]`,另加它们要求的 `[email protected]`。**不引入** `@earendil-works/pi-coding-agent`,因为它的 ResourceLoader 会做隐式发现、自带 bash/read 工具,并依赖 TUI 和 jiti 扩展加载。
|
||||
2. 使用 core 的 **`AgentHarness` / `AgentLane`**。它就是 0.87.1 里 REQ §10.1 所说的 AgentSession 层:持久会话、事件流、压缩、abort/resume、工具重放策略都在这一层;而且只使用显式传入的 tools/resources/systemPrompt,不读取任何目录。
|
||||
3. 所有 SDK 类型只出现在 `packages/agent/src/pi/*` 里,对外只暴露 `AgentRuntimePort`。
|
||||
4. 测试统一使用 pi-ai 自带的 `fauxProvider`,配合 `FauxResponseFactory` 按 transcript 决策;生产中的上游用 `createProvider` 显式构造,不依赖环境变量。
|
||||
|
||||
## AgentRuntimePort 草案
|
||||
|
||||
```ts
|
||||
export interface AgentRunSpec {
|
||||
agentRunId: string; // = agent_run.id,同时作为 pi session id
|
||||
goal: string; // 用户目标或事件派生的有界任务
|
||||
scope: { actorId: string; scopeIds: string[]; grantIds: string[] };
|
||||
capabilities: CapabilityId[]; // 本次允许的能力白名单(能力注册中心解析)
|
||||
model: { upstreamId: string; modelId: string; thinkingLevel: ThinkingLevel };
|
||||
budget: { maxTurns: number; maxTokens: number; maxCostUsd?: number; deadlineAt: Date };
|
||||
compaction: { enabled: boolean; reserveTokens: number; keepRecentTokens: number };
|
||||
evidenceRefs: string[];
|
||||
}
|
||||
|
||||
export type AgentRunEvent =
|
||||
| { type: "text_delta"; seq: number; delta: string }
|
||||
| { type: "tool_started"; seq: number; capabilityId: string; invocationId: string; args: unknown }
|
||||
| { type: "tool_finished"; seq: number; capabilityId: string; invocationId: string; isError: boolean; summary: string; recovery?: true }
|
||||
| { type: "usage"; seq: number; input: number; output: number; costUsd: number }
|
||||
| { type: "compaction"; seq: number; reason: "manual" | "threshold" | "overflow"; status: string }
|
||||
| { type: "run_finished"; seq: number; status: "completed" | "aborted" | "failed" | "budget_exceeded"; error?: string };
|
||||
|
||||
export interface AgentRuntimePort {
|
||||
createRun(spec: AgentRunSpec): Promise<{ agentRunId: string }>; // 建会话 + accept prompt,不驱动
|
||||
stream(agentRunId: string, fromSeq?: number): AsyncIterable<AgentRunEvent>; // SSE 用,事件先落库(带 seq)再推送
|
||||
drive(agentRunId: string, signal?: AbortSignal): Promise<AgentRunOutcome>; // 由持久任务调用;内部是 lane.resume / drive
|
||||
abort(agentRunId: string, reason: string): Promise<void>; // lane.abort → 工具收到 context.abortSignal
|
||||
resume(agentRunId: string): Promise<AgentRunOutcome>; // 进程重启后:AgentHarness.create 返回 open → lane.resume
|
||||
followUp(agentRunId: string, message: string): Promise<void>; // 用户纠正
|
||||
}
|
||||
```
|
||||
|
||||
实现要点:
|
||||
|
||||
- `drive/resume` 由 `packages/tasks` 的持久任务(例如 `agentRun` 任务)调用。聊天 HTTP 只做 createRun + stream,断线不影响执行(REQ §10.7)。
|
||||
- 事件监听器(`harness.events.on`)把事件写入 `agent_run_event`(seq 单调递增),SSE 按 seq 补读。
|
||||
- 预算:`usage` 事件累计,`before_request` hook 里发现超预算就 `abort`,状态记为 `budget_exceeded`,然后进入 attention。
|
||||
- 会话存储:v0.1 用 `JsonlSessionRepo`,目录固定为应用数据目录 `data/agent-sessions/`(不放在媒体目录或用户目录),`agent_run` 行记录 session id 和路径。`NodeExecutionEnv` 只当作这个目录的 FileSystem 用,**不把它的 exec/shell 暴露成工具**。PG 存储要等上游或自研 `Storage` 实现(接口约 10 个方法);格式仍是 WIP,所以不在 v0.1 做。
|
||||
- 业务真相放在 PG(`agent_run`、操作账本、能力调用记录),JSONL 只是会话转写和恢复点。REQ §10.5/§11.4 说"不能把压缩摘要当唯一状态",这里同理。
|
||||
|
||||
## 工具白名单机制(三层)
|
||||
|
||||
1. **注册层**:`AgentHarness.create({ tools })` 只传本次 `capabilities` 对应的工具;也可以用 `activeToolNames` / `lane.setActiveTools` 缩小范围。模型调用未注册的工具时,harness 合成 `Tool "x" is unavailable` 错误结果,不会执行(spike 中已验证 `bash`)。
|
||||
2. **授权层**:工具 `execute` 调能力服务。能力服务在服务端校验 `toolContext`(actorId、grants、scope)和持续/单次授权(REQ §10.3),不信任模型参数里的"已同意"。`before_tool` hook 用来记审计和做快速拒绝(`{ block: { reason } }`)。
|
||||
3. **命名映射**:能力 ID `download.submit` ↔ 工具名 `download__submit`,因为上游工具名只允许 `[a-zA-Z0-9_-]`。映射表在 `packages/agent` 里唯一定义。
|
||||
|
||||
每个能力还要声明 `replay`:只读能力(`catalog.lookup`、`inventory.query`、`task.inspect` 等)用 `"safe"`;有副作用的能力(`download.submit`、`organize.execute`、`policy.apply` 等)用 `"never"`。崩溃恢复时,副作用工具会得到"结果未知"。业务侧按服务端生成的幂等键到操作账本对账,模型重新调用时会命中去重,不会产生第二次副作用。
|
||||
|
||||
## fake provider 用法
|
||||
|
||||
```ts
|
||||
const faux = fauxProvider({ provider: "umt-fake", models: [{ id: "fake", contextWindow: 32000 }] });
|
||||
models.setProvider(faux.provider);
|
||||
faux.setResponses([
|
||||
() => fauxAssistantMessage([fauxText("先查库存"), fauxToolCall("inventory__query", { title: "Dune" })], { stopReason: "toolUse" }),
|
||||
(ctx) => /* 读 ctx.messages 里的 toolResult 决定下一步 */ fauxAssistantMessage("完成"),
|
||||
]);
|
||||
// faux.state.callCount、getPendingResponseCount();队列耗尽时返回 errorMessage "No more faux responses queued"
|
||||
```
|
||||
|
||||
`packages/agent` 提供 `createScriptedModel(steps)`,供 testkit 和 e2e 使用(plan §3.1-13,"把 X 电影 1080p 收进来")。
|
||||
|
||||
## 设置页 → SDK 映射
|
||||
|
||||
| 设置项 | SDK |
|
||||
| --- | --- |
|
||||
| 上游接入(Anthropic / OpenAI 兼容) | `createProvider({ id, auth: { apiKey: { resolve } }, models: [Model], api: anthropicMessagesApi() / openAICompletionsApi() })`;`Model.baseUrl`(Anthropic 不带 `/v1`,OpenAI 兼容带 `/v1`)、`Model.id`、`Model.compat` |
|
||||
| 模型选择 | `lane.setModel({ provider, modelId })` |
|
||||
| 思考档位 | `thinkingLevel` / `lane.setThinkingLevel`;可选值来自 `getSupportedThinkingLevels(model)` |
|
||||
| 上下文预算 | 自定义 `Model.contextWindow` / `maxTokens` |
|
||||
| 压缩策略 | `compaction: { enabled, reserveTokens, keepRecentTokens }` + `before_compaction` hook / `customInstructions`(保留目标、约束、证据引用、已完成动作、剩余事项) |
|
||||
| 重试 | `retry: { enabled, maxRetries, baseDelayMs }`、`streamOptions.timeoutMs` |
|
||||
| 子代理开关 | **SDK 不提供**。由我们实现一个 `agent.delegate` 能力:开新 lane 或新会话,继承父任务的授权上限、预算和取消状态;默认关闭 |
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 0.x 版本,存储格式 4 仍是 WIP,可能原地改变。锁精确版本,升级前重跑 `spikes/pi`。
|
||||
- 没有子代理,没有 PG 会话存储,`watchSession` 未实现。
|
||||
- Pi 不提供权限系统(REQ §10.2)。进程、容器、网络白名单要在部署层落实。
|
||||
- 模型流中途崩溃后按"合成 error + 重试"恢复,只读了规范,未实测;v0.1 的 e2e 要补上。
|
||||
- 真实上游只验证了请求形状(本地抓包),没有联网调用。
|
||||
Reference in new issue
Block a user