From 9d6d9364af596b080f7da05f7dd77e818947ebef Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:06:11 +0800 Subject: [PATCH 01/12] docs: add steer endpoint and correct resume lifecycle diagram --- Wiki/content/docs/zh/api/overview.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Wiki/content/docs/zh/api/overview.mdx b/Wiki/content/docs/zh/api/overview.mdx index 14cd065..4e833aa 100644 --- a/Wiki/content/docs/zh/api/overview.mdx +++ b/Wiki/content/docs/zh/api/overview.mdx @@ -36,6 +36,7 @@ Agent Server 的默认地址为 `http://localhost:8124`。业务资源使用版 | GET | `/v1/runs/{run_id}/join` | 最长 300 秒等待 run | | GET | `/v1/runs/{run_id}/stream` | SSE 事件流 | | POST | `/v1/runs/{run_id}/cancel` | 请求协作式取消 | +| POST | `/v1/runs/{run_id}/steer` | durably 注入运行中输入 | | POST | `/v1/runs/{run_id}/resume` | 恢复 paused run | | POST | `/v1/runs/{run_id}/redrive` | 重驱 failed/dead-letter run | | POST | `/v1/store/batch` | 批量 Store 操作 | @@ -63,9 +64,12 @@ stateDiagram-v2 paused --> cancelled: cancel(无存活 worker) failed --> pending: redrive dead_letter --> pending: redrive - paused --> pending: resume creates new run + state "new run: pending" as resumed_pending + paused --> resumed_pending: resume creates descendant run ``` +`resume` 不会把旧 run 的 `paused` 状态改成 `pending`:旧 run 仍保持 `paused`,并通过类型化的 `superseded_by_run_id` 标记已被取代;新创建的后继 run 才从 `pending` 开始执行。 + 稳定状态值为 `pending`、`running`、`paused`、`succeeded`、`failed`、`cancelling`、`cancelled`、`timed_out`、`dead_letter`。 From f20819402e6109c2d54ae56174d60e3686c411bf Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:06:30 +0800 Subject: [PATCH 02/12] docs: add steer endpoint and correct resume lifecycle diagram --- Wiki/content/docs/en/api/overview.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Wiki/content/docs/en/api/overview.mdx b/Wiki/content/docs/en/api/overview.mdx index 1c3f28d..4c72ac4 100644 --- a/Wiki/content/docs/en/api/overview.mdx +++ b/Wiki/content/docs/en/api/overview.mdx @@ -36,6 +36,7 @@ Agent Server defaults to `http://localhost:8124`. Business resources use the ver | GET | `/v1/runs/{run_id}/join` | Wait up to 300 seconds | | GET | `/v1/runs/{run_id}/stream` | SSE event stream | | POST | `/v1/runs/{run_id}/cancel` | Request cooperative cancellation | +| POST | `/v1/runs/{run_id}/steer` | Durably inject mid-run input | | POST | `/v1/runs/{run_id}/resume` | Resume a paused run | | POST | `/v1/runs/{run_id}/redrive` | Redrive a failed/dead-letter run | | POST | `/v1/store/batch` | Batch Store operations | @@ -63,9 +64,12 @@ stateDiagram-v2 paused --> cancelled: cancel (no live worker) failed --> pending: redrive dead_letter --> pending: redrive - paused --> pending: resume creates new run + state "new run: pending" as resumed_pending + paused --> resumed_pending: resume creates descendant run ``` +`resume` does not mutate the old run from `paused` back to `pending`: the old run remains `paused` and is marked stale through the typed `superseded_by_run_id` field; the newly created descendant run is the one that starts in `pending`. + Stable status values are `pending`, `running`, `paused`, `succeeded`, `failed`, `cancelling`, `cancelled`, `timed_out`, and `dead_letter`. From b23f66edba0108fdc9ec5b8876c19df831fd2d8d Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:06:55 +0800 Subject: [PATCH 03/12] docs: move steering SDK examples before terminal completion --- Wiki/content/docs/zh/api/python-sdk.mdx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Wiki/content/docs/zh/api/python-sdk.mdx b/Wiki/content/docs/zh/api/python-sdk.mdx index 65a4e8a..4ab69a9 100644 --- a/Wiki/content/docs/zh/api/python-sdk.mdx +++ b/Wiki/content/docs/zh/api/python-sdk.mdx @@ -31,13 +31,8 @@ with LingxiGraphClient( max_model_calls=8, ) - for event in client.runs.stream(run["id"]): - print(event["sequence"], event["kind"]) - - completed = client.runs.join(run["id"], timeout=120) - print(completed["output"]) - - # Durably 注入一条运行中输入;成功只代表已 durably 写入,不代表已被图消费。 + # Run 创建后即可提交 steering;无论它仍在排队还是已经 running, + # 成功都只代表已 durably 写入,不代表图已经消费。 accepted = client.runs.steer( run["id"], kind="user_input", @@ -45,6 +40,12 @@ with LingxiGraphClient( idempotency_key="msg-123", ) print(accepted["id"], accepted["status"]) # "pending" + + for event in client.runs.stream(run["id"]): + print(event["sequence"], event["kind"]) + + completed = client.runs.join(run["id"], timeout=120) + print(completed["output"]) ``` 可用资源方法: @@ -93,8 +94,6 @@ async with AsyncLingxiGraphClient(base_url, token=token) as client: thread_id=thread["id"], input={"request": "hello"}, ) - async for event in client.runs.stream(run["id"]): - print(event) accepted = await client.runs.steer( run["id"], @@ -103,6 +102,9 @@ async with AsyncLingxiGraphClient(base_url, token=token) as client: idempotency_key="msg-123", ) print(accepted["id"], accepted["status"]) + + async for event in client.runs.stream(run["id"]): + print(event) ``` 异步资源与同步资源一一对应。不要在 async 事件循环中使用同步客户端。 From d6b4290b433e5344af5b807685361fa4f9f58890 Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:07:12 +0800 Subject: [PATCH 04/12] docs: move steering SDK examples before terminal completion --- Wiki/content/docs/en/api/python-sdk.mdx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/Wiki/content/docs/en/api/python-sdk.mdx b/Wiki/content/docs/en/api/python-sdk.mdx index 345fa68..d5f8069 100644 --- a/Wiki/content/docs/en/api/python-sdk.mdx +++ b/Wiki/content/docs/en/api/python-sdk.mdx @@ -31,14 +31,8 @@ with LingxiGraphClient( max_model_calls=8, ) - for event in client.runs.stream(run["id"]): - print(event["sequence"], event["kind"]) - - completed = client.runs.join(run["id"], timeout=120) - print(completed["output"]) - - # Durably inject mid-run input; success only means it was durably - # written -- not that the graph has processed it yet. + # Steering can be submitted immediately after creation: whether the + # run is still queued or already running, success only means durable acceptance. accepted = client.runs.steer( run["id"], kind="user_input", @@ -46,6 +40,12 @@ with LingxiGraphClient( idempotency_key="msg-123", ) print(accepted["id"], accepted["status"]) # "pending" + + for event in client.runs.stream(run["id"]): + print(event["sequence"], event["kind"]) + + completed = client.runs.join(run["id"], timeout=120) + print(completed["output"]) ``` Available resource methods: @@ -96,8 +96,6 @@ async with AsyncLingxiGraphClient(base_url, token=token) as client: thread_id=thread["id"], input={"request": "hello"}, ) - async for event in client.runs.stream(run["id"]): - print(event) accepted = await client.runs.steer( run["id"], @@ -106,6 +104,9 @@ async with AsyncLingxiGraphClient(base_url, token=token) as client: idempotency_key="msg-123", ) print(accepted["id"], accepted["status"]) + + async for event in client.runs.stream(run["id"]): + print(event) ``` Async resources mirror sync resources. Do not use the synchronous client inside an async event loop. From f2980c58edb374ae86d0d43b029a6548d1ac69e8 Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:07:58 +0800 Subject: [PATCH 05/12] docs: align api.md with final steering protocol --- docs/api.md | 65 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/docs/api.md b/docs/api.md index 860ef83..7336c31 100644 --- a/docs/api.md +++ b/docs/api.md @@ -69,16 +69,16 @@ JWT claim 派生,绝不信任调用方自报 header。 `EventBus`(进程内或 Redis)时会发布一个可选的低延迟唤醒通知供 worker 心跳循环等待, 但它不是事件流中的具名事件(不存在 `run.steer.available` 事件),也从不作为唯一来源—— worker 自身的周期性 PostgreSQL 安全点检查始终是最终保证。 -- `running`/`queued`/`cancelling` 均可接受;`paused` run 也接受durable写入。 +- `running`/`pending`/`cancelling` 均可接受;`paused` run 也接受durable写入。 - 终止态(`succeeded`/`failed`/`cancelled`/`timed_out`/`dead_letter`)对一个**新**事件 返回 HTTP 409 `run_terminal`,不会静默生成一条永远不会被消费的事件。 -- **finalization 闸门(`run_finalizing`):** 一旦某个 worker 的图执行到达终态/暂停 - 结果,该 worker 会立即(在 durably flush 最终 steering 消费、提交最终状态**之前**) - 关闭这次投递尝试的新 steering 准入——此时 Run 行可能仍读作 `running`/`cancelling`, - 但已经没有更多图安全点可以消费任何新输入了。在这个窗口内提交一个**新**事件(尚无 - 匹配 idempotency key)会返回 HTTP 409 `run_finalizing`,而不是被静默接受进一个再也 - 没人会 drain 的 channel。`paused` 不受此闸门影响——它是刻意设计支持继续接受 steering - 的(迁移到 resume 后的新 run,见下)。 +- **finalization 闸门(`run_finalizing`):** 一旦某个 worker 的图执行到达真正的终态结果, + 该 worker 会立即(在 durably flush 最终 steering 消费、提交最终状态**之前**)关闭这次 + 投递尝试的新 steering 准入——此时 Run 行可能仍读作 `running`/`cancelling`,但已经没有 + 更多图安全点可以消费任何新输入了。在这个窗口内提交一个**新**事件(尚无匹配 + idempotency key)会返回 HTTP 409 `run_finalizing`,而不是被静默接受进一个再也没人会 + drain 的 channel。`paused` 不属于这条终结路径,也不会关闭闸门;它会继续接受 steering, + 并在 resume 时把 pending steering 迁移到新的 descendant run。 - `Idempotency-Key` header 或 body 的 `idempotency_key` 二选一;同一 `(tenant, run, key)` 只会创建一条事件——**且这条幂等重放检查发生在上面所有准入判断之前,而不是之后**: 同一个 key 的重试永远返回已存在事件的当前持久化状态(`id`/`sequence` 不变),即使该 @@ -93,9 +93,9 @@ JWT claim 派生,绝不信任调用方自报 header。 Run 上所有仍处于 `pending`/`delivered` 的 steering 事件**迁移**到新 Run(保序、保留 `kind`/`payload`/`metadata`/`idempotency_key`,旧事件被标记为 `superseded`),随后由新 Run 的 worker 按正常路径投递、消费——即:paused 时提交的 steering 会在 resume 之后被新 Run -实际消费,而不是永远 pending。旧 Run 同时被标记「已被 resume 取代」;resume 之后再对 -旧 `run_id` 调 `/steer` 会返回 HTTP 409 `run_superseded`(而不是再次静默积压),提示调用方 -改为对新 Run 调用 `/steer`。 +实际消费,而不是永远 pending。旧 Run 同时通过类型化的 `superseded_by_run_id` 字段标记为 +「已被 resume 取代」;resume 之后再对旧 `run_id` 调 `/steer` 或 `/cancel` 会返回 HTTP 409 +`run_superseded`,提示调用方改为操作新 Run。 图内部通过 `runtime.has_steering`、`runtime.peek_steering()`、`runtime.drain_steering()` 在安全点(节点开始前、节点完成后、下一 superstep 前、重试前、resume 之后)读取; @@ -117,23 +117,32 @@ LingxiGraph 只保证durable delivery、顺序、去重与安全消费,"新消 或上一跳的 id`),不会被中间某一跳的 id 覆盖。`created_at` 同样保留最初 durable accepted 的时间,因此后续 `run.steer.consumed` 的 `queue_latency_seconds` 包含了全部 pause 等待时间,而不仅仅是最后一次 resume 之后的等待时间。客户端只需记住 -最初 `/steer` 返回的 `id`,即可用它在 `run.steer.accepted`/`run.steer.consumed` 事件的 -`source_event_id` 字段里找到最终归宿,无需跟踪中间产生的每一个 run_id。 +最初 `/steer` 返回的 `id`,即可用它在后续 lifecycle 事件的 `source_event_id` 字段里找到 +最终归宿,无需跟踪中间产生的每一个 run_id。 **accepted ≠ consumed。** `POST /steer` 的 202 响应只代表事件已durably写入 `run_steering_events`(`run.steer.accepted`,状态 `pending`);只有当某个安全点的图代码 -真正调用 `drain_steering()` 并且该 drain 结果被 `commit_steering_consumptions()` -durably提交后,才会出现 `run.steer.consumed`。两者严格分离:调用方不能仅凭 202 假设图 -已经看到这条输入。 - -**观测性:** worker 在消费(drain)steering 事件后写入的 `run.steer.consumed` 事件包含 -`steering_event_id`、`source_event_id`(若来自迁移,否则省略)、`sequence`、`kind`,以及 -用于定位与诊断的 `queue_latency_seconds`(从 durably accepted 到被消费的等待时长)、 -`node`(消费该事件的节点名)、`namespace`(该节点所在的 subgraph 命名空间路径)、 -`task_id`(区分同一 superstep 内并行/`Send` 任务的具体 task)。`commit_steering_ -consumptions()` 本身是幂等的:只有真正发生 `pending`/`delivered` → `consumed` 状态转换 -的事件才会生成一条 `run.steer.consumed`;worker 因网络/DB 瞬时故障重试同一批 -consumption 时,不会产生第二条重复的 lifecycle 事件。 +真正调用 `drain_steering()` 并且该 drain 结果被 +`commit_steering_consumptions_if_owned()` durably 提交后,才会出现 `run.steer.consumed`。 +两者严格分离:调用方不能仅凭 202 假设图已经看到这条输入。 + +**观测性:** steering 有三种专用 durable lifecycle 事件: + +- `run.steer.accepted`:事件被 durably 接受; +- `run.steer.consumed`:图 drain 该事件且 fenced consumption commit 成功,包含 + `steering_event_id`、`source_event_id`(若来自迁移,否则省略)、`sequence`、`kind`、 + `queue_latency_seconds`、`node`、`namespace`、`task_id`; +- `run.steer.superseded`:事件未被消费但获得明确 durable 归宿。`reason=resume_transfer` 表示 + paused run 被 resume 后旧事件被迁移到 descendant run; + `reason=unconsumed_at_final_boundary` 表示真正终态 finalization 时仍有 + `pending`/`delivered` steering。该 disposition、对应 lifecycle event 与 Run 的终态写入在 + 同一个 lease/attempt-fenced 事务中提交,因此不会出现“Run 已终结但 steering 归宿丢失”的 + 中间状态。 + +`commit_steering_consumptions_if_owned()` 本身是幂等并按 `(lease_owner, attempt)` fenced: +只有真正发生 `pending`/`delivered` → `consumed` 状态转换的事件才会生成一条 +`run.steer.consumed`;worker 因网络/DB 瞬时故障重试同一批 consumption 时,不会产生第二条 +重复 lifecycle 事件。 ## SSE 续传 @@ -170,6 +179,12 @@ with LingxiGraphClient( thread_id=thread["id"], input={"request": "reset access", "result": ""}, ) + accepted = client.runs.steer( + run["id"], + payload={"message": "switch to Spanish"}, + idempotency_key="msg-123", + ) + print(accepted["id"], accepted["status"]) for event in client.runs.stream(run["id"]): print(event) ``` From d261e6a2d5a0885e8825a5b9ac2a4684cfd820dc Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:08:41 +0800 Subject: [PATCH 06/12] docs: correct steering payload-too-large HTTP status --- Wiki/content/docs/zh/api/errors-events.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Wiki/content/docs/zh/api/errors-events.mdx b/Wiki/content/docs/zh/api/errors-events.mdx index 6498f4d..51b68fb 100644 --- a/Wiki/content/docs/zh/api/errors-events.mdx +++ b/Wiki/content/docs/zh/api/errors-events.mdx @@ -35,7 +35,7 @@ description: "处理 problem details、run 失败、事件续传与客户端重 | --- | --- | --- | | `run_terminal` | 409 | 对处于终止态(`succeeded`/`failed`/`cancelled`/`timed_out`/`dead_letter`)的 run 调用 `/steer`;不产生任何 durable 写入 | | `run_superseded` | 409 | 对已经被 `resume` 取代的旧 `run_id` 调用 `/steer` 或 `/cancel`;应改用新 run 的 `id`(见 `metadata.resumed_from_run_id`)。对 `/cancel` 而言,即使旧 run 的 `status` 仍是 `paused` 也会触发此错误——检查的是类型化的 `superseded_by_run_id` 列,而非 `status`,也不是可被用户写入的 `metadata`,因此历史 run 绝不会被误改为 `cancelled`,而其被 resume 出来的新 run 却仍在继续执行;该检查也无法通过客户端提交的 `metadata` key 伪造(详见运维文档"`superseded_by_run_id` 血缘标记"一节) | -| `payload_too_large` | 413/422 | `payload` + `metadata` 序列化后超过大小上限(32KB,或服务端事件大小上限,取两者较小值);不产生任何 durable 写入 | +| `payload_too_large` | 413 | `payload` + `metadata` 序列化后超过大小上限(32KB,或服务端事件大小上限,取两者较小值);不产生任何 durable 写入 | | `run_resume_conflict` | 409 | 并发 `resume` 调用同一个已暂停的 run;只有一个调用会成功创建新 run,其余调用都会收到该错误 | | `run_finalizing` | 409 | run 的图已经执行结束、但最终状态尚未提交(worker 正在持久化刷新并收尾的短暂窗口)时调用 `/steer`;此时已经没有任何安全点可以消费该事件,因此直接拒绝,而不是接受一条永远不会被消费的记录。`paused` 结局不受影响——对已暂停的 run 发起 `/steer` 仍然正常 | From b733ba11b8dafdeca6079d6f226ccc3819b46008 Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:09:10 +0800 Subject: [PATCH 07/12] docs: correct steering payload-too-large HTTP status --- Wiki/content/docs/en/api/errors-events.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Wiki/content/docs/en/api/errors-events.mdx b/Wiki/content/docs/en/api/errors-events.mdx index a3917f3..f1f5f76 100644 --- a/Wiki/content/docs/en/api/errors-events.mdx +++ b/Wiki/content/docs/en/api/errors-events.mdx @@ -35,7 +35,7 @@ Common stable codes include `idempotency_conflict`, `quota_exceeded`, and `join_ | --- | --- | --- | | `run_terminal` | 409 | `/steer` is called against a run in a terminal status (`succeeded`/`failed`/`cancelled`/`timed_out`/`dead_letter`); no durable write is created | | `run_superseded` | 409 | `/steer` or `/cancel` is called against an old `run_id` already superseded by a `resume`; call it against the new run's `id` instead (see `metadata.resumed_from_run_id`). For `/cancel`, this fires even though the old run's `status` is still `paused` -- the check reads the typed `superseded_by_run_id` column, not `status` and not user-writable `metadata`, so the historical run is never mutated to `cancelled` while its resumed descendant keeps running, and the check cannot be forged via a client-submitted `metadata` key (see the operations doc's "The `superseded_by_run_id` lineage marker") | -| `payload_too_large` | 413/422 | Serialized `payload` + `metadata` exceed the size cap (32KB, or the server's event size cap, whichever is smaller); no durable write is created | +| `payload_too_large` | 413 | Serialized `payload` + `metadata` exceed the size cap (32KB, or the server's event size cap, whichever is smaller); no durable write is created | | `run_resume_conflict` | 409 | Concurrent resume calls raced against the same paused run; exactly one wins and creates the new run, every other caller gets this instead | | `run_finalizing` | 409 | `/steer` is called after the run's graph has already finished executing but before its final status is committed (a short window while the worker durably flushes and finalizes); there is no safe point left to consume the event, so it is rejected rather than durably accepted with nobody to ever read it. A `paused` outcome is not affected -- steering a paused run remains normal | From 44b802274c560e15a46f7a328623e252cd3fd389 Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:10:16 +0800 Subject: [PATCH 08/12] docs: clarify terminal steering gate and remove review-history prose --- .../zh/operations/steering-operations.mdx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/Wiki/content/docs/zh/operations/steering-operations.mdx b/Wiki/content/docs/zh/operations/steering-operations.mdx index 456685a..6bf3610 100644 --- a/Wiki/content/docs/zh/operations/steering-operations.mdx +++ b/Wiki/content/docs/zh/operations/steering-operations.mdx @@ -5,14 +5,14 @@ description: "迁移上线顺序、PostgreSQL 作为真相来源、延迟、重 ## 迁移上线顺序 -运行中输入(issue #16)在基础 schema 之上新增了四个 forward-only 的 Alembic revision: +运行中输入在基础 schema 之上新增了四个 forward-only 的 Alembic revision: | Revision | 新增内容 | | --- | --- | | `0002_steering` | `run_steering_events` 表及其索引,以及读写它的 worker/server 代码路径 | | `0003_steering_source_event` | `run_steering_events` 上的 `source_event_id` 列 + 局部索引,用于跨多次 pause/resume 迁移关联同一条 steering | -| `0004_steering_closed` | `runs.steering_closed`——finalization 准入闸门(review round 6/7);详见下方"`steering_closed` finalization 闸门" | -| `0005_superseded_by_run_id` | `runs.superseded_by_run_id`——被 resume 覆盖的类型化控制面血缘标记(review round 14,BLOCKER);把它从可被用户写入的 `metadata` 中移出,使其无法被伪造。详见下方"`superseded_by_run_id` 血缘标记" | +| `0004_steering_closed` | `runs.steering_closed`——finalization 准入闸门;详见下方"`steering_closed` finalization 闸门" | +| `0005_superseded_by_run_id` | `runs.superseded_by_run_id`——被 resume 覆盖的类型化控制面血缘标记;把它从可被用户写入的 `metadata` 中移出,使其无法被伪造。详见下方"`superseded_by_run_id` 血缘标记" | 按标准 Alembic 路径应用: @@ -43,7 +43,7 @@ alembic upgrade head ## `steering_closed` finalization 闸门 -一旦 worker 的图执行到达终态/暂停结果,该 run 可能还会短暂地继续读作 +一旦 worker 的图执行到达真正的终态结果,该 run 可能还会短暂地继续读作 `running`/`cancelling`,同时 worker 把图已经消费掉的 steering durably flush 出去、 提交最终状态——此时已经没有更多图安全点可以消费任何新输入了。`runs.steering_closed` 是持有该 run 的 worker(与状态写入一样,基于自己的 `lease_owner`/`attempt` 加锁)在执行 @@ -91,12 +91,11 @@ steering 准入边界"一节。 ## `superseded_by_run_id` 血缘标记 当 `/resume` 从一个 `paused` 的 run 创建出后继 run 时,旧 run 需要一种可靠的方式来表示 -"我已经被取代了——请对新 run 执行 steer/cancel/resume"。在 `0005` 之前,这个标记存放在 +"我已经被取代了——请对新 run 执行 steer/cancel/resume"。早期实现曾把这个标记存放在 `metadata.superseded_by_run_id` 中——普通的、用户可写的 `Run` metadata。由于 `/steer`、 `/cancel` 以及并发 resume 的冲突闸门都把这个 key 当作权威状态来读取,调用方可以在创建 run 时伪造它(`POST /v1/runs` 携带 `metadata: {"superseded_by_run_id": "..."}`),从而在一个从未 -真正被 resume 过的 run 上错误触发 `409 run_superseded` / `409 run_resume_conflict` -(issue #16 PR #17 review round 14,BLOCKER)。 +真正被 resume 过的 run 上错误触发 `409 run_superseded` / `409 run_resume_conflict`。 `runs.superseded_by_run_id`(由 `0005_superseded_by_run_id` 新增)是一个类型化、可为空的列, 取代该 key 成为唯一的真相来源: @@ -108,7 +107,7 @@ steering 准入边界"一节。 `run.superseded_by_run_id`;它们都不再为此决策查询 `metadata`。 - 调用方在普通 `RunCreate` 上提交 `metadata.superseded_by_run_id` 现在对控制流完全没有 影响——这个 key 是惰性的,最多作为普通用户 metadata 存储,永远不会被拷贝进类型化列。 -- 升级兼容性:升级到本 PR 之后,一个升级前就存在、恰好携带同名用户 metadata key 的历史 +- 升级兼容性:升级到当前版本之后,一个升级前就存在、恰好携带同名用户 metadata key 的历史 run,不会被重新解释为系统血缘状态——类型化列对每一条既有记录都从 `NULL` 开始,只有未来 真正发生的 resume 才会填充它。 @@ -209,11 +208,10 @@ durable 的 steering 历史,要么需要把正在进行的 run 与新二进制 - 永远不要把"回滚 schema"当作回滚策略的一部分。如果确实需要回滚一次部署,应该回滚 **二进制**、保持 schema 停在当前(更靠前)的 revision——`0002_steering`/ `0003_steering_source_event`/`0004_steering_closed`/`0005_superseded_by_run_id` 都是纯 - 新增,早于对应特性的旧 - 二进制会直接忽略多出来的表/列,不会因此启动失败; + 新增,早于对应特性的旧二进制会直接忽略多出来的表/列,不会因此启动失败; - **schema 兼容不等于语义兼容。** 旧二进制在新 schema 上不会崩溃,但它并不理解 `steering_closed` 或 `superseded_by_run_id`——它不会执行 finalization 的 steering - admission 门控,也不会执行本 PR 新增的 resume-lineage cancel/steer 拒绝逻辑。应该把 + admission 门控,也不会执行当前版本的 resume-lineage cancel/steer 拒绝逻辑。应该把 新的 steering/lineage 控制语义视为"只有当所有控制面实例都升级到新二进制后才可用", 而不是 schema 迁移一落地就生效。如果一个二进制已经真正执行过这些语义(例如某次 resume 已经写入了 `superseded_by_run_id`),把它回滚成旧版本并不会撤销这次转换—— @@ -221,5 +219,5 @@ durable 的 steering 历史,要么需要把正在进行的 run 与新二进制 quiesce 或等待 in-flight run 结束,不要假设旧二进制能保留同样的保证; - 因为没有 schema 回滚路径,`0002_steering`/`0003_steering_source_event`/ `0004_steering_closed`/`0005_superseded_by_run_id` 应该先在 staging 环境(或 CI 的 - `test_postgres_integration.py` - Postgres job)中验证过,再应用到生产环境,而不是依赖"出问题了还能撤销"。 + `test_postgres_integration.py` Postgres job)中验证过,再应用到生产环境,而不是依赖 + "出问题了还能撤销"。 From a7ea42a29059da3c617cee82ec428a567585bd39 Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:11:08 +0800 Subject: [PATCH 09/12] docs: clarify terminal steering gate and remove review-history prose --- .../en/operations/steering-operations.mdx | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/Wiki/content/docs/en/operations/steering-operations.mdx b/Wiki/content/docs/en/operations/steering-operations.mdx index 56e20c6..1644d4d 100644 --- a/Wiki/content/docs/en/operations/steering-operations.mdx +++ b/Wiki/content/docs/en/operations/steering-operations.mdx @@ -5,15 +5,14 @@ description: "Migration rollout order, PostgreSQL-as-source-of-truth, latency, r ## Migration rollout order -Mid-run steering (issue #16) ships as four forward-only Alembic revisions on top of the base -schema: +Mid-run steering ships as four forward-only Alembic revisions on top of the base schema: | Revision | Adds | | --- | --- | | `0002_steering` | `run_steering_events` table, its indexes, and the worker/server code paths that read/write it | | `0003_steering_source_event` | `source_event_id` column + partial index on `run_steering_events`, used to correlate a steering event across repeated pause/resume migrations | -| `0004_steering_closed` | `runs.steering_closed` — the atomic finalization admission gate (review round 6/7); see "The `steering_closed` finalization gate" below | -| `0005_superseded_by_run_id` | `runs.superseded_by_run_id` — the typed control-plane lineage marker for resumed runs (review round 14, BLOCKER); moves this out of user-writable `metadata` so it cannot be forged. See "The `superseded_by_run_id` lineage marker" below | +| `0004_steering_closed` | `runs.steering_closed` — the atomic finalization admission gate; see "The `steering_closed` finalization gate" below | +| `0005_superseded_by_run_id` | `runs.superseded_by_run_id` — the typed control-plane lineage marker for resumed runs; moves this out of user-writable `metadata` so it cannot be forged. See "The `superseded_by_run_id` lineage marker" below | Apply them with the standard Alembic path: @@ -47,7 +46,7 @@ run, and it must reach `0005_superseded_by_run_id`, not stop at `0002_steering`, ## The `steering_closed` finalization gate -Once a worker's graph execution reaches its terminal/paused outcome, the run may still read +Once a worker's graph execution reaches a genuinely terminal outcome, the run may still read `running`/`cancelling` for a little while longer while the worker durably flushes whatever steering the graph already drained and commits the final status — there is no more graph safe point left to consume anything new. `runs.steering_closed` is a boolean the owning worker sets @@ -96,19 +95,18 @@ still carry a `pending` steering row, as deliberate durable history, picked back `/redrive` like any other queued steering. `/redrive` only accepts `failed`/`dead_letter` runs, though — an ordinary client-initiated **cancel** is not covered by that recovery path, so steering left `pending` on a `cancelled` run has no automatic future delivery attempt today. See the -steering concept page's "Finalization and the steering -admission boundary" section for the full policy. +steering concept page's "Finalization and the steering admission boundary" section for the full +policy. ## The `superseded_by_run_id` lineage marker When `/resume` creates a descendant run from a `paused` run, the old run needs a durable way to -say "I have been superseded — steer/cancel/resume the new run instead". Before `0005`, that -marker lived in `metadata.superseded_by_run_id` — ordinary, user-writable `Run` metadata. +say "I have been superseded — steer/cancel/resume the new run instead". An earlier implementation +stored that marker in `metadata.superseded_by_run_id` — ordinary, user-writable `Run` metadata. Because `/steer`, `/cancel`, and the concurrent-resume conflict gate all treated that key as authoritative, a caller could forge it at run-creation time (`POST /v1/runs` with `metadata: {"superseded_by_run_id": "..."}`) to falsely trigger `409 run_superseded` / -`409 run_resume_conflict` on a run that was never actually resumed (issue #16 PR #17 review -round 14, BLOCKER). +`409 run_resume_conflict` on a run that was never actually resumed. `runs.superseded_by_run_id` (added by `0005_superseded_by_run_id`) is a typed, nullable column that replaces that key as the sole source of truth: @@ -123,10 +121,10 @@ that replaces that key as the sole source of truth: - A caller submitting `metadata.superseded_by_run_id` on an ordinary `RunCreate` now has no effect on control flow at all — the key is inert, stored (if at all) as plain user metadata, and never copied into the typed column. -- Upgrade compatibility: a pre-existing run from before this PR that happens to carry a user - metadata key with this exact name is never reinterpreted as system lineage after upgrading — - the typed column starts `NULL` for every existing row and is populated only by a future - resume. +- Upgrade compatibility: a pre-existing run from before the current version that happens to + carry a user metadata key with this exact name is never reinterpreted as system lineage after + upgrading — the typed column starts `NULL` for every existing row and is populated only by a + future resume. ## Idempotent replay is safe across every admission gate @@ -238,11 +236,11 @@ runs against a schema the new binaries no longer expect. Practical implications: revision simply ignore the extra table/columns and do not fail to start. - **Schema compatibility is not the same as semantic compatibility.** An older binary running against the new schema will not crash, but it also does not understand `steering_closed` or - `superseded_by_run_id` — it will not enforce the finalization admission gate or the - resume-lineage cancel/steer rejection this PR adds. Treat the new steering/lineage control - semantics as available only once every control-plane instance is running the new binary, not - the moment the schema migration lands. Rolling a binary back after it has already exercised - these semantics (e.g. after a resume has set `superseded_by_run_id`) does not un-happen that + `superseded_by_run_id` — it will not enforce the finalization admission gate or the current + resume-lineage cancel/steer rejection. Treat the new steering/lineage control semantics as + available only once every control-plane instance is running the new binary, not the moment + the schema migration lands. Rolling a binary back after it has already exercised these + semantics (e.g. after a resume has set `superseded_by_run_id`) does not un-happen that transition; the old binary simply stops enforcing it going forward. If you must roll back binaries while steering is in active use, quiesce or let in-flight runs finish first rather than assuming the old binary preserves the same guarantees. From 152f08630e05ee31b6c660f805eba30d5996aa22 Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:11:33 +0800 Subject: [PATCH 10/12] ci(docs): run documentation checks for root docs changes --- .github/workflows/docs.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 880cdf8..87107d2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,10 +6,18 @@ on: - main paths: - 'Wiki/**' + - 'docs/**' + - 'README.md' + - 'README.en.md' + - 'CHANGELOG.md' - '.github/workflows/docs.yml' pull_request: paths: - 'Wiki/**' + - 'docs/**' + - 'README.md' + - 'README.en.md' + - 'CHANGELOG.md' - '.github/workflows/docs.yml' workflow_dispatch: From 128c77d3a3a9a0ca400d0454f49db2cbc5e54614 Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:12:43 +0800 Subject: [PATCH 11/12] docs: make steering payload limit status unambiguous --- Wiki/content/docs/zh/api/threads-runs.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Wiki/content/docs/zh/api/threads-runs.mdx b/Wiki/content/docs/zh/api/threads-runs.mdx index 1cf3775..e07d0f8 100644 --- a/Wiki/content/docs/zh/api/threads-runs.mdx +++ b/Wiki/content/docs/zh/api/threads-runs.mdx @@ -189,7 +189,7 @@ steering 必须已经全部就位——不存在"新 Run 已可被执行,但 ### payload 大小限制 `payload` + `metadata` 序列化后大小受限(默认约 32KB,或服务端事件字节上限,两者先 -到者生效);超限返回 `413`/`422`,错误码 `payload_too_large`,不会产生任何 durable +到者生效);超限返回 HTTP `413`,错误码 `payload_too_large`,不会产生任何 durable 写入。JSON 值必须是可安全序列化的普通类型(字符串、数字、布尔、null、数组、对象); 不支持二进制、循环引用或不可 JSON 化的自定义类型。 From 1b2661f8846a8a8ae4c86808573002f7f2894469 Mon Sep 17 00:00:00 2001 From: leecyang <164771957+leecyang@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:13:32 +0800 Subject: [PATCH 12/12] docs: make steering payload limit status unambiguous --- Wiki/content/docs/en/api/threads-runs.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Wiki/content/docs/en/api/threads-runs.mdx b/Wiki/content/docs/en/api/threads-runs.mdx index a4358e5..844f7f0 100644 --- a/Wiki/content/docs/en/api/threads-runs.mdx +++ b/Wiki/content/docs/en/api/threads-runs.mdx @@ -198,7 +198,7 @@ events on whatever run eventually consumes it — no need to track every interme ### Payload size limits Serialized `payload` + `metadata` size is bounded (roughly 32KB by default, or the server's -event byte cap, whichever is smaller); exceeding it returns `413`/`422` with error code +event byte cap, whichever is smaller); exceeding it returns HTTP `413` with error code `payload_too_large` and creates no durable write. JSON values must be safely serializable plain types (string, number, boolean, null, array, object) — no binary data, cyclic references, or non-JSON-serializable custom types.