diff --git a/CHANGELOG.md b/CHANGELOG.md index d6efcbc..d3673fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,70 @@ # Changelog +## Unreleased + +### Durable mid-run steering (issue #16) + +- 新增 `POST /v1/runs/{run_id}/steer`:在 Run 执行期间(含 `paused`)durably 注入新的 + 结构化输入,不等待完成、不强制取消、也不需要先暂停。支持 `Idempotency-Key` + header/body 去重、`kind`/`payload`/`metadata` 结构化 payload、大小上限校验。 +- Python SDK(同步/异步)新增 `client.runs.steer(run_id, kind=..., payload=..., metadata=..., + idempotency_key=...)`。 +- Runtime 新增 `runtime.has_steering`、`runtime.peek_steering()`、 + `runtime.drain_steering()`;`compiled_graph.steer(run_id, ...)` 在嵌入式/无 Server + 场景下提供同一套语义,未使用时零开销、不泄漏 channel 状态。 +- Run 生命周期新增 `run.steer.accepted`、`run.steer.consumed`、`run.steer.superseded` + SSE 事件;`consumed` 事件携带 `steering_event_id`、`source_event_id`、`sequence`、 + `kind`、`queue_latency_seconds`、`node`、`namespace`、`task_id`;`superseded` 事件 + 在 resume-transfer 与 final-boundary disposition 两种场景下幂等写入,同样携带 + `sequence`/`kind`/`source_event_id`。 +- Resume 语义:`POST /v1/runs/{run_id}/resume` 在**同一原子事务**中创建新 Run 并把旧 + Run 上仍 `pending`/`delivered` 的 steering 事件迁移到新 Run(保序、保留身份);旧 + Run 之后再收到 `/steer` 或 `/cancel` 返回 `409 run_superseded`。`source_event_id` + 在跨多次 pause/resume 时始终指向客户端最初 `/steer` 收到的根 id,`created_at`/ + `queue_latency_seconds` 从最初 durable accepted 时刻起算,不因中间的 resume 跳数 + 被重置。旧 Run 是否已被 resume 取代由新增的 typed `runs.superseded_by_run_id` 列 + 记录(与该事务同时原子写入),而不是用户可写的 `metadata`,避免调用方在创建 Run + 时伪造该字段来假冒 lineage 状态。 +- Cancel 语义:`request_cancel()` 现在对每个非终态状态都有明确策略——`pending`/ + `paused`(没有存活 worker 可协作)立即变为 `cancelled`;`running` 变为 + `cancelling`,由持有该 Run 的 worker 协作完成终结;已被 resume 取代 + (`superseded_by_run_id` 已设置)的历史 Run 拒绝为 `409 run_superseded`,不会 + 假成功。`claim_run()` 的租约过期回收同样区分:过期的 `running` 恢复为 `pending`; + 过期的 `cancelling`(worker 在收到 cancel 后、终结提交前崩溃)恢复为 `cancelled`, + 避免 Run 永久卡死在 `cancelling` 并阻塞同 thread 后续任务。 +- `commit_steering_consumptions_if_owned()`(状态更新 + `run.steer.consumed` 写入) + 与 `finalize_run_with_steering_disposition_if_owned()`(终态提交 + 未消费 steering + 的 `superseded` disposition + `run.steer.superseded` 写入)均在同一事务/临界区内 + 完成,并按 `(lease_owner, attempt)` fenced:同一批 consumption 的重复提交(例如 + worker 在收到 ack 前连接断开后重试)只会产生一次 `run.steer.consumed`;旧 attempt + 在 lease 被新 worker 接管后无法再修改该 Run 的 durable 状态。`retry_run_with_event_ + if_owned()` 同样把状态转换与 `worker_retrying` 事件合并为一次原子写入,cancel 优先 + 于 stale 的 retry 结果。 +- PostgreSQL schema:新增 `run_steering_events` 表及 `runs` 表的多个新增列,共 4 个 + 纯新增、forward-only 的 Alembic revision: + - `0002_steering` — `run_steering_events` 表本身; + - `0003_steering_source_event` — `source_event_id` 列/局部索引; + - `0004_steering_closed` — `runs.steering_closed`(finalization 阶段的 steering + admission 门控); + - `0005_superseded_by_run_id` — `runs.superseded_by_run_id`(typed resume-lineage + marker,见上)。 + `alembic upgrade head` 是生产环境唯一支持的迁移路径,**必须到达 + `0005_superseded_by_run_id`**——`/steer`、`/cancel`、finalization、resume 的生产 + SQL 直接依赖 `0004`/`0005` 新增的列,只迁移到 `0003` 会导致这些代码路径在缺列的 + schema 上报 `UndefinedColumn` 错误。四个 revision 均可在部署新二进制前对存活数据库 + 提前执行(schema 层面向后兼容,旧二进制会忽略新增的 nullable 列);但 schema + 兼容不等于语义兼容——只有在控制面实例全部升级到本版本后,`steering_closed`/ + `superseded_by_run_id` 才会被读取并生效,回滚二进制会失去这些新语义(不是完整的 + 语义回滚保证),必要时应在回滚前 quiesce 或等待 in-flight Run 结束。Redis 仅用于 + 可选的低延迟通知,从不作为唯一数据源;Redis 丢失不影响最终投递。 +- 兼容性:不影响不使用 steering 的现有图与调用方;上述 schema 变更均为纯新增列/表, + 无 breaking API 变更。 +- 新增中英文文档:`Wiki/content/docs/{zh,en}/api/threads-runs.mdx`(steer API 全量 + 语义)、`python-sdk.mdx`(SDK 示例)、`errors-events.mdx`(错误码与生命周期事件)、 + 新增概念页 `Wiki/content/docs/{zh,en}/concepts/steering.mdx`、新增运维页 + `Wiki/content/docs/{zh,en}/operations/steering-operations.mdx`;`docs/api.md` + 同步更新。 + ## 2.2.0 ### Cache-first prompt optimization diff --git a/README.en.md b/README.en.md index d843ab7..6fd22d8 100644 --- a/README.en.md +++ b/README.en.md @@ -55,6 +55,7 @@ flowchart TB LingxiGraph is for product and platform teams that need to deliver Agent experiences reliably to end users. It focuses on the engineering problems that appear after a model call works in a prototype: - **Continuous user experiences** — threads, checkpoints, history, forks, resumes, and resumable SSE support multi-turn sessions, long-running work, and reconnects. +- **Durable mid-run steering** — inject new structured input into a running (or paused) run without cancelling it; delivery is durable, ordered, deduplicated, and survives worker crashes and paused-run resumes. - **Composable intelligent workflows** — state graphs, subgraphs, and supervisor, handoff, swarm, group chat, plan-and-execute, parallel-review, and map-reduce patterns organize complex requests. - **Governed production execution** — pinned graph versions, lease-based queues, idempotency keys, timeouts, budgets, quotas, dead-letter/redrive, and cooperative cancellation make retries and scaling manageable. - **Tenant-aware security boundaries** — OIDC/JWT, RBAC, tenant isolation, PostgreSQL RLS, audit records, and safe JSON state serialization provide runtime foundations for data isolation in online products. diff --git a/README.md b/README.md index 9629d9e..cab6cbd 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ StateGraph · Runtime · Checkpoint · Events - **StateGraph**:用节点和边描述 Agent 与业务执行逻辑。 - **Durable Execution**:支持 checkpoint、暂停恢复、重试与长任务执行。 +- **Durable Steering**:运行中(甚至暂停中)durably 注入新的结构化输入,无需取消 run;投递 + durable、有序、去重,并能在 worker 崩溃和 paused run resume 后依然可靠送达。 - **Streaming Events**:通过 Python Runtime、REST 与可续传 SSE 暴露运行状态。 - **Production Runtime**:提供 Agent Server、Worker、持久化、权限、审计与观测能力。 - **Open Integrations**:支持 OpenAI-compatible、Coze、A2A、MCP 与开放 Agent Skills。 diff --git a/Wiki/content/docs/en/api/errors-events.mdx b/Wiki/content/docs/en/api/errors-events.mdx index 7138d52..a3917f3 100644 --- a/Wiki/content/docs/en/api/errors-events.mdx +++ b/Wiki/content/docs/en/api/errors-events.mdx @@ -29,6 +29,16 @@ Non-2xx responses use `application/problem+json`: Common stable codes include `idempotency_conflict`, `quota_exceeded`, and `join_timeout`, plus problem codes mapped from schema, authentication, concurrency, and resource-state failures. Treat new codes as an unknown error rather than failing to parse them. +### Steering-specific error codes + +| `code` | HTTP | Triggered when | +| --- | --- | --- | +| `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 | +| `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 | + ## Run business errors Successful creation returns `202 pending`. A later node/schema/provider error moves the resource to `failed`, `timed_out`, or `dead_letter` and populates: @@ -67,6 +77,73 @@ data: {"id":"...","run_id":"...","sequence":18,"kind":"node_completed","data":{} - Lines beginning `: heartbeat` are keep-alive comments and should be ignored. - The server closes the stream when the run becomes terminal or `paused`. +### Steering lifecycle events + +Besides ordinary execution events (`node_completed`, etc.), mid-run input (steering) +produces three dedicated SSE event kinds across its lifecycle: + +| `kind` | Emitted when | Key fields | +| --- | --- | --- | +| `run.steer.accepted` | A `/steer` call is durably accepted (202) | `steering_event_id`, `source_event_id` (if from a resume migration), `sequence`, `kind` | +| `run.steer.consumed` | Graph code calls `drain_steering()` and that consumption is durably committed by `commit_steering_consumptions_if_owned()` | `steering_event_id`, `source_event_id`, `sequence`, `kind`, `queue_latency_seconds`, `node`, `namespace`, `task_id` | +| `run.steer.superseded` | A steering event's row durably transitions to the `superseded` disposition without ever being consumed — either because a paused run it belonged to was resumed (its still-pending steering is migrated onto the new run) or because it was still `pending`/`delivered` when the run reached its terminal-finalization boundary (see below) | `steering_event_id`, `source_event_id`, `sequence`, `kind`, `reason` (`resume_transfer` or `unconsumed_at_final_boundary`), `superseded_by_run_id` (set for `resume_transfer`, `null` for `unconsumed_at_final_boundary`), `replacement_steering_event_id` (set for `resume_transfer`, `null` for `unconsumed_at_final_boundary`) | + +`run.steer.superseded`'s `unconsumed_at_final_boundary` reason is a deliberately honest, +generic label — it does **not** claim the event was racing anything. It covers every steering +row still `pending`/`delivered` once the owning worker reaches a genuinely terminal +finalization boundary, and that has more than one possible cause: a `/steer` call can durably +commit its row a moment before the worker closes new admission (a real race), but just as +commonly the graph simply never called `drain_steering()` at all for a row that had been +sitting there, fully visible, for the run's entire lifetime — ordinary application-level +non-consumption, not a race. The Runtime cannot currently distinguish these two cases from the +data it has, so it does not pretend to; both get the same `unconsumed_at_final_boundary` +reason. Rather than forcing the run through an indefinite retry loop just because a steering +event happened to exist — which would penalize a graph that legitimately never calls +`drain_steering()` at all — the Runtime durably transitions the row to `superseded` and +finalizes the run with its already-computed outcome. A `run.steer.superseded` event is never +silently dropped in this case; it is always paired with the disposition change, committed +together with the run's terminal status in the same durable transaction, and is idempotent +under retry (a repeated attempt to supersede an already-superseded row emits no duplicate +event). + +Field meanings: + +- `steering_event_id`: the id of the steering event under the **current** run (if the event + was migrated from an older run, this is the fresh id it got after migration, not the id + the client originally received); +- `source_event_id`: the **root** id the client originally received from its `/steer` call. + Even across repeated pause/resume migrations of the same underlying steering, this field + stays consistent at every hop, letting a client correlate the eventual + `run.steer.consumed`/`run.steer.superseded` back to its original request. This is + per-lifecycle-event, not a blanket rule: on `run.steer.accepted` and `run.steer.consumed` it + is present only for events that arrived via a migration — a directly-submitted event (never + transferred) has no `source_event_id` on those two. `run.steer.superseded` is different: it + always sets `source_event_id` to `event.source_event_id or event.id`, so a directly-submitted + event's superseded record still carries a root id — its own — even though it was never + migrated; +- `sequence`: this event's position in its run's steering sequence (a separate counter from + the run's overall event-stream `sequence`); +- `kind`: passed through from the `/steer` request body's `kind`; +- `queue_latency_seconds`: seconds from the steering event's **original** durably-accepted + time (including time accumulated across every resume) to when it was consumed; +- `node`: the name of the node that consumed the event; +- `namespace`: the subgraph namespace path (array) the consuming node belongs to; +- `task_id`: distinguishes the specific task among parallel/`Send` tasks in the same + superstep. + +**PostgreSQL is the durable source of truth; Redis is only an optional accelerator.** All three +of `run.steer.accepted`, `run.steer.consumed`, and `run.steer.superseded` are durably written +to PostgreSQL before becoming visible anywhere else. Redis pub/sub is used only to hint +workers that "steering may be waiting," reducing polling latency. Even if that Redis +notification is lost (a dropped connection, a Redis restart), a worker still checks/polls +PostgreSQL at its next safe point and eventually consumes the event — a lost Redis +notification never permanently drops steering. Similarly, the production Worker path, +`commit_steering_consumptions_if_owned()`, is idempotent: when a worker retries the same +consumption batch after a transient failure, only events that actually transition status +produce a new `run.steer.consumed` — already-consumed events are never re-emitted. The same +holds for superseding a stale row: retrying an already-superseded transition emits no +duplicate `run.steer.superseded`. + ## Retry policy 1. Use bounded exponential backoff only for `retryable=true`, network failure, 429, and transient 5xx. diff --git a/Wiki/content/docs/en/api/overview.mdx b/Wiki/content/docs/en/api/overview.mdx index 82214a7..1c3f28d 100644 --- a/Wiki/content/docs/en/api/overview.mdx +++ b/Wiki/content/docs/en/api/overview.mdx @@ -59,6 +59,8 @@ stateDiagram-v2 running --> timed_out running --> cancelling cancelling --> cancelled + pending --> cancelled: cancel (no live worker) + paused --> cancelled: cancel (no live worker) failed --> pending: redrive dead_letter --> pending: redrive paused --> pending: resume creates new run diff --git a/Wiki/content/docs/en/api/python-sdk.mdx b/Wiki/content/docs/en/api/python-sdk.mdx index 4950c4c..345fa68 100644 --- a/Wiki/content/docs/en/api/python-sdk.mdx +++ b/Wiki/content/docs/en/api/python-sdk.mdx @@ -36,6 +36,16 @@ with LingxiGraphClient( 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. + accepted = client.runs.steer( + run["id"], + kind="user_input", + payload={"message": "switch to Spanish"}, + idempotency_key="msg-123", + ) + print(accepted["id"], accepted["status"]) # "pending" ``` Available resource methods: @@ -45,12 +55,35 @@ Available resource methods: | `graphs` | `list()`, `get(graph_id)` | | `assistants` | `create(**values)`, `list()`, `get()`, `update()`, `delete()` | | `threads` | `create()`, `list()`, `get()`, `update()`, `state()`, `history()`, `fork()`, `delete()` | -| `runs` | `create()`, `get()`, `list(thread_id)`, `join()`, `stream()`, `cancel()`, `resume()` | +| `runs` | `create()`, `get()`, `list(thread_id)`, `join()`, `stream()`, `cancel()`, `steer()`, `resume()` | | `store` | `batch(operations)`, `search(namespace, ...)` | | `schedules` | `create()`, `list()`, `update()`, `delete()` | Use `client.request(method, path, **kwargs)` for redrive or an endpoint not yet wrapped by a resource. +### `runs.steer()` + +```python +client.runs.steer( + run_id, + kind="user_input", + payload={"message": "..."}, + metadata=None, + idempotency_key="msg-123", +) +``` + +- `kind` defaults to `"user_input"` but may be any application-defined string; +- `payload` is the raw JSON the graph reads back via `runtime.drain_steering()`; +- `idempotency_key`, when set, is also sent as the `Idempotency-Key` header; a repeat call + with the same key returns the original event instead of enqueueing a second one; +- the return value is the 202 response body (`id`, `sequence`, `status`) — it does **not** + mean the graph has processed the input; only a subsequent `run.steer.consumed` event + means it was actually consumed; +- calling it against a terminal run raises `LingxiGraphAPIError` with + `code == "run_terminal"`; calling it against an old `run_id` already superseded by a + `resume` raises `code == "run_superseded"`. + ## Asynchronous client ```python @@ -65,6 +98,14 @@ async with AsyncLingxiGraphClient(base_url, token=token) as client: ) async for event in client.runs.stream(run["id"]): print(event) + + accepted = await client.runs.steer( + run["id"], + kind="user_input", + payload={"message": "switch to Spanish"}, + idempotency_key="msg-123", + ) + print(accepted["id"], accepted["status"]) ``` Async resources mirror sync resources. Do not use the synchronous client inside an async event loop. diff --git a/Wiki/content/docs/en/api/threads-runs.mdx b/Wiki/content/docs/en/api/threads-runs.mdx index 66bf26c..a4358e5 100644 --- a/Wiki/content/docs/en/api/threads-runs.mdx +++ b/Wiki/content/docs/en/api/threads-runs.mdx @@ -79,9 +79,143 @@ POST /v1/runs/{run_id}/redrive ``` - Cancellation is cooperative and usually returns `cancelling` first; nodes and providers should honor the cancellation token. +- Cancelling a `pending` (not yet claimed) or `paused` (parked on `interrupt`, no live worker holding it) run resolves to `cancelled` immediately, with `finished_at` set — there is no live delivery attempt for either status to cooperatively observe a token, so there is nothing to wait on. +- Cancelling a `paused` run that has since been `resume`d returns `409 run_superseded` instead — the same signal `/steer` gives for a stale `run_id` (see below). The old run's `status` deliberately stays `paused` after a resume (only the typed `superseded_by_run_id` column marks it stale — never user-writable `metadata`, which cannot forge this signal), so cancel checks that same field, atomically, before deciding the status transition; it never silently succeeds against the historical run while the resumed descendant keeps executing. Cancel the new `run_id` instead. - Only a `paused` run can resume. The response is a new run whose `metadata.resumed_from_run_id` points to the original. +- Concurrent resume calls against the same paused run have exactly one winner: it creates the new run as above, and every other concurrent caller gets `409 run_resume_conflict` instead of a duplicate run. - Only `failed` or `dead_letter` runs can redrive. Redrive preserves input/config/version and re-enqueues the run. Redrive does not mean “retry with edited input.” When business input or graph version changes, create a new run with a new idempotency key. + +## Mid-run input (Steering) + +`POST /v1/runs/{run_id}/steer` durably injects a new piece of structured input while a run +executes — without waiting for it to finish, without forcing a cancel, and without needing +the run to be paused first. This is distinct from both `cancel` (terminate execution) and +`interrupt` (graph code voluntarily yielding, waiting for `resume`): steering only safely +delivers a new message into a run that is running (or about to run, or just paused). Whether, +when, and how it changes the plan is entirely up to the graph code. + +```bash +curl -X POST https://agents.example.com/v1/runs/$RUN_ID/steer \ + -H "Authorization: Bearer $TOKEN" \ + -H "Idempotency-Key: msg-123" \ + -H "Content-Type: application/json" \ + -d '{"kind":"user_input","payload":{"message":"switch to Spanish"},"metadata":{}}' +``` + +202 response: + +```json +{ + "id": "steer_01H...", + "run_id": "run_01H...", + "sequence": 3, + "kind": "user_input", + "status": "pending", + "created_at": "2026-08-18T09:00:00Z" +} +``` + +Request body fields: + +| Field | Required | Description | +| --- | --- | --- | +| `kind` | yes | Application-defined event kind, e.g. `user_input`, `priority_change` | +| `payload` | yes | Arbitrary JSON; this is exactly what graph code reads back from `drain_steering()` | +| `metadata` | no | Extra metadata, not part of dedup | +| `idempotency_key` | no | See below | + +**`Idempotency-Key` header vs. body field.** Both express the same logical key and either +may be used; when both are present the `Idempotency-Key` header wins. The same +`(tenant_id, run_id, idempotency_key)` triple creates at most one steering event — a repeat +call returns the **same** event (`id` and `sequence` unchanged, no new row created and no +`sequence` bump), not a `409`. The response body is *not* guaranteed to be byte-identical to +the first call's, though: it reflects that event's **current persisted status**, which can +have moved on between calls (e.g. `pending` on the first call, `consumed` on a later replay +after the graph has since drained it). This mirrors the `Idempotency-Key` semantics on +`POST /runs`: same key + same request is a safe replay, not an error — but a replay always +returns the live state of the one underlying resource, not a frozen snapshot of the first +response. Crucially, this replay lookup happens **before** any of the run-status admission +checks below, not after: a same-key retry always returns the existing event, even if the run +has since moved into `run_finalizing`, gone terminal, or been superseded. Only a genuinely new +key is subject to those checks. + +### Behavior by run status + +| Run status | `/steer` behavior | +| --- | --- | +| `pending` (queued, not yet claimed) | 202, durably written; visible as soon as a worker claims the run | +| `running` | 202, durably written; visible at the next safe point | +| `cancelling` | 202, still accepted (cancel still wins overall — see below — but accepting the write itself is unaffected) | +| `running`/`cancelling`, but finalizing (`runs.steering_closed`) | `409 run_finalizing` for a genuinely new event; a same-key replay still returns the existing event, not a 409 — the graph has already finished executing and there is no further safe point in this delivery attempt, but the run row has not yet flipped to a terminal/paused status | +| `paused` (waiting on `interrupt`/`resume`) | 202, durably written; see "paused steering transfer" below | +| `succeeded` / `failed` / `cancelled` / `timed_out` / `dead_letter` (terminal) | `409 run_terminal`, no event is created for a new key | +| An old `run_id` already superseded by a `resume` | `409 run_superseded`, see below | + +### Cancel always wins over steer + +Regardless of arrival order, a cancel request always takes effect: a run that receives a +`/steer` and is then `/cancel`led still moves to `cancelling` (or, for a `paused` run, +straight to `cancelled` — see above) and stops executing. The already-durably-written +steering event is not rolled back — it simply is never consumed by any surviving execution. +Steer can never undo or delay a cancel that has already been issued. + +### Paused steering transfers to the new run inside the atomic resume transaction + +`POST /v1/runs/{run_id}/resume` creates a **new** run (a new `run_id`, linked to the old one +via `metadata.resumed_from_run_id`). Because a worker only pulls pending steering for the +`run_id` it is actually executing, the resume endpoint does two things in a **single atomic +transaction**: it creates the new run, and it **migrates** every still-`pending`/`delivered` +steering event from the old run onto the new one (preserving order, `kind`, `payload`, +`metadata`, `idempotency_key`; the old events are marked `superseded`). The atomicity matters: +before the new run can be claimed by any worker, its migrated steering must already be fully +in place — there is no intermediate window where the new run is claimable but its migration +has not finished yet. From there the new run's worker delivers and consumes it through the +normal path: steering submitted while paused ends up actually consumed by the resumed run, +instead of sitting `pending` forever. + +The old run is also marked as superseded by the resume. A subsequent `/steer` call against +the old `run_id` returns HTTP `409 run_superseded` (instead of silently queuing an event +nobody will ever consume), telling the caller to steer the new run instead. + +### `source_event_id`: stable correlation across repeated resumes + +A steering event migrated onto a new run gets a brand-new `id` (it is a fresh durable row +under the new run), but its `source_event_id` field points back at the `id` the client +originally received from its `/steer` call. Even if a run is paused and resumed repeatedly +(A → B → C → …), every hop of the migration preserves the **original** root id — it is never +overwritten by an intermediate hop's id. `created_at` likewise preserves the original +durably-accepted time, so the eventual `run.steer.consumed` event's `queue_latency_seconds` +includes the entire time spent waiting across every pause, not just the time since the last +resume. A client only needs to remember the `id` its very first `/steer` call returned to +find it, later, in the `source_event_id` field of `run.steer.accepted`/`run.steer.consumed` +events on whatever run eventually consumes it — no need to track every intermediate +`run_id`. + +### 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 +`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. + +### Accepted ≠ consumed + +The 202 response from `/steer` only means the event has been durably written to PostgreSQL's +steering inbox (`run.steer.accepted`, status `pending`) — **it does not mean the graph has +processed it**. When an `EventBus` is configured (in-process, or Redis via `RedisEventBus`), +`/steer` publishes an optional wake notification a running worker's heartbeat loop is waiting +on, which can shorten the time to discovery; it is never the sole source of truth, and it is +not itself a named lifecycle event (there is no `run.steer.available` event in the stream) — +it is purely a low-latency hint. Even if that notification is lost, a worker still eventually +discovers and consumes the event via PostgreSQL's own periodic safe-point checks. Only once +graph code at some safe point actually calls `runtime.drain_steering()` and that consumption +is durably committed by `commit_steering_consumptions_if_owned()` does `run.steer.consumed` appear. +Callers must not assume the graph has "seen" the input just because they got a 202 — there is +no endpoint to poll a steering event's status directly; watch the run's event stream +(`GET /v1/runs/{run_id}/stream`) or event list for `run.steer.consumed` to confirm it was +actually consumed. diff --git a/Wiki/content/docs/en/concepts/meta.json b/Wiki/content/docs/en/concepts/meta.json index 6e4b066..7c7729b 100644 --- a/Wiki/content/docs/en/concepts/meta.json +++ b/Wiki/content/docs/en/concepts/meta.json @@ -1,4 +1,4 @@ { "title": "Core concepts", - "pages": ["architecture", "durable-execution", "agents-tools", "agent-skills"] + "pages": ["architecture", "durable-execution", "steering", "agents-tools", "agent-skills"] } diff --git a/Wiki/content/docs/en/concepts/steering.mdx b/Wiki/content/docs/en/concepts/steering.mdx new file mode 100644 index 0000000..68b2ebd --- /dev/null +++ b/Wiki/content/docs/en/concepts/steering.mdx @@ -0,0 +1,242 @@ +--- +title: "Mid-run steering" +description: "Durably injecting new input into a running graph, and how it differs from cancel/interrupt/resume" +--- + +## What problem does steering solve? + +A long-running agent run is often not a closed box: a human or another system may want to +hand it new information — "actually reply in Spanish," "the customer just called, priority is +now urgent," "here is the missing API key" — *while the run is still going*, without stopping +it, without waiting for it to reach a natural pause point on its own, and without losing that +input if a worker crashes right after accepting it. + +Steering is LingxiGraph's answer: `POST /v1/runs/{run_id}/steer` durably accepts a small piece +of structured JSON and hands it to graph code the next time that code chooses to look for it. +LingxiGraph guarantees the delivery — durable, ordered, deduplicated, atomic-drain-once within +a single live `SteeringChannel` — but it never decides *what the new input means*. Whether a +message means "replan," "just append to context," or "ignore, we're already past that step" is +entirely up to the graph. See [Ordering, idempotency, and drain-once](#ordering-idempotency-and-drain-once) +below for exactly what "drain-once" does and does not guarantee across a worker crash. + +## How it differs from cancel, interrupt, and resume + +These four mechanisms are easy to conflate because they all touch a run mid-flight, but they +answer different questions: + +| Mechanism | Question it answers | Who initiates | What happens to the run | +| --- | --- | --- | --- | +| `cancel` | "Stop this run." | Caller | Run moves to `cancelling` → `cancelled`; execution is torn down, cooperatively | +| `interrupt` | "Graph code needs a value it doesn't have yet, then keep going with it." | Graph code (`interrupt(value)`) | Run moves to `paused`; execution suspends *at that call site* until `resume` supplies a value | +| `resume` | "Continue a paused run with this value." | Caller | A **new** run is created, continuing from where `interrupt` suspended | +| `steer` | "Here is a new piece of input, whenever it's convenient." | Caller | The run keeps executing on its own schedule; the graph decides when/whether to look at the new input | + +`interrupt`/`resume` is a *request-response* pattern baked into a specific point in the graph: +the graph explicitly stops and waits, and the caller must supply exactly the value that call +site is waiting for. Steering is the opposite: it is *push*, asynchronous, not tied to any +particular call site, and does not stop anything. A run does not need to be paused, waiting, +or even running yet (a `pending`, not-yet-claimed run also durably accepts steering) to +receive one. + +Steering also composes with the other three. A run can be steered while running, steered while +paused (see the durable-transfer section below), and steering never blocks or delays a cancel. + +## Data flow: PostgreSQL durable inbox → worker → SteeringChannel → runtime + +```mermaid +sequenceDiagram + participant C as Caller + participant API as API server + participant PG as PostgreSQL (run_steering_events) + participant R as Redis (optional) + participant W as Worker + participant Ch as SteeringChannel + participant N as Node code + + C->>API: POST /runs/{id}/steer + API->>PG: INSERT pending steering row + PG-->>API: durably committed + API-->>C: 202 run.steer.accepted + API-->>R: best-effort NOTIFY (optional) + R--)W: wake hint (may be lost) + W->>PG: poll/peek pending steering (safe point) + PG-->>W: rows still pending + W->>Ch: ingest into in-memory channel + N->>Ch: drain_steering() at a safe point + Ch-->>N: ordered, deduplicated events + N-->>W: consumption record (node, namespace, task_id, latency) + W->>PG: commit_steering_consumptions_if_owned() (status + run.steer.consumed, one transaction) +``` + +PostgreSQL's `run_steering_events` table is the single source of truth for every steering +event's existence and status. The schema reserves a `delivered` value for forward +compatibility, but the currently implemented flow never writes it — the real transition is +`pending` → `consumed` directly (via a single atomic `commit_steering_consumptions_if_owned()` call), +or `pending`/`delivered` → `superseded` — either because the paused run it belonged to was +resumed (its still-pending steering migrates onto the new run) or because it was still +unconsumed once the owning run reached its terminal finalization boundary, for one of several +possible reasons (see "Finalization and the steering admission boundary" below). Treat `delivered` as a status your code may need to read/allow +today, not one anything currently produces. The Redis notification is purely an optimization +to reduce polling latency; losing +it never loses a steering event — the worker's periodic safe-point checks fall back to reading +PostgreSQL directly. `SteeringChannel` is the in-process, per-run structure the worker uses to +hand already-fetched events to graph code with atomic drain-once semantics; it is not itself +durable and is rebuilt from PostgreSQL whenever a worker restarts or takes over a run. + +## Safe points are application-chosen, not an arbitrary preemption + +LingxiGraph never interrupts a node function mid-execution to deliver steering. It guarantees +that whenever graph code calls `runtime.drain_steering()`, the events returned are the current, +freshest, not-yet-drained set — but *where inside a node's code* that call happens is entirely +the application's choice. A node can call it once at the very start, in a loop, or not at all. + +What LingxiGraph *does* control is when a node is scheduled as a new task by the executor — +before a node starts, before the next superstep, before a retry, and after a resume — and each +of those transitions is an opportunity for steering ingested since the last one to become +visible. This is a deliberate boundary, not a gap: true preemption of arbitrary user code would +break the deterministic replay guarantees the rest of the runtime depends on. + +## Ordering, idempotency, and drain-once + +- Steering events for a given run are strictly ordered by an internal `sequence`, assigned at + durable-accept time. +- Within a single live `SteeringChannel` — one worker, one in-process instance, spanning any + number of internal retries of a node — `drain_steering()` never returns the same event twice: + atomic drain-once. +- **This is not a system-wide exactly-once guarantee.** If a worker crashes (or its lease is + reassigned) after `drain_steering()` has handed an event to a node but *before* + `commit_steering_consumptions_if_owned()` durably records that consumption, the PostgreSQL row is + still `pending`/`delivered` — a new worker rebuilding the channel from PostgreSQL for that run + will legitimately redeliver the same event. Across a crash, steering delivery is + **at-least-once**, not exactly-once. Applications performing an irreversible side effect from + a drained event (a charge, an external API call with side effects) should key their own + idempotency off `event.id` (or the root `source_event_id` after a pause/resume transfer, + see below) rather than assume drain-once implies process-once. +- Deduplication by `idempotency_key`: submitting the same `(tenant_id, run_id, + idempotency_key)` twice returns the original event instead of creating a second one, and + that dedup survives a `drain_steering()` call — a retried submission after the original was + already drained still returns the original (now-consumed) event, not a new pending one. This + dedupes the *accept* side (repeated `/steer` calls); it does not by itself make the *drain* + side exactly-once across a crash — see above. +- The consumed-commit (`commit_steering_consumptions_if_owned()`) and the graph's own checkpoint commit + for the superstep that drained the event are two separate durable operations, not one + distributed transaction (see "Consumption vs. checkpoint commit" below) — so there is no + system-wide exactly-once processing guarantee spanning steering delivery *and* graph state. + +## Retry, worker crash, and Redis loss + +- **Worker retry**: `commit_steering_consumptions_if_owned()` is idempotent — a worker that commits a + consumption batch, loses the connection before seeing the ack, and retries the same batch + will not create a duplicate `run.steer.consumed` event; only steering rows that actually + transition status in a given call produce one. +- **Worker crash**: because delivery state lives in PostgreSQL, not in the crashed worker's + memory, a new worker claiming the run reconstructs the picture (`pending`/`delivered` rows) + from PostgreSQL and continues from there. No steering event is silently dropped by a worker + crash. +- **Redis loss**: Redis is an accelerator, never a dependency for correctness. If the pub/sub + notification never arrives — connection reset, Redis restarted, message lost — the worker's + own periodic PostgreSQL checks at each safe point still discover and deliver the event, just + with slightly higher latency instead of a lost update. + +## Finalization and the steering admission boundary + +Once graph execution ends, the owning worker atomically seals new steering admission +(`close_steering()`) before it starts the final durable flush of whatever the graph already +drained. From that instant on, a fresh `/steer` call is refused with a stable `409 +run_finalizing` — there is no safe point left for the graph to ever consume a newly accepted +event. + +This still leaves a gap: a `/steer` call can win the run row's lock, and durably commit its +row, a moment *before* `close_steering()` wins that same lock — even though the graph has +already produced its terminal output and the heartbeat loop that would have ingested the event +has already stopped. Once the final flush completes, the Runtime checks for exactly this: any +steering row still `pending`/`delivered` at that point is durably transitioned to `superseded` +(reason `unconsumed_at_final_boundary`), paired with a `run.steer.superseded` lifecycle event, +in the *same* lease/attempt-fenced transaction as the rest of finalization — the steering +disposition and the run's terminal status commit together or not at all (see +`finalize_run_with_steering_disposition_if_owned`; a crash or lease loss between two separately +committed writes used to be able to leave steering permanently `superseded` while the run +itself never actually finished). The run then finalizes normally with its already-computed +outcome (`succeeded`, `failed`, `dead_letter`, etc. — any terminal outcome; `paused` is excluded +from this finalization path since it was never terminal to begin with, see below) — it is **not** +forced through a retry loop just because a steering event happened to exist. + +The `unconsumed_at_final_boundary` reason is deliberately generic and does **not** claim a +race occurred. It covers two different situations the Runtime cannot currently tell apart from +the data it has: the narrow lock race described above, and the far more common case of a graph +that legitimately never calls `drain_steering()` at all (a documented, allowed choice — see +"Safe points are application-chosen" above), where the row had been sitting there, fully +visible, for the run's entire lifetime. Either way the outcome is the same: the run finalizes +on its first attempt exactly as if the steering event had never arrived, and that event's +disposition is recorded as `superseded`, not silently dropped. + +**Final disposition policy, stated precisely:** a terminal run's *own* finalization decision +can never silently strand an unconsumed steering event without a durable, observable +disposition — that is what the mechanism above guarantees. Any genuinely terminal `finish` +intent — `succeeded`, `failed`, `cancelled`, or `dead_letter` produced by the graph's own +outcome computation — closes steering admission and supersedes leftovers exactly as described +above; `paused` is the only status excluded (see the next paragraph). This means an ordinary +`failed`/`dead_letter` outcome does **not**, by itself, leave steering `pending`. + +The one case that still can is narrower than a blanket "dead-lettered runs leave steering +pending": it is specifically a run whose *final steering flush itself* could never durably +commit (`_retry_or_dead_letter()` — see `Worker._execute`'s post-flush handling). That path +never reaches `finalize_run_with_steering_disposition_if_owned()` at all — it routes the attempt +through the ordinary retry/dead-letter machinery instead, precisely because writing any +disposition (superseded or otherwise) would either report a false status or silently strand the +already-drained-but-uncommitted consumption. Only in that specific situation can a `dead_letter` +run still have steering sitting `pending` — and it is deliberate, retained durable history, not +a bug: a later `/redrive` of that run (allowed only from `failed` or `dead_letter`) picks the +still-pending row back up like any other queued steering. An ordinary client-initiated **cancel** +is different, and not currently covered by that same recovery path: `/redrive` does not accept a +`cancelled` run, so steering left `pending` on a cancelled run has no automatic future delivery +attempt today — recovering it, if ever needed, is an operator/API task outside this mechanism, +not something the Runtime resolves on its own. + +## Parallel supersteps, `Send`, and subgraphs share one channel + +A single run's `SteeringChannel` is shared across every task in the same superstep, including +parallel branches and `Send`-fanned-out tasks, and across subgraph node boundaries within the +same run. Draining is mutually exclusive per event (only one task can ever drain a given event +— no double-processing across parallel tasks) but is not restricted to a single node: any node +anywhere in the run's task graph, at any nesting level, can call `drain_steering()` and see the +same durable, ordered backlog. `queue_latency_seconds` and `task_id`/`namespace` on the +eventual `run.steer.consumed` event record exactly which task, at which subgraph depth, ended +up consuming a given event. + +## Consumption vs. checkpoint commit — current scope cut + +As of this release, marking a steering event `consumed` (via +`commit_steering_consumptions_if_owned()`) is a separate durable operation from the graph's own +checkpoint commit for the superstep that drained it. They are not wrapped in one distributed +transaction. In practice this means: if a node drains steering and then the process dies before +its checkpoint is written, the checkpoint-level retry will re-execute the node — but the +steering event was already marked `consumed`, so a re-executed node will not see it again via +`drain_steering()`. Nodes that need the drained payload to survive a mid-superstep crash should +persist what they need from it into state as part of the same node's return value, the same way +they would for any other externally observed input, rather than re-reading it from the +steering channel after a retry. This is a deliberate, currently-shipped scope cut — not a +promise that consumption is checkpoint-transactional — and may be revisited in a future +release. + +## What steering explicitly does not do + +Steering is a delivery mechanism, not a planning mechanism. It does not: + +- decide whether a new message should trigger replanning, cancel a tool call in flight, or + change the current node's control flow — that is entirely business graph logic; +- guarantee low latency between accept and consume — only that consumption, once it happens, + is durable and observable via `run.steer.consumed`; +- provide a way to modify already-committed state directly; the graph must explicitly fold a + drained payload into its own state update. + +## Embedded / library-mode degradation + +When LingxiGraph is used as an embedded library without the Agent Server (no worker process, +no PostgreSQL-backed repository), `compiled_graph.steer(run_id, ...)` and +`runtime.drain_steering()` still work against an in-process `SteeringChannel`, but durability +degrades to "as durable as the process hosting it": there is no cross-process delivery, no +worker-crash recovery, and no PostgreSQL-backed audit trail of `run.steer.accepted`/ +`run.steer.consumed` events. A plain `invoke()` that never touches steering incurs no overhead +and leaks no channel state — see the embedded lifecycle tests referenced from the Agent Server +implementation for the exact guarantees at that layer. diff --git a/Wiki/content/docs/en/operations/meta.json b/Wiki/content/docs/en/operations/meta.json index 015c634..edf6b28 100644 --- a/Wiki/content/docs/en/operations/meta.json +++ b/Wiki/content/docs/en/operations/meta.json @@ -1,4 +1,4 @@ { "title": "Operations and project", - "pages": ["configuration", "security-observability"] + "pages": ["configuration", "security-observability", "steering-operations"] } diff --git a/Wiki/content/docs/en/operations/steering-operations.mdx b/Wiki/content/docs/en/operations/steering-operations.mdx new file mode 100644 index 0000000..56e20c6 --- /dev/null +++ b/Wiki/content/docs/en/operations/steering-operations.mdx @@ -0,0 +1,252 @@ +--- +title: "Operating durable steering" +description: "Migration rollout order, PostgreSQL-as-source-of-truth, latency, retries, and rollback boundaries" +--- + +## Migration rollout order + +Mid-run steering (issue #16) 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 | + +Apply them with the standard Alembic path: + +```bash +alembic upgrade head +``` + +All four are additive (`ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, nullable/ +`NOT NULL DEFAULT FALSE` columns) and safe to run against a live database before deploying the +new server/worker binaries — an old binary that predates `0004`/`0005` simply never reads or +writes `steering_closed`/`superseded_by_run_id`; it keeps behaving exactly as it did before the +columns existed. Deploy order for a zero-downtime rollout: + +1. Run `alembic upgrade head` (adds `run_steering_events`, `source_event_id`, + `steering_closed`, and `superseded_by_run_id`) while the old server/worker binaries are + still serving traffic. +2. Roll out the new server and worker binaries. + +Do not reverse this order: a binary that writes `source_event_id`/`steering_closed`/ +`superseded_by_run_id` before the column exists will fail its inserts/updates. Because migrations in this project are +forward-only (`downgrade()` raises), there is no automated rollback path for the schema — see +"Rollback boundaries" below. + + +`PostgresRepository.setup()` also applies the same SQL files directly (used by tests and by +embedded/dev setups). That code path is **not** a substitute for driving the Alembic revision +chain in a real deployment: only `alembic upgrade head` is what a production rollout should +run, and it must reach `0005_superseded_by_run_id`, not stop at `0002_steering`, +`0003_steering_source_event`, or `0004_steering_closed`. + + +## The `steering_closed` finalization gate + +Once a worker's graph execution reaches its terminal/paused 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 +(fenced on its own `lease_owner`/`attempt`, just like the status write itself) the instant +execution ends, before that final flush starts. + +- While `steering_closed` is `false`, `POST /steer` is admitted normally, exactly as it always + was before `0004`. +- Once `steering_closed` is `true` for the run's current attempt, a **new** `POST /steer` + (no matching Idempotency-Key on file yet) is rejected with `409 run_finalizing` — a stable, + documented error distinct from the terminal-run `409`, so a client can tell "this run is + momentarily busy finishing up" apart from "this run is permanently done". +- A **replayed** `POST /steer` — same `(tenant_id, run_id, Idempotency-Key)` as an event that + was already durably accepted — always returns that existing event's current state, never a + fresh `409`, regardless of `steering_closed`/terminal/superseded status. See "Idempotent + replay is safe across every admission gate" below. +- A `paused` outcome never closes the gate: pausing is not "no safe point left" the way a + terminal outcome is — `/steer` against a paused run is an intentional, documented part of the + design (the pending steering is transferred to the resumed run at `/resume` time). +- A gate closed by an abandoned delivery attempt does not leak into the next one: `claim_run` + resets `steering_closed` to `false` on every fresh claim, so a retried run accepts steering + normally again. + +A closed gate on its own is not sufficient to prevent a permanently-pending steering row: a +`/steer` call can win the run row's lock a moment *before* `close_steering()` does, even though +the graph has already finished. The worker's post-flush check +(`finalize_run_with_steering_disposition_if_owned()`, `Worker._execute`) is what actually +catches that case — if anything is still `pending`/`delivered` after the final flush has +committed everything it could, that row is durably transitioned to `superseded` (reason +`unconsumed_at_final_boundary` — a deliberately generic label that does **not** claim a race +occurred; it also covers the far more common case of a graph that simply never called +`drain_steering()` at all), paired with a `run.steer.superseded` lifecycle event, in the *same* +lease/attempt-fenced transaction as the run's terminal status write — the two commit together +or not at all, never as two separately-committed writes that could diverge on a crash or lease +loss between them. The run then finalizes normally with its already-computed outcome — it is +**not** routed through a retry/dead-letter loop just because a steering event happened to +exist, since doing so would force a rerun onto a graph that may be correctly, deliberately +never calling `drain_steering()` at all. **A terminal run's own finalization decision can never +silently strand an unconsumed steering event without a durable, observable disposition** — an +ordinary `failed`/`dead_letter` outcome computed by the graph closes admission and supersedes +leftovers exactly like `succeeded`/`cancelled` do. The one narrower exception is a run whose +*final steering flush itself* could never durably commit (`_retry_or_dead_letter()`), which never +reaches `finalize_run_with_steering_disposition_if_owned()` at all and instead routes the attempt +through the ordinary retry/dead-letter machinery — only there can a `dead_letter` run legitimately +still carry a `pending` steering row, as deliberate durable history, picked back up by a later +`/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. + +## 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. +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). + +`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: + +- It is written exactly once, atomically, inside the same fenced resume transaction that + creates the descendant run and migrates the old run's pending steering + (`resume_run_with_pending_steering()` / its Postgres counterpart) — never derived from, + or writable via, `RunCreate.metadata`. +- `/steer`'s admission check, `/cancel`'s superseded check, and the resume conflict gate all + read `run.superseded_by_run_id` directly; none of them consult `metadata` for this decision + any more. +- 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. + +## Idempotent replay is safe across every admission gate + +`submit_steering()` (both the in-memory and PostgreSQL repositories) checks for an existing +event matching `(tenant_id, run_id, Idempotency-Key)` **before** any terminal/`steering_closed`/ +superseded admission gate, not after. If a client's `POST /steer` durably committed but the 202 +response never reached it (a dropped connection, a proxy timeout), a standard idempotent retry +with the same key returns that event's current persisted state — id and sequence unchanged — +even if the run has since gone finalizing, terminal, or superseded. Only a genuinely *new* key +(one this run has never seen) is subject to those gates; that is a real admission decision, and +still gets the appropriate `409`. + +## PostgreSQL is the source of truth; Redis is only an accelerator + +Every steering event's existence and status lives in PostgreSQL's `run_steering_events` +table. Redis pub/sub is used only to reduce the latency between "an event became durable" and +"a worker notices it" — it is never consulted to determine whether an event exists, and its +loss (a Redis restart, a dropped connection, a missed NOTIFY) never loses an event. Operators +can treat Redis as fully disposable for steering correctness: if Redis is down or evicted, +steering keeps working, just with worker discovery falling back to periodic PostgreSQL +polling at each safe point instead of a near-instant wake-up. + +## Worker heartbeat and steering delivery latency + +A worker only picks up newly-durable steering for a run it currently holds the lease on, at +that run's next safe point (node start, next superstep, retry, post-resume). Two factors +therefore dominate delivery latency: + +- **How often the run reaches a safe point.** A node doing a single long-running tool call + will not see steering submitted mid-call until that call returns and the node either + finishes or calls `drain_steering()` itself. +- **Worker poll/heartbeat interval**, when Redis notification is unavailable. A larger + interval trades steering-latency for lower PostgreSQL polling load; tune it per the + worker's `--health-port`/poll configuration in `docs/api.md` and the deployment's + `lingxigraph worker` flags. + +## Diagnosing long-pending steering + +If a steering event stays `pending` far longer than expected: + +1. Confirm the run is actually claimed and progressing: `GET /v1/runs/{run_id}` — a `pending` + run has no worker executing it yet; steering on it waits for a claim, not a bug. +2. Check whether the run's graph code ever calls `runtime.drain_steering()` at all, and how + often — steering delivery depends entirely on the graph choosing to look, per the + application-chosen safe-point model (see the steering concept page). +3. Query `run_steering_events` directly for the row's `status`, `created_at`, and + `source_event_id` to see whether it was migrated from an earlier, paused run — the + `queue_latency_seconds` on the eventual `run.steer.consumed` event will include all + accumulated pause time, which is expected, not a bug. +4. Check worker logs/metrics for repeated `commit_steering_consumptions_if_owned()` failures — + those retry safely (the operation is idempotent, and fenced on the worker's own + `lease_owner`/`attempt` — see "The `steering_closed` finalization gate") but repeated + failures indicate a database connectivity problem worth alerting on. +5. If a client got `409 run_finalizing` on `/steer`, the run's graph had already finished + executing at the moment the request landed — check `runs.steering_closed` for the run's + current attempt (`true` means the gate is shut) and retry against a fresh run instead of the + same one. A replay with the same Idempotency-Key is always safe here, even after the gate + closes — see "Idempotent replay is safe across every admission gate". +6. If a steering event's status is `superseded` with `reason: unconsumed_at_final_boundary` in its + `run.steer.superseded` event data (rather than `reason: resume_transfer`), that is the + finalization-boundary fix working as intended: the event was durably accepted with no safe + point left in that delivery attempt to consume it, so it was given a durable, observable + `superseded` disposition instead of the run finalizing with a permanently-pending row or + being forced through a needless rerun. This is not itself a bug; a repeating pattern of + `unconsumed_at_final_boundary` for the same run suggests steering is consistently arriving after the + graph already reaches its terminal node, which is worth investigating at the + application/graph level. + +## The normal `run.steer.accepted → consumed` lifecycle + +1. `POST /steer` durably inserts a `pending` row and emits `run.steer.accepted` (202). +2. A worker holding the run's lease discovers the row (via Redis hint or PostgreSQL poll) and + ingests it into the run's in-memory `SteeringChannel`. +3. Graph code calls `drain_steering()` at some safe point and receives it. +4. The worker durably commits the consumption (`commit_steering_consumptions_if_owned()`, + fenced on the worker's own `lease_owner`/`attempt`), which atomically transitions the row to + `consumed` and emits `run.steer.consumed` in the same transaction. A worker that no longer + owns the lease/attempt (a takeover mid-heartbeat or mid-final-flush) gets nothing written at + all — the new owner is solely responsible for the row from that point on. + +No step in this chain is optional or reorderable; a client should treat only step 4's +`run.steer.consumed` event as proof of actual processing. + +## Why worker/DB transient failures do not lose a consumption, and how to observe retries + +The status update and the `run.steer.consumed` event insert happen inside one PostgreSQL +transaction. If the connection drops after the transaction commits but before the worker +observes the acknowledgment, the worker's retry logic re-sends the same consumption batch. +Because `commit_steering_consumptions_if_owned()` is idempotent — it only emits a lifecycle +event for rows that transition status in that specific call, and only writes anything at all +when the caller still owns the run's current lease/attempt — a retried batch against +already-consumed rows is a safe no-op: the status stays `consumed` and no second +`run.steer.consumed` event is created. Operators can observe this pattern in worker logs as a +repeated `commit_steering_consumptions_if_owned` call for the same steering ids with no +corresponding growth in `run.steer.consumed` events in `run_events` — that is expected +behavior, not a stuck retry loop, as long as it eventually stops retrying. + +## Upgrade and rollback boundaries + +Migrations in this project are forward-only: every revision's `downgrade()` raises rather than +attempting to undo the change. This is intentional — a real rollback of `run_steering_events` +or its columns would either lose durable steering history or require reconciling in-flight +runs against a schema the new binaries no longer expect. Practical implications: + +- Never plan a rollback strategy that downgrades the schema. If a deployment needs to be + rolled back, roll back the **binaries** while leaving the schema at its current (forward) + revision — the additive nature of `0002_steering`/`0003_steering_source_event`/ + `0004_steering_closed`/`0005_superseded_by_run_id` means older binaries that predate a given + 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 + 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. +- Because there is no schema downgrade path, validate `0002_steering`/ + `0003_steering_source_event`/`0004_steering_closed`/`0005_superseded_by_run_id` against a + staging database (or the CI `test_postgres_integration.py` Postgres job) before applying to + production, rather than relying on the ability to undo it. diff --git a/Wiki/content/docs/zh/api/errors-events.mdx b/Wiki/content/docs/zh/api/errors-events.mdx index d28eefd..6498f4d 100644 --- a/Wiki/content/docs/zh/api/errors-events.mdx +++ b/Wiki/content/docs/zh/api/errors-events.mdx @@ -29,6 +29,16 @@ description: "处理 problem details、run 失败、事件续传与客户端重 常见稳定码包括 `idempotency_conflict`、`quota_exceeded`、`join_timeout` 以及由 schema、认证、并发和资源状态映射的 problem code。错误码新增时客户端应安全地归入未知错误,而不是失败解析。 +### Steering 相关错误码 + +| `code` | HTTP | 触发条件 | +| --- | --- | --- | +| `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 写入 | +| `run_resume_conflict` | 409 | 并发 `resume` 调用同一个已暂停的 run;只有一个调用会成功创建新 run,其余调用都会收到该错误 | +| `run_finalizing` | 409 | run 的图已经执行结束、但最终状态尚未提交(worker 正在持久化刷新并收尾的短暂窗口)时调用 `/steer`;此时已经没有任何安全点可以消费该事件,因此直接拒绝,而不是接受一条永远不会被消费的记录。`paused` 结局不受影响——对已暂停的 run 发起 `/steer` 仍然正常 | + ## Run 业务错误 创建 run 成功返回 `202 pending`。之后的节点/schema/provider 错误会使资源变为 `failed`、`timed_out` 或 `dead_letter`,并写入: @@ -67,6 +77,59 @@ data: {"id":"...","run_id":"...","sequence":18,"kind":"node_completed","data":{} - 以 `: heartbeat` 开头的行是保活注释,应忽略; - run 到达 terminal 或 `paused` 后,服务端关闭流。 +### Steering 生命周期事件 + +除了图执行过程中产生的普通事件(`node_completed` 等),运行中输入(steering)在其生命周期 +中会产生三类专门的 SSE 事件: + +| `kind` | 何时产生 | 关键字段 | +| --- | --- | --- | +| `run.steer.accepted` | `/steer` 调用被 durably 接受时(202) | `steering_event_id`、`source_event_id`(若来自 resume 迁移)、`sequence`、`kind` | +| `run.steer.consumed` | 图代码调用 `drain_steering()` 消费该事件,且这次消费被 `commit_steering_consumptions_if_owned()` durably 提交后 | `steering_event_id`、`source_event_id`、`sequence`、`kind`、`queue_latency_seconds`、`node`、`namespace`、`task_id` | +| `run.steer.superseded` | 某条 steering 事件在从未被消费的情况下,其行 durably 转为 `superseded` 状态——原因可能是它所属的 paused run 被 resume(其仍 pending 的 steering 被迁移到新 run),也可能是它在该 run 到达终结 finalization 边界时仍处于 `pending`/`delivered`(见下文) | `steering_event_id`、`source_event_id`、`sequence`、`kind`、`reason`(`resume_transfer` 或 `unconsumed_at_final_boundary`)、`superseded_by_run_id`(`resume_transfer` 时有值,`unconsumed_at_final_boundary` 时为 `null`)、`replacement_steering_event_id`(`resume_transfer` 时有值,`unconsumed_at_final_boundary` 时为 `null`) | + +`run.steer.superseded` 的 `unconsumed_at_final_boundary` 原因是一个刻意保持诚实、笼统的 +标签——它**并不**声称该事件在与什么竞争。它涵盖了 worker 到达真正的终结 finalization 边界 +时仍处于 `pending`/`delivered` 的每一条 steering 行,而这背后的成因不止一种:某次 `/steer` +调用确实可能恰好在 worker 关闭新准入之前完成 durable 提交(这是真实的竞争);但同样常见的 +情况是,图从始至终就没有调用过 `drain_steering()` 去消费一条其实一直可见的行——这是普通的 +应用层未消费,根本不是竞争。Runtime 目前无法从现有数据中区分这两种情况,因此它也不假装能 +区分:两者都得到相同的 `unconsumed_at_final_boundary` 原因。与其仅因为存在一条 steering +事件就强制该 run 进入无限重试循环——这会惩罚那些本就合法地从不调用 `drain_steering()` 的 +图——Runtime 会把这条行 durably 转为 `superseded`,并以之前已经算出的结果正常终结该 run。 +这种情况下 `run.steer.superseded` 事件绝不会被静默丢弃:它总是与 run 的终结状态在同一个 +durable 事务中一起提交,并且在重试下是幂等的(对已经 `superseded` 的行重复尝试转换不会 +产生重复事件)。 + +字段说明: + +- `steering_event_id`:被消费的 steering 事件在**当前** run 下的 id(如果该事件是从旧 run + 迁移过来的,这是迁移后的新 id,不是客户端最初收到的 id); +- `source_event_id`:客户端最初 `/steer` 调用收到的**根** id。同一根 steering 即使经过多次 + pause/resume 迁移,该字段在每一跳都保持一致,客户端可用它把最终 + `run.steer.consumed`/`run.steer.superseded` 与自己最初发出的请求关联起来。这一行为按具体 + 生命周期事件而定,并非统一规则:在 `run.steer.accepted` 与 `run.steer.consumed` 上,仅当 + 事件来自迁移时才出现该字段——直接提交(从未被迁移)的事件在这两类事件上没有此字段; + `run.steer.superseded` 则不同:它总是把 `source_event_id` 设为 + `event.source_event_id or event.id`,因此即便是一条直接提交、从未迁移过的事件,其 + superseded 记录里也会带有一个根 id——就是它自己的 id; +- `sequence`:该 steering 事件在其所属 run 的 steering 序列里的序号(与 run 事件流的 + `sequence` 是两个独立计数器); +- `kind`:透传自 `/steer` 请求体的 `kind`; +- `queue_latency_seconds`:从该 steering **最初** durably accepted(包括跨 resume 累积的 + 等待时间)到被消费的秒数; +- `node`:消费该事件的节点名; +- `namespace`:该节点所在的 subgraph 命名空间路径(数组); +- `task_id`:区分同一 superstep 内并行/`Send` 任务的具体 task。 + +**durable source 是 PostgreSQL,Redis 只是可选加速器。** `run.steer.accepted`、 +`run.steer.consumed` 和 `run.steer.superseded` 都是先 durably 写入 PostgreSQL 再对外可见的;Redis pub/sub 只用于 +提示 worker "可能有新 steering 了",从而降低轮询延迟。即使 Redis 通知丢失(连接断开、 +Redis 重启等),worker 仍会在下一个安全点检查/轮询 PostgreSQL 并最终消费——不会因为 +Redis 通知丢失而永久遗漏一条 steering。同理,`commit_steering_consumptions_if_owned()` 是幂等的: +worker 因瞬时故障重试同一批 consumption 时,只有真正发生状态转换的事件才会产生新的 +`run.steer.consumed`,不会为已经消费过的事件重复生成。 + ## 重试策略 1. 只对 `retryable=true`、网络故障、429 和临时 5xx 使用有界指数退避; diff --git a/Wiki/content/docs/zh/api/overview.mdx b/Wiki/content/docs/zh/api/overview.mdx index f7f746a..14cd065 100644 --- a/Wiki/content/docs/zh/api/overview.mdx +++ b/Wiki/content/docs/zh/api/overview.mdx @@ -59,6 +59,8 @@ stateDiagram-v2 running --> timed_out running --> cancelling cancelling --> cancelled + pending --> cancelled: cancel(无存活 worker) + paused --> cancelled: cancel(无存活 worker) failed --> pending: redrive dead_letter --> pending: redrive paused --> pending: resume creates new run diff --git a/Wiki/content/docs/zh/api/python-sdk.mdx b/Wiki/content/docs/zh/api/python-sdk.mdx index c819e3b..65a4e8a 100644 --- a/Wiki/content/docs/zh/api/python-sdk.mdx +++ b/Wiki/content/docs/zh/api/python-sdk.mdx @@ -36,6 +36,15 @@ with LingxiGraphClient( completed = client.runs.join(run["id"], timeout=120) print(completed["output"]) + + # Durably 注入一条运行中输入;成功只代表已 durably 写入,不代表已被图消费。 + accepted = client.runs.steer( + run["id"], + kind="user_input", + payload={"message": "改成用中文回复"}, + idempotency_key="msg-123", + ) + print(accepted["id"], accepted["status"]) # "pending" ``` 可用资源方法: @@ -45,12 +54,33 @@ with LingxiGraphClient( | `graphs` | `list()`、`get(graph_id)` | | `assistants` | `create(**values)`、`list()`、`get()`、`update()`、`delete()` | | `threads` | `create()`、`list()`、`get()`、`update()`、`state()`、`history()`、`fork()`、`delete()` | -| `runs` | `create()`、`get()`、`list(thread_id)`、`join()`、`stream()`、`cancel()`、`resume()` | +| `runs` | `create()`、`get()`、`list(thread_id)`、`join()`、`stream()`、`cancel()`、`steer()`、`resume()` | | `store` | `batch(operations)`、`search(namespace, ...)` | | `schedules` | `create()`、`list()`、`update()`、`delete()` | Redrive 或尚未封装的端点可使用 `client.request(method, path, **kwargs)`。 +### `runs.steer()` + +```python +client.runs.steer( + run_id, + kind="user_input", + payload={"message": "..."}, + metadata=None, + idempotency_key="msg-123", +) +``` + +- `kind` 默认 `"user_input"`,可自定义为任意业务字符串; +- `payload` 是图代码通过 `runtime.drain_steering()` 读回的原始 JSON; +- `idempotency_key` 传入时会同时作为 `Idempotency-Key` header 发送;同一 key 重复调用 + 返回原事件,不会重复入队; +- 返回值是 202 响应体(含 `id`、`sequence`、`status`),**不代表**图已经处理这条输入—— + 只有服务端后续出现的 `run.steer.consumed` 事件才代表真正被消费; +- 对处于终止态的 run 调用会抛出 `LingxiGraphAPIError`,`code == "run_terminal"`; + 对已被 `resume` 取代的旧 `run_id` 调用会抛出 `code == "run_superseded"`。 + ## 异步客户端 ```python @@ -65,6 +95,14 @@ async with AsyncLingxiGraphClient(base_url, token=token) as client: ) async for event in client.runs.stream(run["id"]): print(event) + + accepted = await client.runs.steer( + run["id"], + kind="user_input", + payload={"message": "switch to Spanish"}, + idempotency_key="msg-123", + ) + print(accepted["id"], accepted["status"]) ``` 异步资源与同步资源一一对应。不要在 async 事件循环中使用同步客户端。 diff --git a/Wiki/content/docs/zh/api/threads-runs.mdx b/Wiki/content/docs/zh/api/threads-runs.mdx index 8d7445d..1cf3775 100644 --- a/Wiki/content/docs/zh/api/threads-runs.mdx +++ b/Wiki/content/docs/zh/api/threads-runs.mdx @@ -79,9 +79,131 @@ POST /v1/runs/{run_id}/redrive ``` - cancel 是协作式请求,通常先返回 `cancelling`;节点和 provider 应响应 cancellation token; +- 取消一个 `pending`(尚未被 worker claim)或 `paused`(停在 `interrupt`、没有 worker 持有它)的 run 会立即变为 `cancelled` 并写入 `finished_at`:这两种状态都没有正在进行的投递可以协作式地观察 cancel token,因此无需等待; +- 若一个 `paused` run 之后已被 resume,再对它调用 cancel 会返回 `409 run_superseded`——与 `/steer` 对陈旧 `run_id` 给出的信号一致(见下文)。resume 之后旧 run 的 `status` 依设计仍保持 `paused`(只有类型化的 `superseded_by_run_id` 列会标记它已过期——绝不是可被用户写入、因而可能伪造该信号的 `metadata`),因此 cancel 在决定状态迁移之前,会原子地检查同一个字段;它绝不会在历史 run 上悄然"成功",而真正被 resume 出来的新 run 却仍在继续执行。请改为取消新的 `run_id`; - 只有 `paused` run 可 resume;响应是新 run,`metadata.resumed_from_run_id` 指向原 run; +- 并发对同一个已 `paused` 的 run 调用 resume 时只有一个会成功:它创建上述新 run,其余并发调用都会收到 `409 run_resume_conflict`,而不是各自创建重复的新 run; - 只有 `failed` 或 `dead_letter` 可 redrive;保留原 input/config/version 并重新入队。 Redrive 不是“修改输入后重试”。业务输入或 graph version 改变时应创建新 run,并使用新的幂等键。 + +## 运行中输入(Steering) + +`POST /v1/runs/{run_id}/steer` 在 Run 执行期间 durably 地注入新的结构化输入——不等待完成、 +不强制取消、也不需要先暂停 Run。这与 `cancel`(终止执行)和 `interrupt`(图代码主动让出、 +等待 `resume`)是三件不同的事:steering 只是把一条新消息安全地送进正在跑(或即将跑、或 +刚被 pause)的 Run,是否处理、何时处理、处理后是否改变计划完全由图代码决定。 + +```bash +curl -X POST https://agents.example.com/v1/runs/$RUN_ID/steer \ + -H "Authorization: Bearer $TOKEN" \ + -H "Idempotency-Key: msg-123" \ + -H "Content-Type: application/json" \ + -d '{"kind":"user_input","payload":{"message":"改成用中文回复"},"metadata":{}}' +``` + +202 响应: + +```json +{ + "id": "steer_01H...", + "run_id": "run_01H...", + "sequence": 3, + "kind": "user_input", + "status": "pending", + "created_at": "2026-08-18T09:00:00Z" +} +``` + +请求体字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `kind` | 是 | 业务自定义的事件种类字符串,例如 `user_input`、`priority_change` | +| `payload` | 是 | 任意 JSON,图代码通过 `drain_steering()` 读到的就是这个对象 | +| `metadata` | 否 | 附加元数据,不参与去重 | +| `idempotency_key` | 否 | 见下 | + +**`Idempotency-Key` header 与 body 字段的优先级:** 两者语义相同(同一个逻辑 key), +可以二选一传递;同时传递时以 `Idempotency-Key` header 为准。同一 `(tenant_id, run_id, +idempotency_key)` 三元组只会创建一条 steering 事件——重复调用返回**同一条**事件 +(`id`、`sequence` 不变,不会新建行,也不会递增 `sequence`),而不是 409。但响应体 +**不保证**与首次调用逐字节一致:它反映的是该事件**当前持久化的状态**,两次调用之间 +状态可能已经变化(例如首次调用时是 `pending`,而图已经在这之后消费了它,重放时读到 +的就是 `consumed`)。这与 `POST /runs` 创建接口的 `Idempotency-Key` 语义一致,都是 +"同 key 同请求安全重放"而不是"报错"——但重放返回的始终是同一个底层资源的最新状态, +而不是首次响应的静态快照。关键的一点是:这次重放查找发生在下面所有"按 run 状态"的 +准入检查**之前**,而不是之后——同一个 key 的重试永远返回已存在的事件,即使该 run +此后已经进入 `run_finalizing`、走到终止态、或已被 superseded。只有一个真正全新的 key +才会受这些检查约束。 + +### 各 Run 状态下的行为 + +| Run 状态 | `/steer` 行为 | +| --- | --- | +| `pending`(已入队,worker 未 claim) | 202,durably 写入,worker claim 后立即可见 | +| `running` | 202,durably 写入,下一个安全点可读到 | +| `cancelling` | 202,仍然接受(cancel 优先级见下,但接受写入本身不受影响) | +| `running`/`cancelling`,但正在 finalizing(`runs.steering_closed`) | 对一个真正全新的事件返回 `409 run_finalizing`;同一 key 的重放仍然返回已存在的事件,而不是 409——此时该 run 的图已经执行完毕、这次投递尝试里已经没有更多安全点了,但 run 行还没翻转为终止态/暂停态 | +| `paused`(等待 `interrupt`/`resume`) | 202,durably 写入;见下方"paused steering 迁移" | +| `succeeded` / `failed` / `cancelled` / `timed_out` / `dead_letter`(终止态) | 对一个新 key 返回 `409 run_terminal`,不产生任何事件 | +| 已被 `resume` 取代的旧 `run_id` | `409 run_superseded`,见下 | + +### cancel 优先于 steer + +无论 steer 请求先到还是后到,cancel 请求都会立即生效:一个 Run 上先 `/steer` 再 +`/cancel`,该 Run 依然会进入 `cancelling`(若该 Run 是 `paused`,则直接进入 +`cancelled`,见上文)并停止执行;已经 durably 写入的 steering +事件不会被回滚,只是不会再被任何存活的执行消费。steer 从不能撤销或延迟一次已经发出 +的 cancel。 + +### paused steering 在 atomic resume transaction 中迁移到新 Run + +`POST /v1/runs/{run_id}/resume` 会创建一条**新** Run(新的 `run_id`,通过 +`metadata.resumed_from_run_id` 关联旧 Run)。因为 worker 只会为它正在执行的 +`run_id` 拉取 pending steering,所以 resume 端点在**同一个原子事务**里做两件事: +创建新 Run,并把旧 Run 上所有仍处于 `pending`/`delivered` 的 steering 事件**迁移**到 +新 Run(保序、保留 `kind`/`payload`/`metadata`/`idempotency_key`,旧事件被标记为 +`superseded`)。这里的"原子"很关键:新 Run 在被任何 worker `claim` 之前,其迁移过的 +steering 必须已经全部就位——不存在"新 Run 已可被执行,但尚未迁移完成"的中间态窗口。 +之后由新 Run 的 worker 按正常路径投递、消费:paused 时提交的 steering 会在 resume +之后被新 Run 实际消费,而不是永远停留在 `pending`。 + +旧 Run 同时被标记为"已被 resume 取代"。resume 之后再对旧 `run_id` 调 `/steer` +会返回 HTTP 409 `run_superseded`(而不是再次静默积压出一条永远没人消费的事件), +提示调用方改为对新 Run 调用 `/steer`。 + +### `source_event_id`:跨多次 resume 的稳定关联 + +迁移到新 Run 的 steering 事件会获得一个全新的 `id`(它是新 Run 下的一条独立 durable +行),但其 `source_event_id` 字段回指客户端**最初** `/steer` 调用收到的那个 `id`。 +即便一个 Run 被反复 pause/resume 多次(A → B → C → ……),每一跳迁移都保留**最初** +的根 id,不会被中间某一跳的 id 覆盖。`created_at` 同样保留最初 durably accepted 的 +时间,因此最终 `run.steer.consumed` 的 `queue_latency_seconds` 包含了全部 pause +等待时间。客户端只需记住最初 `/steer` 返回的 `id`,即可在任意后续 Run 的 +`run.steer.accepted`/`run.steer.consumed` 事件的 `source_event_id` 字段里找到它, +无需跟踪中间产生的每一个 `run_id`。 + +### payload 大小限制 + +`payload` + `metadata` 序列化后大小受限(默认约 32KB,或服务端事件字节上限,两者先 +到者生效);超限返回 `413`/`422`,错误码 `payload_too_large`,不会产生任何 durable +写入。JSON 值必须是可安全序列化的普通类型(字符串、数字、布尔、null、数组、对象); +不支持二进制、循环引用或不可 JSON 化的自定义类型。 + +### accepted ≠ consumed + +`/steer` 的 202 响应只代表事件已 durably 写入 PostgreSQL 的 steering inbox +(`run.steer.accepted`,状态 `pending`)——**不代表图已经处理这条输入**。当配置了 +`EventBus`(进程内,或通过 `RedisEventBus` 使用 Redis)时,`/steer` 会发布一个可选的 +唤醒通知,正在运行的 worker 心跳循环会等待它,从而缩短发现延迟;它从不是唯一数据源, +也不是事件流里的一个具名生命周期事件(事件流中并不存在 `run.steer.available` 这个 +事件),只是一个低延迟提示。即使这个通知丢失,worker 仍会通过 PostgreSQL 自身的周期性 +安全点检查最终发现并消费该事件。只有当某个安全点的图代码真正调用 +`runtime.drain_steering()` 读取到该事件,并且这次消费被 `commit_steering_consumptions_if_owned()` +durably 提交后,才会出现 `run.steer.consumed`。调用方不能仅凭 202 就假设图已经 +"看到"这条输入——目前没有可以直接轮询某个 steering 事件状态的接口;请观察该 run 的 +事件流(`GET /v1/runs/{run_id}/stream`)或事件列表中的 `run.steer.consumed` 来确认 +真正被消费。 diff --git a/Wiki/content/docs/zh/concepts/meta.json b/Wiki/content/docs/zh/concepts/meta.json index 45ca64a..877a2fd 100644 --- a/Wiki/content/docs/zh/concepts/meta.json +++ b/Wiki/content/docs/zh/concepts/meta.json @@ -1,4 +1,4 @@ { "title": "核心概念", - "pages": ["architecture", "durable-execution", "agents-tools", "agent-skills"] + "pages": ["architecture", "durable-execution", "steering", "agents-tools", "agent-skills"] } diff --git a/Wiki/content/docs/zh/concepts/steering.mdx b/Wiki/content/docs/zh/concepts/steering.mdx new file mode 100644 index 0000000..9c45da1 --- /dev/null +++ b/Wiki/content/docs/zh/concepts/steering.mdx @@ -0,0 +1,213 @@ +--- +title: "运行中输入(Steering)" +description: "在图执行期间 durably 注入新输入,及其与 cancel/interrupt/resume 的区别" +--- + +## 要解决什么问题? + +一次长时间运行的 agent 执行往往不是一个封闭黑盒:人类或另一个系统可能想在**它仍在执行时** +交给它新的信息——"其实要用中文回复""客户刚打来电话,优先级变成 urgent 了""这是你缺的那个 +API key"——而不需要停止它、不需要等它自己走到某个天然的暂停点、也不能因为 worker 在接受 +输入之后马上崩溃就把这条输入弄丢。 + +Steering 就是 LingxiGraph 对这个问题的回答:`POST /v1/runs/{run_id}/steer` durably地 +接受一小段结构化 JSON,并在图代码下一次主动查看时把它交给图代码。LingxiGraph 保证的是 +**投递**本身——durable、有序、去重、在单个存活的 `SteeringChannel` 内原子 drain-once—— +但从不替业务决定"这条新输入意味着什么"。一条消息应该触发重新规划、只是追加到上下文, +还是应该被忽略(因为已经跑过那一步了),完全是业务图的逻辑。具体"drain-once"在跨 worker +崩溃时保证到什么程度、不保证什么,见下方"顺序、幂等与 drain-once"一节。 + +## 与 cancel、interrupt、resume 的区别 + +这四个机制都会在运行过程中"介入"一个 run,很容易被混为一谈,但它们回答的是不同的问题: + +| 机制 | 回答的问题 | 由谁发起 | 对 run 的影响 | +| --- | --- | --- | --- | +| `cancel` | "停止这个 run。" | 调用方 | run 进入 `cancelling` → `cancelled`;执行被协作式地终止 | +| `interrupt` | "图代码现在需要一个还没有的值,拿到之后再继续。" | 图代码(`interrupt(value)`) | run 进入 `paused`;执行在**那个调用点**挂起,直到 `resume` 提供值 | +| `resume` | "用这个值继续一个已暂停的 run。" | 调用方 | 创建一条**新** run,从 `interrupt` 挂起处继续 | +| `steer` | "这是一条新输入,随时方便的时候看一下。" | 调用方 | run 按自己的节奏继续执行;图决定何时/是否查看新输入 | + +`interrupt`/`resume` 是嵌在图内某个特定位置的"请求-响应"模式:图显式停下来等待,调用方 +必须提供正好是那个调用点在等的值。Steering 正相反:它是**推送式**的、异步的、不绑定任何 +特定调用点,也不会让任何东西停下来。一个 run 甚至不需要处于 `running`(`pending`、还没被 +worker claim 的 run 同样能 durably 接受 steering)就能收到一条 steering。 + +Steering 也可以和另外三者组合使用:一个 run 可以在 running 时被 steer,也可以在 paused +时被 steer(见下方"跨暂停迁移"一节),而且 steering 永远不会阻塞或延迟一次 cancel。 + +## 数据流:PostgreSQL durable inbox → worker → SteeringChannel → runtime + +```mermaid +sequenceDiagram + participant C as 调用方 + participant API as API 服务 + participant PG as PostgreSQL(run_steering_events) + participant R as Redis(可选) + participant W as Worker + participant Ch as SteeringChannel + participant N as 节点代码 + + C->>API: POST /runs/{id}/steer + API->>PG: INSERT pending steering 行 + PG-->>API: durably 提交 + API-->>C: 202 run.steer.accepted + API-->>R: best-effort NOTIFY(可选) + R--)W: 唤醒提示(可能丢失) + W->>PG: 在安全点轮询/查看 pending steering + PG-->>W: 仍为 pending 的行 + W->>Ch: 摄入内存中的 channel + N->>Ch: 在安全点调用 drain_steering() + Ch-->>N: 有序、去重后的事件 + N-->>W: 消费记录(node、namespace、task_id、latency) + W->>PG: commit_steering_consumptions_if_owned()(状态更新 + run.steer.consumed,同一事务) +``` + +PostgreSQL 的 `run_steering_events` 表是每一条 steering 事件是否存在及其状态的唯一真相 +来源。schema 保留了 `delivered` 这个状态值以便将来扩展,但目前实现的流程从不会写入 +它——真实的状态转换是 `pending` → `consumed`(通过一次原子的 +`commit_steering_consumptions_if_owned()` 调用直接完成),或者 `pending`/`delivered` → +`superseded`——原因可能是它所属的 paused run 被 resume(其仍 pending 的 steering 被迁移到 +新 run),也可能是它在该 run 到达终结 finalization 边界时仍未被消费(原因不止一种,见下文 +"终结流程与 steering 准入边界"一节)。请把 `delivered` 当作代码里 +可能需要读取/放行的一个状态值,而不是当前任何路径会产生的状态。Redis +通知只是为了降低轮询延迟的优化手段;丢失它从不会丢失一条 steering 事件——worker 在每个 +安全点的周期性检查会退化为直接读 PostgreSQL。`SteeringChannel` 是 worker 用来把已取到的 +事件以原子 drain-once 语义交给图代码的进程内、按 run 划分的结构;它本身不是 durable 的, +worker 重启或接管一个 run 时会从 PostgreSQL 重新构建它。 + +## "安全点"由应用选择,不是任意强制抢占 + +LingxiGraph 从不会在节点函数执行到一半时强行打断它去投递 steering。它保证的是:只要图 +代码调用 `runtime.drain_steering()`,读到的就是当前最新、尚未被消费过的事件集合——但 +**在节点代码内部的哪一行**调用它,完全由应用决定。一个节点可以在开头调用一次,可以在 +循环里反复调用,也可以完全不调用。 + +LingxiGraph 真正控制的是 executor **何时把一个节点当作新任务来调度**——节点开始前、下一个 +superstep 前、重试前、resume 之后——每一次这样的调度切换都是"自上次以来摄入的 steering +变得可见"的机会。这是刻意的设计边界,不是缺口:真正抢占任意用户代码会破坏运行时其余部分 +依赖的确定性重放保证。 + +## 顺序、幂等与 drain-once + +- 同一个 run 的 steering 事件按内部 `sequence` 严格排序,序号在 durably accepted 时分配; +- 在**单个存活的 `SteeringChannel`** 内——同一个 worker、同一个进程内实例,跨节点的任意 + 次内部重试——`drain_steering()` 永远不会返回同一事件两次:原子 drain-once; +- **这不是系统级的恰好一次保证。** 如果 worker 在 `drain_steering()` 已经把某条事件交给 + 节点、但 `commit_steering_consumptions_if_owned()` 尚未把这次消费 durably 记录下来之前崩溃(或 + 它的 lease 被重新分配),PostgreSQL 里那一行仍然是 `pending`/`delivered`——接管该 run、 + 从 PostgreSQL 重建 channel 的新 worker 会合理地重新投递同一条事件。跨越一次崩溃, + steering 的投递语义是**至少一次(at-least-once)**,不是恰好一次。如果业务要对 drain + 到的事件执行不可逆的副作用(扣费、有副作用的外部 API 调用),应该用 `event.id`(或者 + 经过暂停/恢复迁移后的根 `source_event_id`,见下文)自行做幂等控制,而不能假设 + drain-once 就等于"只处理一次"; +- 通过 `idempotency_key` 去重:对同一个 `(tenant_id, run_id, idempotency_key)` 提交两次 + 会返回**原**事件而不是创建第二条;这个去重在 `drain_steering()` 之后依然有效——原事件被 + drain 之后再次重放同一提交,返回的仍然是那条(现已 consumed 的)原事件,而不是一条新的 + pending 事件。这只对 accept 一侧(重复的 `/steer` 调用)去重,本身并不能让跨崩溃的 + drain 一侧变成恰好一次——见上一条; +- consumed-commit(`commit_steering_consumptions_if_owned()`)与图自身为消费它的那个 superstep + 所做的 checkpoint commit 是两个独立的 durable 操作,不是同一个分布式事务(见下方 + "consumption 与 checkpoint commit 的当前关系")——因此不存在覆盖"steering 投递 + 图 + 状态"两者的系统级恰好一次处理保证。 + +## 重试、worker 崩溃与 Redis 丢失 + +- **worker 重试:** `commit_steering_consumptions_if_owned()` 是幂等的——一个 worker 提交某批 + consumption 后、在看到 ack 之前连接断开并重试同一批,不会产生重复的 + `run.steer.consumed`;只有在这次调用中真正发生状态转换的 steering 行才会产生一条; +- **worker 崩溃:** 因为投递状态存放在 PostgreSQL 里,而不是崩溃 worker 的内存里,接管该 + run 的新 worker 会从 PostgreSQL(`pending`/`delivered` 行)重新构建完整状态并继续。 + worker 崩溃不会静默丢失任何 steering 事件; +- **Redis 丢失:** Redis 只是加速器,从不是正确性所依赖的东西。如果 pub/sub 通知从未 + 到达——连接重置、Redis 重启、消息丢失——worker 自身在每个安全点对 PostgreSQL 的周期性 + 检查仍然会发现并投递该事件,只是延迟略高,而不是彻底丢失更新。 + +## 终结流程与 steering 准入边界 + +一旦图执行结束,拥有该 run 的 worker 会先原子地封闭新的 steering 准入 +(`close_steering()`),然后才开始对图已经消费的内容做最终 durable flush。从这一刻起, +新的 `/steer` 调用会收到一个稳定的 `409 run_finalizing`——因为已经不再存在任何安全点能让 +图消费一条新接受的事件。 + +但这仍然留下一处缺口:某次 `/steer` 调用可能恰好在 `close_steering()` 抢到同一把 +run 行锁**之前**先抢到锁并完成 durable 提交——即便此时图早已产生终态输出,本该负责摄入 +该事件的心跳循环也早已停止。final flush 完成之后,Runtime 会专门检查这种情况:任何仍处于 +`pending`/`delivered` 的 steering 行都会被 durably 转为 `superseded`(原因为 +`unconsumed_at_final_boundary`),并伴随一条 `run.steer.superseded` 生命周期事件,二者与该 +run 的终结状态写入在**同一个** lease/attempt-fenced 事务中一起提交——不再是两次分别提交、 +可能在崩溃或 lease 丢失之间彼此脱节的写入(见 `finalize_run_with_steering_disposition_if_owned`)。 +随后该 run 会以之前已经算出的结果(`succeeded`、`failed`、`dead_letter` 等——任何终态; +`paused` 不属于此终结路径,因为它本来就从来不是一种终态,见下文)正常终结——**不会**仅仅 +因为存在一条 steering 事件就被强制进入重试循环。 + +`unconsumed_at_final_boundary` 是一个刻意保持笼统的原因标签,**并不**声称发生了竞争。它 +涵盖两种 Runtime 目前无法从现有数据中区分的情况:上面描述的锁竞争,以及更常见的情况—— +一个本就合法地从不调用 `drain_steering()` 的图(见上文"安全点由应用选择",这是文档明确 +允许的选择),其对应的行其实一直可见、只是从未被消费。无论哪种情况,结果都一样:该 run +会在第一次投递尝试就正常终结,就像这条 steering 事件从未到达过一样,而该事件的最终归宿会 +被记录为 `superseded`,绝不会被静默丢弃。 + +**最终归宿策略,精确表述如下:** 一个 terminal run **自身**的终结决策绝不能让一条未被消费 +的 steering 事件被静默搁置、没有任何 durable、可观测的归宿——这正是上面机制所保证的。任何 +真正终结的 `finish` 意图——由图自身的结果计算产生的 `succeeded`、`failed`、`cancelled` 或 +`dead_letter`——都会封闭 steering 准入并 supersede 遗留行,与上文描述完全一致;唯一被排除 +在外的是 `paused`(见下一段)。也就是说,一次普通的 `failed`/`dead_letter` 结果本身**不会** +让 steering 停留在 `pending`。 + +唯一仍可能出现这种情况的场景比"被 dead-letter 的 run 就会留下 pending steering"要窄得多: +特指**最终 steering flush 本身就无法持久提交**的那次 run(`_retry_or_dead_letter()`——见 +`Worker._execute` 里 final flush 之后的处理)。这条路径根本不会走到 +`finalize_run_with_steering_disposition_if_owned()`——它会转而让这次投递尝试走普通的 +retry/dead-letter 流程,因为此时写下任何归宿(无论是否 superseded)都会要么报告一个虚假 +状态,要么静默丢失已经 drain 但尚未提交的消费记录。只有在这种特定情况下,一个 `dead_letter` +的 run 才可能仍然留有 `pending` 的 steering——这是刻意保留的 durable 历史记录,而不是 bug: +之后如果该 run 被 `/redrive`,这条仍 pending 的行会像任何其他排队中的 steering 一样被重新 +拾起。一次普通的、由客户端发起的 **cancel** 不在这条恢复路径之内:`/redrive` 不接受 +`cancelled` 的 run,因此留在 `cancelled` run 上的 `pending` steering 目前没有任何自动的 +未来投递机会——如果确实需要恢复,那是这套机制之外的运维/API 任务,而不是由 Runtime 自动 +解决的。 + +## 并行 superstep、`Send` 与 subgraph 共享同一个 channel + +一个 run 的 `SteeringChannel` 在同一 superstep 内的所有任务之间共享,包括并行分支和 +`Send` 扇出的任务,也跨同一 run 内的 subgraph 节点边界共享。drain 对每个事件是互斥的 +(一个事件永远只能被某一个任务 drain 到——不存在跨并行任务的重复处理),但并不限定在 +某一个特定节点:run 的任务图中任意位置、任意嵌套深度的节点都可以调用 +`drain_steering()`,看到的是同一份 durable、有序的积压。最终 `run.steer.consumed` 事件上 +的 `queue_latency_seconds` 与 `task_id`/`namespace` 精确记录了究竟是哪个 task、在哪个 +subgraph 深度,最终消费了这条事件。 + +## consumption 与 checkpoint commit 的当前关系(明确的 scope 边界) + +在当前版本中,把一条 steering 事件标记为 `consumed`(通过 +`commit_steering_consumptions_if_owned()`)与图自身为消费它的那个 superstep 所做的 checkpoint +commit,是两个独立的 durable 操作,**没有**被包在同一个分布式事务里。这在实践中意味着: +如果某个节点 drain 了 steering 之后、其 checkpoint 尚未写入之前进程就退出了, +checkpoint 级别的重试会重新执行该节点——但那条 steering 事件已经被标记为 +`consumed`,重新执行的节点通过 `drain_steering()` 不会再次看到它。需要让 drain 到的 +payload 在 superstep 中途崩溃后依然存活的节点,应该把需要的内容作为该节点返回值的一部分 +持久化进 state(就像对待任何其他外部观测到的输入一样),而不是指望重试后再从 steering +channel 里重新读到它。这是当前版本**刻意**做出的 scope 收窄,不是"consumption 与 +checkpoint 是事务性的"这样的承诺,未来版本可能会重新审视这一点。 + +## Steering 明确不做什么 + +Steering 是投递机制,不是规划机制。它不会: + +- 替业务决定一条新消息是否应该触发重新规划、取消正在进行的 tool 调用,或改变当前节点的 + 控制流——这完全是业务图的逻辑; +- 保证 accept 到 consume 之间的低延迟——只保证一旦消费发生,它是 durable 且可通过 + `run.steer.consumed` 观测到的; +- 提供直接修改已提交 state 的方式;图必须显式地把 drain 到的 payload 折叠进自己的 + state 更新里。 + +## 嵌入式 / library 模式下的降级语义 + +当 LingxiGraph 作为嵌入式库使用、不经过 Agent Server(没有 worker 进程,没有基于 +PostgreSQL 的 repository)时,`compiled_graph.steer(run_id, ...)` 和 +`runtime.drain_steering()` 仍然可以对进程内的 `SteeringChannel` 生效,但 durability +会降级为"和承载它的这个进程一样 durable":没有跨进程投递、没有 worker 崩溃恢复、也没有 +基于 PostgreSQL 的 `run.steer.accepted`/`run.steer.consumed` 审计轨迹。一次从未接触 +steering 的普通 `invoke()` 不会因此产生任何额外开销,也不会泄漏 channel 状态——该层面的 +精确保证参见 Agent Server 实现引用的嵌入式生命周期测试。 diff --git a/Wiki/content/docs/zh/operations/meta.json b/Wiki/content/docs/zh/operations/meta.json index 307b52f..10f6162 100644 --- a/Wiki/content/docs/zh/operations/meta.json +++ b/Wiki/content/docs/zh/operations/meta.json @@ -1,4 +1,4 @@ { "title": "运维与项目", - "pages": ["configuration", "security-observability"] + "pages": ["configuration", "security-observability", "steering-operations"] } diff --git a/Wiki/content/docs/zh/operations/steering-operations.mdx b/Wiki/content/docs/zh/operations/steering-operations.mdx new file mode 100644 index 0000000..456685a --- /dev/null +++ b/Wiki/content/docs/zh/operations/steering-operations.mdx @@ -0,0 +1,225 @@ +--- +title: "运行中输入(Steering)的运维" +description: "迁移上线顺序、PostgreSQL 作为真相来源、延迟、重试与回滚边界" +--- + +## 迁移上线顺序 + +运行中输入(issue #16)在基础 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` 血缘标记" | + +按标准 Alembic 路径应用: + +```bash +alembic upgrade head +``` + +四者都是纯新增(`ADD COLUMN IF NOT EXISTS`、`CREATE INDEX IF NOT EXISTS`、可为空/ +`NOT NULL DEFAULT FALSE` 列),可以在部署新的 server/worker 二进制之前先对存活数据库 +执行——早于 `0004`/`0005` 的旧二进制根本不会读写 `steering_closed`/`superseded_by_run_id`, +行为与这些列不存在时完全一致。零停机上线顺序: + +1. 在旧的 server/worker 二进制仍在提供服务时执行 `alembic upgrade head` + (新增 `run_steering_events`、`source_event_id`、`steering_closed` 与 + `superseded_by_run_id`); +2. 再滚动发布新的 server 与 worker 二进制。 + +不要颠倒这个顺序:在列存在之前就尝试写入 `source_event_id`/`steering_closed`/ +`superseded_by_run_id` 的二进制会插入/更新失败。因为本项目的迁移是 forward-only 的(`downgrade()` 会直接抛错),schema +层面没有自动回滚路径——见下方"升级与回滚边界"。 + + +`PostgresRepository.setup()` 也会直接应用同一批 SQL 文件(测试和嵌入式/开发环境会用到)。 +这条路径**不能**替代在真实部署中驱动 Alembic revision 链:生产上线应该且只应该执行 +`alembic upgrade head`,并且必须走到 `0005_superseded_by_run_id`,而不是停在 +`0002_steering`、`0003_steering_source_event` 或 `0004_steering_closed`。 + + +## `steering_closed` finalization 闸门 + +一旦 worker 的图执行到达终态/暂停结果,该 run 可能还会短暂地继续读作 +`running`/`cancelling`,同时 worker 把图已经消费掉的 steering durably flush 出去、 +提交最终状态——此时已经没有更多图安全点可以消费任何新输入了。`runs.steering_closed` +是持有该 run 的 worker(与状态写入一样,基于自己的 `lease_owner`/`attempt` 加锁)在执行 +结束的瞬间、final flush 开始之前设置的一个布尔值。 + +- 当 `steering_closed` 为 `false` 时,`POST /steer` 会像 `0004` 之前一样被正常接受; +- 一旦当前 attempt 的 `steering_closed` 变为 `true`,一个**全新**的 `POST /steer` + (尚无匹配 Idempotency-Key 记录)会被拒绝,返回 `409 run_finalizing`——这是一个稳定的、 + 与终态 run 的 `409` 不同的独立错误,方便客户端区分"这个 run 正在收尾"和"这个 run + 已经彻底结束了"; +- 一次**重放**的 `POST /steer`——即 `(tenant_id, run_id, Idempotency-Key)` 与某个已经 + durably 接受过的事件完全相同——永远返回该已存在事件的当前状态,绝不会返回新的 + `409`,无论 `steering_closed`/终态/superseded 状态如何。见下方"幂等重放在所有准入闸门 + 前都是安全的"; +- `paused` 结果永远不会关闭闸门:暂停并不像终态那样"没有安全点剩下"——对一个 paused + run 调用 `/steer` 是设计中刻意支持的行为(其 pending steering 会在 `/resume` 时迁移到 + 新 run 上); +- 一次被放弃的投递尝试关闭的闸门不会泄漏到下一次尝试:`claim_run` 在每次新的 claim 上 + 都会把 `steering_closed` 重置为 `false`,所以被重试的 run 会重新正常接受 steering。 + +仅仅关闭闸门本身并不足以防止一条永久 pending 的 steering 行:`/steer` 请求有可能在 +`close_steering()` 之前赢得该 run 行的锁——即使此时图早已执行完毕。真正能捕获这种情况的 +是 worker 在 flush 之后的检查(`finalize_run_with_steering_disposition_if_owned()`,见 +`Worker._execute`)——如果 final flush 已经提交了它能提交的一切之后,仍然存在 +`pending`/`delivered` 的行,该行会被 durably 转为 `superseded`(原因为 +`unconsumed_at_final_boundary`——这是一个刻意保持笼统的标签,**并不**声称发生了竞争;它 +同样覆盖更常见的情况:图从始至终就没有调用过 `drain_steering()`),并伴随一条 +`run.steer.superseded` 生命周期事件,二者与 run 的终结状态写入在**同一个** +lease/attempt-fenced 事务中一起提交——不再是两次分别提交、可能在崩溃或 lease 丢失时 +彼此脱节的写入。随后该 run 会以之前已经算出的结果正常终结——**不会**仅仅因为存在一条 +steering 事件就走 retry/dead-letter 循环,因为那样会给一个本就合法地从不调用 +`drain_steering()` 的图强加一次不必要的重跑。**一个终态 run 自身的终结决策绝不能让一条 +未被消费的 steering 事件被静默搁置、没有任何 durable、可观测的归宿**——一次由图自身计算出的 +普通 `failed`/`dead_letter` 结果,会像 `succeeded`/`cancelled` 一样封闭准入并 supersede +遗留行。唯一更窄的例外是:某次 run 的**最终 steering flush 本身就无法持久提交** +(`_retry_or_dead_letter()`),这条路径根本不会走到 +`finalize_run_with_steering_disposition_if_owned()`,而是转而让这次投递尝试走普通的 +retry/dead-letter 流程——只有在这种情况下,一个 `dead_letter` 的 run 才可能合法地仍然携带 +`pending` 的 steering 行,作为刻意保留的 durable 历史记录,之后由 `/redrive` 像任何其他排队 +中的 steering 一样重新拾起。但 `/redrive` 只接受 `failed`/`dead_letter` 状态的 run——一次 +普通的、由客户端发起的 **cancel** 不在这条恢复路径之内,因此留在 `cancelled` run 上的 +`pending` steering 目前没有任何自动的未来投递机会。完整策略见 steering 概念页"终结流程与 +steering 准入边界"一节。 + +## `superseded_by_run_id` 血缘标记 + +当 `/resume` 从一个 `paused` 的 run 创建出后继 run 时,旧 run 需要一种可靠的方式来表示 +"我已经被取代了——请对新 run 执行 steer/cancel/resume"。在 `0005` 之前,这个标记存放在 +`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)。 + +`runs.superseded_by_run_id`(由 `0005_superseded_by_run_id` 新增)是一个类型化、可为空的列, +取代该 key 成为唯一的真相来源: + +- 它只在创建后继 run、迁移旧 run 待处理 steering 的同一个受 fence 保护的 resume 事务内 + (`resume_run_with_pending_steering()` 及其 PostgreSQL 对应实现)被原子地写入一次—— + 永远不会从 `RunCreate.metadata` 派生,也不能通过它写入。 +- `/steer` 的准入检查、`/cancel` 的 superseded 检查,以及 resume 冲突闸门,现在都直接读取 + `run.superseded_by_run_id`;它们都不再为此决策查询 `metadata`。 +- 调用方在普通 `RunCreate` 上提交 `metadata.superseded_by_run_id` 现在对控制流完全没有 + 影响——这个 key 是惰性的,最多作为普通用户 metadata 存储,永远不会被拷贝进类型化列。 +- 升级兼容性:升级到本 PR 之后,一个升级前就存在、恰好携带同名用户 metadata key 的历史 + run,不会被重新解释为系统血缘状态——类型化列对每一条既有记录都从 `NULL` 开始,只有未来 + 真正发生的 resume 才会填充它。 + +## 幂等重放在所有准入闸门前都是安全的 + +`submit_steering()`(内存版和 PostgreSQL 版仓库都一样)会先检查是否已经存在匹配 +`(tenant_id, run_id, Idempotency-Key)` 的事件,**然后**才走终态/`steering_closed`/ +superseded 这些准入闸门,而不是反过来。如果客户端的 `POST /steer` 已经 durably +提交,但 202 响应从未真正送达(连接中断、代理超时),使用相同 key 的标准幂等重试会 +返回该事件当前的持久化状态——id 和 sequence 都不变——即使该 run 此后已经进入 +finalizing、terminal 或 superseded 状态。只有一个真正**全新**的 key(这个 run 从未见过 +的 key)才会受这些闸门约束;那才是一次真正的准入决策,仍然会得到相应的 `409`。 + +## PostgreSQL 是真相来源,Redis 只是加速器 + +每一条 steering 事件是否存在及其状态,都存放在 PostgreSQL 的 `run_steering_events` 表里。 +Redis pub/sub 只用于缩短"事件变为 durable"和"worker 注意到它"之间的延迟——从不会被用来 +判断一个事件是否存在,它的丢失(Redis 重启、连接断开、NOTIFY 被漏掉)也从不会丢失一条 +事件。运维可以把 Redis 视为对 steering 正确性完全可丢弃的组件:即使 Redis 宕机或被驱逐, +steering 仍能正常工作,只是 worker 的发现方式退化为在每个安全点周期性轮询 PostgreSQL, +而不是近乎即时的唤醒。 + +## Worker heartbeat 对 steering 投递延迟的影响 + +一个 worker 只会在其持有租约的 run 到达下一个安全点时(节点开始、下一 superstep、重试、 +resume 之后)去拉取该 run 新产生的 steering。因此投递延迟主要受两个因素支配: + +- **该 run 多久到达一次安全点。** 一个正在做单次长耗时 tool 调用的节点,在调用返回之前 + 看不到调用期间提交的 steering——直到该节点结束,或节点自己主动调用了 + `drain_steering()`; +- **Redis 通知不可用时的 worker 轮询/heartbeat 间隔。** 更大的间隔用更低的 PostgreSQL + 轮询负载换取更高的 steering 延迟;可参照 `docs/api.md` 与部署时 + `lingxigraph worker` 的相关参数调优。 + +## 如何诊断长期 pending 的 steering + +如果某条 steering 事件停留在 `pending` 状态的时间远超预期: + +1. 先确认该 run 确实已被 claim 并在推进:`GET /v1/runs/{run_id}`——一个仍处于 `pending` + 的 run 还没有 worker 在执行它,其上的 steering 在等待被 claim,而不是 bug; +2. 检查该 run 的图代码是否真的调用了 `runtime.drain_steering()`,以及调用频率—— + steering 投递完全取决于图是否选择去查看,这是应用自选安全点模型的一部分(详见 + Steering 概念页); +3. 直接查询 `run_steering_events` 该行的 `status`、`created_at`、`source_event_id`, + 看它是否是从更早、曾经 paused 的 run 迁移过来的——最终 `run.steer.consumed` 事件上的 + `queue_latency_seconds` 会包含所有累积的暂停等待时间,这是预期行为,不是 bug; +4. 检查 worker 日志/指标中是否有反复的 `commit_steering_consumptions_if_owned()` + 失败——该操作重试是安全的(幂等,并且基于 worker 自己的 `lease_owner`/`attempt` + 加锁,见"`steering_closed` finalization 闸门"),但持续失败说明存在值得告警的数据库 + 连通性问题; +5. 如果客户端在 `/steer` 上收到 `409 run_finalizing`,说明请求到达时该 run 的图已经 + 执行完毕——检查当前 attempt 的 `runs.steering_closed`(`true` 表示闸门已关闭),并对 + 一个全新的 run 重试,而不是同一个 run。这里用相同 Idempotency-Key 重放永远是安全的, + 即使在闸门关闭之后也一样——见"幂等重放在所有准入闸门前都是安全的"; +6. 如果某条 steering 事件的 `run.steer.superseded` 事件数据里 `reason` 是 + `unconsumed_at_final_boundary`(而不是 `resume_transfer`),这正是终结边界修复按预期工作:该事件 + 已经 durably 被接受,但该投递尝试已经没有安全点可以消费它了,所以它被赋予了一个 + durable、可观测的 `superseded` 归宿,而不是让 run 带着一条永久 pending 的行走向终态, + 也不会被强加一次不必要的重跑。这本身不是 bug;但如果同一个 run 反复出现 + `unconsumed_at_final_boundary`,就值得在应用/图层面排查为什么 steering 总是在图到达终止节点之后 + 才到达。 + +## 正常的 `run.steer.accepted → consumed` 生命周期 + +1. `POST /steer` durably 插入一条 `pending` 行,并(202 响应中)产生 + `run.steer.accepted`; +2. 持有该 run 租约的 worker 发现这条行(通过 Redis 提示或 PostgreSQL 轮询),把它摄入 + 该 run 的内存 `SteeringChannel`; +3. 图代码在某个安全点调用 `drain_steering()` 并收到它; +4. worker durably 提交这次消费(`commit_steering_consumptions_if_owned()`,基于 worker + 自己的 `lease_owner`/`attempt` 加锁),在**同一事务**中把该行原子地转为 `consumed` + 并产生 `run.steer.consumed`。一个已经不再持有该 lease/attempt 的 worker(比如在 + heartbeat 或 final flush 期间发生了 lease 被接管)什么都不会写入——从这一刻起该行 + 完全由新的持有者负责。 + +这条链路中没有任何一步是可选或可打乱顺序的;客户端应该只把第 4 步的 +`run.steer.consumed` 事件当作"确实被处理"的证据。 + +## 为什么 worker/DB 瞬时故障不会丢失一次 consumption,以及如何观察重试 + +状态更新与 `run.steer.consumed` 事件的插入发生在同一个 PostgreSQL 事务内。如果事务已经 +提交,但连接在 worker 看到确认之前断开,worker 的重试逻辑会重新发送同一批 consumption。 +因为 `commit_steering_consumptions_if_owned()` 是幂等的——只为这次调用中真正发生状态 +转换的行产生 lifecycle 事件,并且只有在调用方仍然持有该 run 当前 lease/attempt 时才会 +写入任何东西——针对已经 consumed 的行重放同一批次是安全的空操作:状态保持 +`consumed`,也不会产生第二条 `run.steer.consumed`。运维可以在 worker 日志里观察到这种 +模式:同一批 steering id 反复出现 `commit_steering_consumptions_if_owned` 调用,但 +`run_events` 里对应的 `run.steer.consumed` 数量并没有增长——只要重试最终会停止,这就是 +预期行为,而不是卡住的重试循环。 + +## 升级与回滚边界 + +本项目的迁移是 forward-only 的:每个 revision 的 `downgrade()` 都会直接抛错,而不是 +尝试撤销变更。这是刻意的设计:真正回滚 `run_steering_events` 表或其新增列,要么会丢失 +durable 的 steering 历史,要么需要把正在进行的 run 与新二进制已经不再预期的 schema +重新对齐,代价过高。实践含义: + +- 永远不要把"回滚 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 拒绝逻辑。应该把 + 新的 steering/lineage 控制语义视为"只有当所有控制面实例都升级到新二进制后才可用", + 而不是 schema 迁移一落地就生效。如果一个二进制已经真正执行过这些语义(例如某次 + resume 已经写入了 `superseded_by_run_id`),把它回滚成旧版本并不会撤销这次转换—— + 旧二进制只是从此不再强制执行它。如果必须在 steering 正在被使用时回滚二进制,应先 + 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)中验证过,再应用到生产环境,而不是依赖"出问题了还能撤销"。 diff --git a/Wiki/scripts/check-docs.mjs b/Wiki/scripts/check-docs.mjs index 4229b9f..67aad92 100644 --- a/Wiki/scripts/check-docs.mjs +++ b/Wiki/scripts/check-docs.mjs @@ -4,7 +4,7 @@ import process from 'node:process'; const root = path.resolve(process.cwd(), 'content/docs'); const locales = ['zh', 'en']; -const expectedPageCount = 24; +const expectedPageCount = 26; async function walk(directory) { const entries = await readdir(directory, { withFileTypes: true }); diff --git a/docs/api.md b/docs/api.md index d7f2fc5..860ef83 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,7 +10,7 @@ OpenAPI 是 Agent Server 的协议真相。默认服务地址为 `http://localho | Graph registry | `GET /v1/graphs`、`GET /v1/graphs/{id}` | | Assistants | `POST/GET /v1/assistants`、`GET/PATCH/DELETE /v1/assistants/{id}` | | Threads | `POST/GET /v1/threads`、`GET/PATCH/DELETE /v1/threads/{id}`、state、history、fork、runs | -| Runs | threaded/stateless create、get/list、join、resume、cancel、redrive、stream | +| Runs | threaded/stateless create、get/list、join、resume、cancel、redrive、steer、stream | | Store | `POST /v1/store/batch`、`GET /v1/store/search` | | Schedules | create/list/update/delete | | Interop | `/a2a/{assistant_id}`、`/mcp` | @@ -54,6 +54,87 @@ curl -X POST http://localhost:8124/v1/runs \ `X-Tenant-ID` 只在 `LINGXIGRAPH_INSECURE_DEV_AUTH=true` 时生效。生产 tenant 必须从已验证 JWT claim 派生,绝不信任调用方自报 header。 +## 运行中输入(Steering) + +`POST /v1/runs/{run_id}/steer` 在 Run 执行期间durably地注入新的结构化输入,不等待完成、 +不强制取消、也不需要先暂停。请求体: + +```json +{"kind": "user_input", "payload": {"message": "..."}, "metadata": {}, "idempotency_key": "..."} +``` + +语义: + +- 202 只代表已durably写入 PostgreSQL 的 steering inbox,不代表图已经处理;配置了 + `EventBus`(进程内或 Redis)时会发布一个可选的低延迟唤醒通知供 worker 心跳循环等待, + 但它不是事件流中的具名事件(不存在 `run.steer.available` 事件),也从不作为唯一来源—— + worker 自身的周期性 PostgreSQL 安全点检查始终是最终保证。 +- `running`/`queued`/`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,见下)。 +- `Idempotency-Key` header 或 body 的 `idempotency_key` 二选一;同一 `(tenant, run, key)` + 只会创建一条事件——**且这条幂等重放检查发生在上面所有准入判断之前,而不是之后**: + 同一个 key 的重试永远返回已存在事件的当前持久化状态(`id`/`sequence` 不变),即使该 + Run 此后已经进入 `run_finalizing`、终止态、或被 superseded,也绝不会返回一个新的 + 409。只有真正全新的 key 才会受终止态/`run_finalizing`/superseded 这些准入检查约束。 +- cancel 优先于 steer:先 steer 后 cancel,cancel 依然立即生效,steer 从不撤销/延迟取消。 +- payload+metadata 序列化后大小受限(默认约 32KB / 服务端事件字节上限,先到者生效)。 + +**Paused run 的 steering 归属哪个 run_id?** `POST /v1/runs/{run_id}/resume` 会创建一条 +*新* Run(新的 `run_id`,通过 `metadata.resumed_from_run_id` 关联旧 Run),worker 只会为它 +正在执行的 run_id 拉取 pending steering。因此 resume 端点在创建新 Run 后,会原子地把旧 +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`。 + +图内部通过 `runtime.has_steering`、`runtime.peek_steering()`、`runtime.drain_steering()` +在安全点(节点开始前、节点完成后、下一 superstep 前、重试前、resume 之后)读取; +LingxiGraph 只保证durable delivery、顺序、去重与安全消费,"新消息是否意味着重新规划"完全 +由业务图决定。嵌入式/无 Server 场景下同一套 API 生效:`compiled_graph.steer(run_id, ...)`。 + +**"安全点"由谁定义?** LingxiGraph 保证的是 channel 本身的一致性(原子 drain-once、去重、 +顺序)以及*调用* `drain_steering()` 这件事发生时机的新鲜性——每次节点被 executor 调用时, +它读到的都是当前最新、未被消费过的事件集合。但**在节点函数内部的哪一行代码调用 +`drain_steering()`,完全由应用节点自己决定**:executor 不会在节点函数内部插入任何强制 +边界(例如"只能在函数开头调用一次"),也不会暂停用户代码的执行去插入安全点。这是刻意的 +设计选择而非缺口:真正的边界语义体现在 executor *何时把节点当作一个 task 来调度* +(节点开始前、下一 superstep 前、重试前、resume 之后见上),而不是节点内部的执行位置。 + +**`source_event_id`:跨 resume 的稳定关联。** 迁移到新 Run 的 steering 事件会获得一个 +全新的 `id`(它是新 Run 下一条独立的durable行),但其 `source_event_id` 字段回指客户端 +最初 `/steer` 调用收到的那个 `id`。即使一个 Run 被反复 pause/resume 多次(A → B → C → +……),每一跳迁移都保留**最初**那个根 id(`source_event_id = 上一跳的 source_event_id +或上一跳的 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。 + +**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 事件。 + ## SSE 续传 ```text diff --git a/migrations/env.py b/migrations/env.py index ed108f5..4f92a48 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -14,9 +14,29 @@ config.set_main_option("sqlalchemy.url", dsn.replace("%", "%%")) +def _use_psycopg_driver(url: str) -> str: + """Force the psycopg (v3) driver, matching the project's declared dependency. + + Plain ``postgresql://`` DSNs make SQLAlchemy default to the psycopg2 + dialect, which is not installed anywhere in this project (the ``postgres`` + extra pins ``psycopg[binary]``, i.e. psycopg 3). Callers -- including + tests that set ``sqlalchemy.url`` directly via ``Config.set_main_option`` + -- may pass a plain ``postgresql://`` or ``postgres://`` DSN, so normalize + the scheme here rather than relying on every caller to do so. + """ + if url.startswith("postgresql+"): + return url + if url.startswith("postgresql://"): + return "postgresql+psycopg://" + url[len("postgresql://") :] + if url.startswith("postgres://"): + return "postgresql+psycopg://" + url[len("postgres://") :] + return url + + def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") context.configure( - url=config.get_main_option("sqlalchemy.url"), + url=_use_psycopg_driver(url) if url else url, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) @@ -25,8 +45,12 @@ def run_migrations_offline() -> None: def run_migrations_online() -> None: + section = dict(config.get_section(config.config_ini_section, {})) + url = section.get("sqlalchemy.url") + if url: + section["sqlalchemy.url"] = _use_psycopg_driver(url) connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), + section, prefix="sqlalchemy.", poolclass=pool.NullPool, ) diff --git a/migrations/versions/0002_steering.py b/migrations/versions/0002_steering.py new file mode 100644 index 0000000..0c8e725 --- /dev/null +++ b/migrations/versions/0002_steering.py @@ -0,0 +1,29 @@ +"""Durable mid-run steering inbox (issue #16).""" + +import os +import re +from importlib.resources import files + +from alembic import op + +revision = "0002_steering" +down_revision = "0001_lingxigraph_v1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + schema = os.getenv("LINGXIGRAPH_POSTGRES_SCHEMA", "lingxigraph") + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", schema): + raise ValueError("invalid LINGXIGRAPH_POSTGRES_SCHEMA") + sql = ( + files("lingxigraph.server") + .joinpath("migrations/0002_steering.sql") + .read_text(encoding="utf-8") + .replace("{{schema}}", schema) + ) + op.execute(sql) + + +def downgrade() -> None: + raise RuntimeError("LingxiGraph v1 production migrations are forward-only") diff --git a/migrations/versions/0003_steering_source_event.py b/migrations/versions/0003_steering_source_event.py new file mode 100644 index 0000000..c8e0d7b --- /dev/null +++ b/migrations/versions/0003_steering_source_event.py @@ -0,0 +1,39 @@ +"""Stable source_event_id for paused-run steering resume (issue #16). + +Issue #16 PR #17 review round 3, point 1: the SQL file +``src/lingxigraph/server/migrations/0003_steering_source_event.sql`` shipped +without a matching Alembic revision, so ``alembic upgrade head`` stayed at +``0002_steering`` and never applied it -- even though +``PostgresRepository._setup_sync()`` (which scans the SQL files directly, +not through Alembic) did apply it, letting CI's real-Postgres integration +job pass while a deployment driven by ``alembic upgrade head`` would not. +This revision closes that gap. +""" + +import os +import re +from importlib.resources import files + +from alembic import op + +revision = "0003_steering_source_event" +down_revision = "0002_steering" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + schema = os.getenv("LINGXIGRAPH_POSTGRES_SCHEMA", "lingxigraph") + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", schema): + raise ValueError("invalid LINGXIGRAPH_POSTGRES_SCHEMA") + sql = ( + files("lingxigraph.server") + .joinpath("migrations/0003_steering_source_event.sql") + .read_text(encoding="utf-8") + .replace("{{schema}}", schema) + ) + op.execute(sql) + + +def downgrade() -> None: + raise RuntimeError("LingxiGraph v1 production migrations are forward-only") diff --git a/migrations/versions/0004_steering_closed.py b/migrations/versions/0004_steering_closed.py new file mode 100644 index 0000000..081cddd --- /dev/null +++ b/migrations/versions/0004_steering_closed.py @@ -0,0 +1,35 @@ +"""Atomic finalization gate for steering admission (issue #16 PR #17 +review round 6, point 3, BLOCKER). + +Adds ``runs.steering_closed`` -- see +``src/lingxigraph/server/migrations/0004_steering_closed.sql`` for the +full rationale. +""" + +import os +import re +from importlib.resources import files + +from alembic import op + +revision = "0004_steering_closed" +down_revision = "0003_steering_source_event" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + schema = os.getenv("LINGXIGRAPH_POSTGRES_SCHEMA", "lingxigraph") + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", schema): + raise ValueError("invalid LINGXIGRAPH_POSTGRES_SCHEMA") + sql = ( + files("lingxigraph.server") + .joinpath("migrations/0004_steering_closed.sql") + .read_text(encoding="utf-8") + .replace("{{schema}}", schema) + ) + op.execute(sql) + + +def downgrade() -> None: + raise RuntimeError("LingxiGraph v1 production migrations are forward-only") diff --git a/migrations/versions/0005_superseded_by_run_id.py b/migrations/versions/0005_superseded_by_run_id.py new file mode 100644 index 0000000..b93c1ad --- /dev/null +++ b/migrations/versions/0005_superseded_by_run_id.py @@ -0,0 +1,35 @@ +"""Typed control-plane lineage marker for resumed runs (issue #16 PR #17 +review round 14, BLOCKER). + +Adds ``runs.superseded_by_run_id`` -- see +``src/lingxigraph/server/migrations/0005_superseded_by_run_id.sql`` for the +full rationale. +""" + +import os +import re +from importlib.resources import files + +from alembic import op + +revision = "0005_superseded_by_run_id" +down_revision = "0004_steering_closed" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + schema = os.getenv("LINGXIGRAPH_POSTGRES_SCHEMA", "lingxigraph") + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", schema): + raise ValueError("invalid LINGXIGRAPH_POSTGRES_SCHEMA") + sql = ( + files("lingxigraph.server") + .joinpath("migrations/0005_superseded_by_run_id.sql") + .read_text(encoding="utf-8") + .replace("{{schema}}", schema) + ) + op.execute(sql) + + +def downgrade() -> None: + raise RuntimeError("LingxiGraph v1 production migrations are forward-only") diff --git a/src/lingxigraph/errors.py b/src/lingxigraph/errors.py index 9d1d6d8..4ffa92a 100644 --- a/src/lingxigraph/errors.py +++ b/src/lingxigraph/errors.py @@ -45,6 +45,52 @@ class IdempotencyConflictError(LingxiGraphError): """Raised when an idempotency key is reused with a different request.""" +class RunTerminalError(LingxiGraphError): + """Raised when an operation targets a Run that has already finished.""" + + +class RunSupersededError(LingxiGraphError): + """Raised when steering targets a paused Run that has already been resumed. + + Resume durably transfers any steering that was pending at resume time + onto the new Run it creates (see + ``repository.resume_run_with_pending_steering`` and issue #16's + paused-run steering semantics) -- once that happens the old Run id is a dead end + for *further* steering, so new steer attempts against it fail loudly + instead of silently pending forever. + """ + + +class RunFinalizingError(LingxiGraphError): + """Raised when steering targets a Run whose graph has already ended. + + Issue #16 PR #17 review round 6, point 3: once a worker's graph + execution reaches its terminal outcome, the normal heartbeat/steering + loop stops and there is no safe point left for the graph to ever + consume a newly-accepted steering event -- even though the Run row + may still read ``running``/``cancelling`` while the final flush and + finalization commit are in flight. ``Repository.close_steering`` sets + an atomic, lease-fenced gate the instant execution ends so a late + ``/steer`` call during this window fails loudly with a stable error + instead of being durably accepted into an inbox nobody will ever read. + """ + + +class RunResumeConflictError(LingxiGraphError): + """Raised when a resume attempt loses a race against a concurrent resume. + + ``POST /runs/{id}/resume`` pre-checks that the target Run is + ``paused`` before calling + ``repository.resume_run_with_pending_steering``, but that pre-check is + not itself atomic with the resume -- two concurrent resume requests can + both pass it. The repository re-validates, *inside* the same locked + critical section that creates the new Run, that the old Run is still + ``paused`` and has no ``superseded_by_run_id`` set yet; the request + that loses that race gets this error instead of silently creating a + second descendant Run (issue #16 PR #17 review round 4, point 1). + """ + + class GraphInterrupt(BaseException): """Internal control-flow signal used by :func:`interrupt`.""" @@ -66,4 +112,8 @@ def __init__(self, interrupt: object) -> None: "IdempotencyConflictError", "LingxiGraphError", "PersistenceError", + "RunFinalizingError", + "RunResumeConflictError", + "RunSupersededError", + "RunTerminalError", ] diff --git a/src/lingxigraph/graph/executor.py b/src/lingxigraph/graph/executor.py index 35c0cc7..374e505 100644 --- a/src/lingxigraph/graph/executor.py +++ b/src/lingxigraph/graph/executor.py @@ -43,6 +43,7 @@ ) from ..schema import SchemaAdapter from ..serialization import JsonSerializer, Serializer +from ..steering import SteeringChannel, SteeringEvent from ..types import ( Command, CommandScope, @@ -244,8 +245,75 @@ def __init__( self._active_runs: dict[str, CancellationToken] = {} self._event_sequences: dict[str, int] = {} self._run_budgets: dict[str, ExecutionBudget] = {} + self._run_steering: dict[str, SteeringChannel] = {} self.response_format: Mapping[str, Any] | type | None = None + def steer( + self, + run_id: str, + *, + kind: str, + payload: Mapping[str, Any], + metadata: Mapping[str, Any] | None = None, + idempotency_key: str | None = None, + ) -> SteeringEvent: + """Embedded/library-mode steering: durably-in-process, no server. + + This is the degraded (SQLite/in-memory/embedded) implementation of + the mid-run steering protocol: it works against an in-process + :class:`~lingxigraph.steering.SteeringChannel` keyed by ``run_id``, + which is the same primitive the Agent Server worker feeds from + PostgreSQL. Safe to call concurrently from another thread/task + while the run is executing; the graph observes it at its next + safe point via ``runtime.drain_steering()``. + """ + + channel = self._run_steering.setdefault(run_id, SteeringChannel(run_id)) + event, _created = channel.submit( + kind=kind, + payload=payload, + metadata=metadata, + idempotency_key=idempotency_key, + ) + return event + + def has_pending_steering(self, run_id: str) -> bool: + channel = self._run_steering.get(run_id) + return channel is not None and channel.has_pending + + def get_steering_channel(self, run_id: str) -> SteeringChannel: + """Return (creating if needed) the in-process channel for a run. + + Used by server integrations (see :mod:`lingxigraph.server.worker`) + to feed durable PostgreSQL steering rows into the live executor + without going through the public ``steer()`` re-sequencing path. + + A channel freshly created here is marked ``owned_by_executor=False`` + -- the caller (a server Worker) is registering it explicitly, ahead + of a run that may not have started executing yet (a still-queued + run can receive steering before any worker claims it), and is + responsible for releasing it via ``forget_steering()`` once done. + This is what lets ``_run()`` distinguish "I created this channel for + a plain embedded invoke and must release it myself when the run + finishes" from "someone else owns this channel's lifecycle". + """ + + channel = self._run_steering.get(run_id) + if channel is None: + channel = SteeringChannel(run_id, owned_by_executor=False) + self._run_steering[run_id] = channel + return channel + + def forget_steering(self, run_id: str) -> None: + """Release the in-process channel for a finished run. + + Call once any final flush of consumed events is done (see + :class:`lingxigraph.server.worker.Worker`) to avoid an unbounded + per-run memory footprint across many completed runs. + """ + + self._run_steering.pop(run_id, None) + def _child_runtime( self, checkpointer: Checkpointer | None, @@ -793,6 +861,36 @@ async def _run( parent_budget = get_runtime().budget except RuntimeError: parent_budget = None + try: + parent_steering = get_runtime()._steering + except RuntimeError: + parent_steering = None + # Subgraphs share the parent run's steering channel (same run_id, + # nested namespace) so draining is deterministic regardless of which + # namespace's node happens to call ``runtime.drain_steering()``. + # + # Ownership: ``SteeringChannel.owned_by_executor`` (see steering.py) + # says whether *this* embedded run lifecycle is responsible for + # releasing the channel once the run truly finishes, as opposed to + # a server integration that pre-registered it via + # ``get_steering_channel()`` and manages release explicitly (see + # ``Worker._execute``'s ``forget_steering`` call). The flag lives on + # the channel object itself (not recomputed per call) so it stays + # correct across a paused run's repeated ``_run()`` invocations -- + # e.g. embedded pause -> ``steer()`` -> resume reuses the exact same + # channel instance under the same ``run_id``, and it is only + # released once that sequence finally completes without pausing + # again. Only the top-level call for a run_id (``parent_steering`` + # is ``None``, i.e. not a subgraph) may release it -- see the + # ``finally`` block below. A run that pauses on an interrupt is + # deliberately excluded from that release (``run_paused`` below) so + # steering submitted while paused remains deliverable at resume. + steering_channel = ( + parent_steering or self._run_steering.get(run_id) or SteeringChannel(run_id) + ) + owns_steering = parent_steering is None and steering_channel.owned_by_executor + run_paused = False + self._run_steering[run_id] = steering_channel limits = {**dict(config.get("configurable", {})), **config} self._run_budgets[run_id] = parent_budget or ExecutionBudget( max_tool_calls=( @@ -968,6 +1066,11 @@ async def _run( ) self._active_runs.pop(run_id, None) self._event_sequences.pop(run_id, None) + # This branch re-surfaces an *already* paused run without a + # resume -- leave the steering channel alone (whether it is the + # original paused run's externally-owned channel, or, for a + # fresh unowned run_id, an unused empty channel not worth a + # special-cased pop). return if not active and not sends: @@ -987,6 +1090,8 @@ async def _run( ) self._active_runs.pop(run_id, None) self._event_sequences.pop(run_id, None) + if owns_steering: + self._run_steering.pop(run_id, None) return executed_steps = 0 @@ -1051,6 +1156,7 @@ async def _run( writer=checkpoint_writer, ) parent_checkpoint_id = saved_id or parent_checkpoint_id + run_paused = True if stream_mode == "values": yield copy.deepcopy(state) elif stream_mode == "updates": @@ -1305,6 +1411,7 @@ def emit( await checkpoint_writer.flush() await self._put_pending_results(config, saved_id, tasks, successful) parent_checkpoint_id = saved_id + run_paused = True if stream_mode == "values": yield self._interrupt_output(state, interrupts) elif stream_mode == "updates": @@ -1486,6 +1593,7 @@ def emit( if completed & self.interrupt_after: self._require_interrupt_support(config) + run_paused = True if stream_mode == "events": marker = Interrupt( value={ @@ -1604,6 +1712,27 @@ def emit( self._active_runs.pop(run_id, None) self._event_sequences.pop(run_id, None) self._run_budgets.pop(run_id, None) + # Steering-channel lifecycle depends on who owns it (see the + # ``owns_steering`` comment above where the channel is set up): + # + # * Externally owned (server worker via ``get_steering_channel`` + # before claiming the run, or inherited from a parent run for + # a subgraph): deliberately NOT popped here. A server + # integration (see Worker._sync_steering_out) needs to flush + # any events the graph drained during its very last safe + # point *after* this generator has fully finished, and the + # owner calls ``forget_steering(run_id)`` itself once that + # final flush is done. + # * Owned by this call (plain embedded/library ``invoke`` / + # ``ainvoke`` with no server and no pre-registered channel): + # released automatically once the run truly finishes, so a + # long-lived compiled graph reused across many embedded runs + # does not leak one ``SteeringChannel`` per run forever. A + # run that paused on an interrupt is exempt -- its channel + # must survive so steering submitted while paused is still + # deliverable when the same ``run_id`` is resumed. + if owns_steering and not run_paused: + self._run_steering.pop(run_id, None) def _plan_tasks( self, @@ -2215,6 +2344,7 @@ def _make_runtime( stream_subgraphs=stream_subgraphs, budget=self._run_budgets.get(run_id), _emit=emit, + _steering=self._run_steering.get(run_id), ) async def _aget_tuple(self, config: Mapping[str, Any]) -> Any: diff --git a/src/lingxigraph/runtime.py b/src/lingxigraph/runtime.py index e74a2ad..3bf24e5 100644 --- a/src/lingxigraph/runtime.py +++ b/src/lingxigraph/runtime.py @@ -12,6 +12,7 @@ from threading import Lock from typing import Any, Generic, TypeVar +from .steering import SteeringChannel, SteeringEvent from .types import StreamWriter ContextT = TypeVar("ContextT") @@ -228,6 +229,63 @@ class Runtime(Generic[ContextT]): stream_subgraphs: bool = False budget: ExecutionBudget | None = None _emit: StreamEmitter | None = None + _steering: SteeringChannel | None = None + + @property + def has_steering(self) -> bool: + """Whether one or more steering events are waiting to be drained. + + This is a stable, generic read -- it never interprets the payload. + Safe to call from sync or async nodes, and from any subgraph + namespace (steering is scoped per top-level run, not per namespace). + """ + + return self._steering is not None and self._steering.has_pending + + #: Alias suggested by the design doc for "should I consider replanning". + steering_pending = has_steering + + def peek_steering(self) -> tuple[SteeringEvent, ...]: + """Read pending steering events without consuming them.""" + + if self._steering is None: + return () + return self._steering.peek() + + def drain_steering(self) -> tuple[SteeringEvent, ...]: + """Atomically consume and return pending steering events in order. + + Once drained, the same events are not re-exposed by this channel. + The graph decides what to do with them (update state, replan, goto + another node, ignore, ask the user) -- LingxiGraph only guarantees + durable delivery, ordering, dedup and safe consumption. + + Documented design choice -- "safe point" is application-chosen, not + executor-enforced: LingxiGraph guarantees *when* a fresh, correctly + ordered, deduplicated view of the channel is available to a node + (at every node invocation, via this ``Runtime``), but it does not + restrict *where inside the node's own code* that node is allowed to + call ``drain_steering()``. A node may call it at the very top before + doing any work, in the middle between two tool calls, or not at + all -- the executor has no notion of an in-node checkpoint to pin + the call to, and enforcing one would mean pausing arbitrary user + code mid-function, which LingxiGraph does not attempt. What *is* + guaranteed and executor-enforced is the channel's consistency + properties (atomic drain-once, dedup, ordering) and that consumption + is recorded (node/namespace/task_id/queue latency -- see + :class:`~lingxigraph.steering.SteeringConsumption`) against whichever + node actually called this method. See ``docs/api.md`` (steering + section) for the full statement of this contract. + """ + + if self._steering is None: + return () + # ``task_id`` is either the plain node name or ``"{node}#{index}"`` + # for a ``Send``-fanout task (see ``CompiledStateGraph._plan_tasks``) + # -- split off the fan-out suffix so the observability record names + # the node itself, not a synthetic per-task id. + node = self.task_id.split("#", 1)[0] if self.task_id else None + return self._steering.drain(node=node, namespace=self.namespace, task_id=self.task_id) @property def cancelled(self) -> bool: @@ -358,6 +416,8 @@ def _reset_runtime_context(token: Token[_RuntimeContext | None]) -> None: "CancellationToken", "ExecutionBudget", "Runtime", + "SteeringChannel", + "SteeringEvent", "StreamWriter", "get_config", "get_runtime", diff --git a/src/lingxigraph/sdk.py b/src/lingxigraph/sdk.py index b2ed431..150c883 100644 --- a/src/lingxigraph/sdk.py +++ b/src/lingxigraph/sdk.py @@ -140,6 +140,34 @@ async def list(self, thread_id: str) -> list[dict[str, Any]]: async def cancel(self, run_id: str) -> dict[str, Any]: return await self._client.request("POST", f"/v1/runs/{run_id}/cancel") + async def steer( + self, + run_id: str, + *, + kind: str = "user_input", + payload: Mapping[str, Any] | None = None, + metadata: Mapping[str, Any] | None = None, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + """Durably inject mid-run steering input; drained by the graph itself. + + Success only means the event was durably accepted -- it does not + mean the graph has processed it yet. + """ + + headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None + return await self._client.request( + "POST", + f"/v1/runs/{run_id}/steer", + json={ + "kind": kind, + "payload": dict(payload or {}), + "metadata": dict(metadata or {}), + "idempotency_key": idempotency_key, + }, + headers=headers, + ) + async def resume(self, run_id: str, resume: Any, **values: Any) -> dict[str, Any]: return await self._client.request( "POST", f"/v1/runs/{run_id}/resume", json={"resume": resume, **values} @@ -375,6 +403,34 @@ def list(self, thread_id: str) -> list[dict[str, Any]]: def cancel(self, run_id: str) -> dict[str, Any]: return self._client.request("POST", f"/v1/runs/{run_id}/cancel") + def steer( + self, + run_id: str, + *, + kind: str = "user_input", + payload: Mapping[str, Any] | None = None, + metadata: Mapping[str, Any] | None = None, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + """Durably inject mid-run steering input; drained by the graph itself. + + Success only means the event was durably accepted -- it does not + mean the graph has processed it yet. + """ + + headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None + return self._client.request( + "POST", + f"/v1/runs/{run_id}/steer", + json={ + "kind": kind, + "payload": dict(payload or {}), + "metadata": dict(metadata or {}), + "idempotency_key": idempotency_key, + }, + headers=headers, + ) + def resume(self, run_id: str, resume: Any, **values: Any) -> dict[str, Any]: return self._client.request( "POST", f"/v1/runs/{run_id}/resume", json={"resume": resume, **values} diff --git a/src/lingxigraph/server/app.py b/src/lingxigraph/server/app.py index e9e8e99..410c1a2 100644 --- a/src/lingxigraph/server/app.py +++ b/src/lingxigraph/server/app.py @@ -20,7 +20,16 @@ from ..cache import BaseCache from ..checkpoint import Checkpointer, InMemorySaver -from ..errors import ConcurrentRunError, EmptyInputError, IdempotencyConflictError +from ..errors import ( + ConcurrentRunError, + EmptyInputError, + IdempotencyConflictError, + RunFinalizingError, + RunResumeConflictError, + RunSupersededError, + RunTerminalError, +) +from ..steering import MAX_STEERING_PAYLOAD_BYTES, SteeringPayloadTooLarge from ..store import BaseStore, InMemoryStore, StoreOperation from ..types import RunStatus from ..version import __version__ @@ -36,6 +45,8 @@ Schedule, ScheduleCreate, SchedulePatch, + SteerAccepted, + SteerCreate, StoreBatchRequest, Thread, ThreadCreate, @@ -184,6 +195,26 @@ async def concurrent_error(request: Request, exc: ConcurrentRunError): async def idempotency_error(request: Request, exc: IdempotencyConflictError): return _problem(request, 409, "idempotency_conflict", str(exc)) + @app.exception_handler(RunTerminalError) + async def run_terminal_error(request: Request, exc: RunTerminalError): + return _problem(request, 409, "run_terminal", str(exc)) + + @app.exception_handler(RunSupersededError) + async def run_superseded_error(request: Request, exc: RunSupersededError): + return _problem(request, 409, "run_superseded", str(exc)) + + @app.exception_handler(RunResumeConflictError) + async def run_resume_conflict_error(request: Request, exc: RunResumeConflictError): + return _problem(request, 409, "run_resume_conflict", str(exc)) + + @app.exception_handler(RunFinalizingError) + async def run_finalizing_error(request: Request, exc: RunFinalizingError): + return _problem(request, 409, "run_finalizing", str(exc)) + + @app.exception_handler(SteeringPayloadTooLarge) + async def steering_payload_error(request: Request, exc: SteeringPayloadTooLarge): + return _problem(request, 413, "payload_too_large", str(exc)) + @app.exception_handler(HTTPException) async def http_error(request: Request, exc: HTTPException): codes = { @@ -537,6 +568,64 @@ async def cancel_run(run_id: str, user: Principal = Depends(require("operator")) assert value is not None return value + @app.post("/v1/runs/{run_id}/steer", response_model=SteerAccepted, status_code=202) + async def steer_run( + run_id: str, + body: SteerCreate, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + user: Principal = Depends(require("operator")), + ): + """Durably accept mid-run steering input. + + 202 means the event was durably written to the steering inbox -- + never that the graph has processed it. The graph decides what a + new steering event means at its next safe point via + ``runtime.drain_steering()``. + """ + + run = await repository.get_run(user.tenant_id, run_id) + if run is None: + raise HTTPException(404, "run not found") + key = idempotency_key or body.idempotency_key + try: + event, created = await repository.submit_steering( + user.tenant_id, + run_id, + kind=body.kind, + payload=body.payload, + metadata=body.metadata, + idempotency_key=key, + # Issue #16 PR #17 review round 5, point 3: the public docs + # and the ``steering`` module both promise a ~32KB payload + # cap (``MAX_STEERING_PAYLOAD_BYTES``); using the much + # larger generic event cap here let payloads up to 256KB + # through, contradicting that documented contract. Enforce + # whichever limit is smaller so the HTTP endpoint never + # accepts more than the documented steering limit, even if + # a deployment raises the generic event size cap. + max_payload_bytes=min( + MAX_STEERING_PAYLOAD_BYTES, repository.limits.max_event_bytes + ), + ) + except KeyError as exc: + raise HTTPException(404, "run not found") from exc + # ``repository.submit_steering`` durably records the steering row + # *and* its ``run.steer.accepted`` lifecycle event atomically (issue + # #16 PR #17 review round 4, point 2) -- nothing further to persist + # here even on an idempotent retry that repairs a previously-missing + # accepted event. + if created: + await event_bus.publish(user.tenant_id, run_id, 0) + await audit(user, "runs.steer", "run", run_id) + return SteerAccepted( + id=event.id, + run_id=run_id, + sequence=event.sequence, + status=event.status, + kind=event.kind, + created_at=event.created_at, + ) + @app.post("/v1/runs/{run_id}/resume", response_model=Run, status_code=202) async def resume_run( run_id: str, @@ -567,9 +656,27 @@ async def resume_run( durability=previous.durability, metadata={"resumed_from_run_id": previous.id}, ) - value = await repository.create_run( - user.tenant_id, previous.thread_id, assistant, request + # Create the resumed Run and carry over any steering that was + # durably accepted while `previous` was paused as a single atomic + # repository operation (issue #16 PR #17 review point 1) -- the + # worker only pulls pending steering for the run it is actually + # executing (the new one), so without atomicity a worker could + # claim and even finish the new Run before the migration below + # ever ran, leaving the migrated steering permanently unconsumed. + # This also marks `previous` so any *later* steer attempt against + # the stale run_id is rejected instead of silently pending again + # (see RunSupersededError). + # ``resume_run_with_pending_steering`` re-validates under its own + # lock that ``previous`` is still paused and not already resumed by + # a concurrent request (issue #16 PR #17 review round 4, point 1), + # and durably records each transferred event's ``run.steer.accepted`` + # lifecycle event in the same migration transaction (review round 4, + # point 2) -- nothing further to persist for that here. + value, transferred = await repository.resume_run_with_pending_steering( + user.tenant_id, previous.thread_id, assistant, request, previous.id ) + if transferred: + await event_bus.publish(user.tenant_id, value.id, 0) await audit(user, "runs.resume", "run", value.id) return value diff --git a/src/lingxigraph/server/eventbus.py b/src/lingxigraph/server/eventbus.py index 30f1cbd..839b655 100644 --- a/src/lingxigraph/server/eventbus.py +++ b/src/lingxigraph/server/eventbus.py @@ -5,7 +5,71 @@ import asyncio import json from collections import defaultdict -from typing import Protocol +from collections.abc import Awaitable +from typing import Protocol, TypeVar + +_T = TypeVar("_T") + + +async def _wait_cancellation_safe(awaitable: Awaitable[_T], timeout: float) -> _T | None: + """Await ``awaitable`` with a timeout, safely under external cancellation. + + Issue #16 PR #17 review round 5, point 4 / round 6, point 4: this is + deliberately *not* ``asyncio.wait_for(awaitable, timeout)``. On + Python's asyncio (still true on the 3.11 runtime this project + targets), ``wait_for`` wraps the awaitable in its own inner Task and, + if the *outer* coroutine awaiting ``wait_for`` is itself cancelled -- + exactly what happens when ``Worker._heartbeat`` is cancelled by + ``Worker._execute`` right as an ``EventBus.wait()`` call is in-flight + -- there is a real, reproducible race where that inner Task's + cancellation is never actually awaited to completion, hanging forever + (https://github.com/python/cpython/issues/86296, fixed by + ``asyncio.timeout()`` in 3.12). Racing an explicit sibling timer task + with ``asyncio.wait`` does not share that failure mode: an external + cancellation of *this* coroutine while it is suspended inside + ``asyncio.wait`` propagates immediately and unconditionally, the same + as any other plain ``await``. + + Both :class:`InMemoryEventBus` and :class:`RedisEventBus` share this + helper so the fix applies to the production Redis path too, not just + the in-memory/test bus -- a heartbeat cancelled while blocked in + ``RedisEventBus.wait()`` must return just as promptly as one blocked + in ``InMemoryEventBus.wait()``. + + Returns the awaitable's result, or ``None`` on a genuine timeout. + + Issue #16 PR #17 review round 7, point 4 (Redis robustness): this used + to also swallow any exception the awaitable itself raised into + ``None``, indistinguishable from a timeout. ``RedisEventBus.wait()``'s + deadline loop treats a ``None`` result as "no message yet, keep + looping" -- so if Redis disconnects right after ``subscribe()`` + succeeds and every subsequent ``get_message()`` call raises + immediately, that used to turn into a tight busy-loop for the rest of + the heartbeat timeout window (rapidly creating new waiter/timer tasks + with no backoff), never reaching ``RedisEventBus.wait()``'s own outer + ``except Exception: return``. Only a real timeout (the timer won the + race, the waiter never completed) is folded into ``None`` now; an + exception raised by the awaitable itself propagates to the caller, so + it reaches that outer handler and returns promptly instead of spinning. + """ + + waiter = asyncio.ensure_future(awaitable) + timer = asyncio.ensure_future(asyncio.sleep(timeout)) + try: + await asyncio.wait({waiter, timer}, return_when=asyncio.FIRST_COMPLETED) + finally: + for pending in (waiter, timer): + if not pending.done(): + pending.cancel() + await asyncio.gather(waiter, timer, return_exceptions=True) + if not waiter.done() or waiter.cancelled(): + # Timed out (or the waiter was cancelled alongside us) -- nothing + # arrived in time. + return None + exc = waiter.exception() + if exc is not None: + raise exc + return waiter.result() class EventBus(Protocol): @@ -17,24 +81,45 @@ async def wait( class InMemoryEventBus: + """In-process wake signal used by the heartbeat's low-latency fast path. + + Backed by :class:`asyncio.Event`, not :class:`asyncio.Condition`. This + is a deliberate choice, not a style preference: ``Condition.wait()`` + must re-acquire its lock before it can propagate cancellation, and a + ``notify_all()`` racing a concurrent ``cancel()`` on the *same* + condition can leave that re-acquire permanently stuck (a long-standing + CPython gotcha -- see https://github.com/python/cpython/issues/86296). + ``Worker._heartbeat`` is cancelled from ``Worker._execute`` right after + the run finishes, at exactly the moment ``_append_event`` is also + calling ``publish()`` for the run's own completion/interrupt events on + this same bus -- i.e. exactly the racing pattern the bug needs, and it + was reproducible here as a real hang once the finalization path in + ``_execute`` was restructured (issue #16 PR #17 review round 5) to + settle the heartbeat cancellation a little earlier relative to that + publish. ``asyncio.Event`` has no such lock-reacquire step, so + cancelling a waiter is always immediate and safe. + """ + def __init__(self) -> None: - self._conditions: dict[tuple[str, str], asyncio.Condition] = defaultdict( - asyncio.Condition - ) + self._events: dict[tuple[str, str], asyncio.Event] = defaultdict(asyncio.Event) async def publish(self, tenant_id: str, run_id: str, sequence: int) -> None: del sequence - condition = self._conditions[(tenant_id, run_id)] - async with condition: - condition.notify_all() + self._events[(tenant_id, run_id)].set() async def wait(self, tenant_id: str, run_id: str, *, timeout: float = 15.0) -> None: - condition = self._conditions[(tenant_id, run_id)] - async with condition: - try: - await asyncio.wait_for(condition.wait(), timeout) - except TimeoutError: - return + event = self._events[(tenant_id, run_id)] + try: + await _wait_cancellation_safe(event.wait(), timeout) + finally: + # Reset for the next waiter. A publish() landing in the + # instant between this waiter waking and the clear() below is + # simply an extra, harmless early wake for whoever waits next + # -- this bus is purely a latency optimization on top of the + # unconditional PostgreSQL poll each heartbeat tick also does, + # so an occasional spurious or missed wake here never loses a + # steering event or a cancellation signal. + event.clear() class RedisEventBus: @@ -61,15 +146,70 @@ async def publish(self, tenant_id: str, run_id: str, sequence: int) -> None: return async def wait(self, tenant_id: str, run_id: str, *, timeout: float = 15.0) -> None: + # Issue #16 PR #17 review round 6, point 4 (REQUIRED): this used to + # be ``asyncio.wait_for(pubsub.get_message(...), timeout)`` -- the + # exact pattern this same round's ``InMemoryEventBus.wait()`` fix + # rewrote away from because it can hang forever when the *caller* + # (``Worker._heartbeat``) is cancelled mid-wait. The Worker + # cancels a Redis-backed heartbeat exactly the same way as an + # in-memory one, so the production Redis path needs the same + # cancellation-safe explicit task/timer pattern, via the bus- + # agnostic ``_wait_cancellation_safe`` helper both classes share. pubsub = self._redis.pubsub() try: await pubsub.subscribe(self._channel(tenant_id, run_id)) - await asyncio.wait_for(pubsub.get_message(ignore_subscribe_messages=True), timeout) + # Two things had to be fixed here, not just one: + # + # 1. ``get_message``'s own ``timeout`` defaults to 0.0 -- + # "return immediately, blocking for nothing" -- not "block + # until a message arrives". Passing it explicitly makes + # redis-py perform an actual blocking read instead of a + # poll. + # 2. Even with that fixed, the *first* ``get_message()`` call + # right after ``subscribe()`` reliably returns near- + # instantly with ``None`` regardless of the timeout given -- + # it is consuming the buffered SUBSCRIBE acknowledgement + # frame that ``ignore_subscribe_messages=True`` then + # filters out, not genuinely waiting for a publish. + # (Confirmed empirically against a real Redis server: a + # second call on the same subscription does block for the + # full requested timeout.) A single non-looping call here + # would therefore make ``wait()`` resolve immediately on + # every tick, in production, independent of whether + # anything was ever published -- exactly the bug the round- + # 6 cancellation regression test caught, for a reason + # unrelated to cancellation. Loop against a shared + # deadline, re-issuing ``get_message()`` for whatever time + # remains, until either a real message arrives (a non- + # ``None`` result) or the deadline is reached. + deadline = asyncio.get_event_loop().time() + timeout + while True: + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + return + message = await _wait_cancellation_safe( + pubsub.get_message(ignore_subscribe_messages=True, timeout=remaining), + remaining, + ) + if message is not None: + return + except asyncio.CancelledError: + # A bare ``except Exception`` below would not normally catch + # this (CancelledError is a BaseException on 3.8+), but some + # libraries' internal read/retry loops (e.g. redis-py's + # connection handling) can translate an in-flight + # cancellation into a different exception type while + # unwinding. Re-raise explicitly and unconditionally so a + # heartbeat cancellation can never be silently absorbed here + # regardless of what the client library does internally. + raise except Exception: return finally: try: await pubsub.aclose() + except asyncio.CancelledError: + raise except Exception: pass diff --git a/src/lingxigraph/server/migrations/0002_steering.sql b/src/lingxigraph/server/migrations/0002_steering.sql new file mode 100644 index 0000000..960c97c --- /dev/null +++ b/src/lingxigraph/server/migrations/0002_steering.sql @@ -0,0 +1,48 @@ +-- Durable mid-run steering inbox (issue #16). +-- PostgreSQL remains the source of truth; Redis (eventbus) is only an +-- optional low-latency notify/accelerator layer on top of this table. + +CREATE TABLE IF NOT EXISTS {{schema}}.run_steering_events ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + run_id TEXT NOT NULL, + sequence BIGINT NOT NULL, + kind TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + idempotency_key TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'delivered', 'consumed', 'superseded')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + consumed_at TIMESTAMPTZ, + UNIQUE (tenant_id, run_id, sequence) +); + +-- Idempotency: the same (tenant, run, idempotency_key) can only ever +-- create one durable event, matching the Run-level idempotency pattern. +CREATE UNIQUE INDEX IF NOT EXISTS run_steering_events_idempotency + ON {{schema}}.run_steering_events (tenant_id, run_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; + +CREATE INDEX IF NOT EXISTS run_steering_events_pending + ON {{schema}}.run_steering_events (tenant_id, run_id, sequence) + WHERE status IN ('pending', 'delivered'); + +ALTER TABLE {{schema}}.run_steering_events ENABLE ROW LEVEL SECURITY; + +DO $policies$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname='{{schema}}' AND tablename='run_steering_events' + AND policyname='tenant_isolation' + ) THEN + EXECUTE format( + 'CREATE POLICY tenant_isolation ON {{schema}}.%I USING ' + || '(tenant_id = current_setting(''app.tenant_id'', true)) ' + || 'WITH CHECK (tenant_id = current_setting(''app.tenant_id'', true))', + 'run_steering_events' + ); + END IF; +END +$policies$; diff --git a/src/lingxigraph/server/migrations/0003_steering_source_event.sql b/src/lingxigraph/server/migrations/0003_steering_source_event.sql new file mode 100644 index 0000000..cd4761c --- /dev/null +++ b/src/lingxigraph/server/migrations/0003_steering_source_event.sql @@ -0,0 +1,12 @@ +-- Stable logical identity for paused-run -> resumed-run steering transfers +-- (issue #16 PR #17 review point 3). ``source_event_id`` points at the +-- original (now ``superseded``) event's id so a client's ``/steer`` +-- response on a paused run can be correlated end-to-end to the +-- ``run.steer.consumed`` event eventually emitted under the resumed run. + +ALTER TABLE {{schema}}.run_steering_events + ADD COLUMN IF NOT EXISTS source_event_id TEXT; + +CREATE INDEX IF NOT EXISTS run_steering_events_source + ON {{schema}}.run_steering_events (tenant_id, source_event_id) + WHERE source_event_id IS NOT NULL; diff --git a/src/lingxigraph/server/migrations/0004_steering_closed.sql b/src/lingxigraph/server/migrations/0004_steering_closed.sql new file mode 100644 index 0000000..ea3009a --- /dev/null +++ b/src/lingxigraph/server/migrations/0004_steering_closed.sql @@ -0,0 +1,12 @@ +-- Atomic finalization gate for steering admission (issue #16 PR #17 +-- review round 6, point 3, BLOCKER). Once a worker's graph execution +-- has ended there is no safe point left for a newly accepted steering +-- event to ever be consumed -- even though the Run row may still read +-- ``running``/``cancelling`` while the final flush and finalization +-- commit are in flight. The owning worker sets this column atomically +-- (fenced on ``lease_owner``/``attempt``, see +-- ``PostgresRepository.close_steering``) the instant execution ends, and +-- ``/steer`` refuses new admission once it is set. + +ALTER TABLE {{schema}}.runs + ADD COLUMN IF NOT EXISTS steering_closed BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/lingxigraph/server/migrations/0005_superseded_by_run_id.sql b/src/lingxigraph/server/migrations/0005_superseded_by_run_id.sql new file mode 100644 index 0000000..7dde347 --- /dev/null +++ b/src/lingxigraph/server/migrations/0005_superseded_by_run_id.sql @@ -0,0 +1,14 @@ +-- Typed control-plane lineage marker for resumed runs (issue #16 PR #17 +-- review round 14, BLOCKER). ``superseded_by_run_id`` used to live in +-- ordinary, user-writable ``runs.metadata`` -- but ``/steer``, ``/cancel``, +-- and the concurrent-resume conflict gate all treat it as authoritative +-- Runtime control state. A caller could forge +-- ``metadata.superseded_by_run_id`` at run-creation time to falsely +-- trigger ``409 run_superseded`` / ``409 run_resume_conflict`` on a run +-- that was never actually resumed. This column is written exactly once, +-- atomically, by the resume transaction that creates a descendant run +-- from a paused run (see ``PostgresRepository`` resume/steer/cancel +-- sync helpers) and is never derived from user-submitted ``metadata``. + +ALTER TABLE {{schema}}.runs + ADD COLUMN IF NOT EXISTS superseded_by_run_id TEXT; diff --git a/src/lingxigraph/server/models.py b/src/lingxigraph/server/models.py index e21ead4..ad6b7f9 100644 --- a/src/lingxigraph/server/models.py +++ b/src/lingxigraph/server/models.py @@ -121,11 +121,68 @@ class Run(ApiModel): attempt: int = 0 lease_owner: str | None = None lease_expires_at: datetime | None = None + #: Set atomically by the owning worker once graph execution has ended + #: and before it starts the final steering flush + finalization commit + #: (issue #16 PR #17 review round 6, point 3). Once set, ``/steer`` + #: must refuse new admission -- there is no safe point left for the + #: graph to ever consume a newly-accepted event. + steering_closed: bool = False + #: Set atomically by the resume transaction that creates a descendant + #: run from this (paused) run (issue #16 PR #17 review round 14, + #: BLOCKER). This is authoritative control-plane lineage state -- it + #: MUST NOT live in user-writable ``metadata``, since a caller could + #: otherwise forge it at run-creation time to falsely trigger + #: ``409 run_superseded`` / ``409 run_resume_conflict`` on a run that + #: was never actually resumed. ``/steer``, ``/cancel``, and the + #: concurrent-resume conflict gate all read this field, never + #: ``metadata.get("superseded_by_run_id")``. + superseded_by_run_id: str | None = None created_at: datetime = Field(default_factory=utcnow) started_at: datetime | None = None finished_at: datetime | None = None +class SteerCreate(ApiModel): + kind: str = "user_input" + payload: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + idempotency_key: str | None = None + + +class SteerAccepted(ApiModel): + id: str + run_id: str + sequence: int + status: Literal["pending", "delivered", "consumed", "superseded"] + kind: str + created_at: datetime = Field(default_factory=utcnow) + + +class RunSteeringEvent(ApiModel): + id: str = Field(default_factory=lambda: str(uuid4())) + tenant_id: str + run_id: str + sequence: int + kind: str + payload: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + idempotency_key: str | None = None + status: Literal["pending", "delivered", "consumed", "superseded"] = "pending" + created_at: datetime = Field(default_factory=utcnow) + consumed_at: datetime | None = None + #: Stable logical identity across a paused-run -> resumed-run transfer + #: (see ``Repository.resume_run_with_pending_steering`` / issue #16 PR + #: #17 review point 3). ``None`` for an event that was never + #: transferred. When set, this is the ``id`` of the original + #: ``superseded`` row the client's ``/steer`` call actually received -- + #: external callers correlate "the id I got back" to "the id that was + #: eventually consumed" via this field rather than the (new, + #: post-transfer) ``id``. ``created_at`` on the transferred row is also + #: preserved from the original so ``queue_latency_seconds`` includes + #: the time an event spent waiting while the run was paused. + source_event_id: str | None = None + + class RunEvent(ApiModel): id: str = Field(default_factory=lambda: str(uuid4())) tenant_id: str @@ -197,6 +254,9 @@ class StoreBatchRequest(ApiModel): "Run", "RunCreate", "RunEvent", + "RunSteeringEvent", + "SteerAccepted", + "SteerCreate", "Schedule", "ScheduleCreate", "SchedulePatch", diff --git a/src/lingxigraph/server/repository.py b/src/lingxigraph/server/repository.py index 95f7623..43cbdf6 100644 --- a/src/lingxigraph/server/repository.py +++ b/src/lingxigraph/server/repository.py @@ -4,11 +4,24 @@ import asyncio import re +from collections.abc import Sequence from dataclasses import dataclass from datetime import timedelta from typing import Any -from ..errors import ConcurrentRunError, IdempotencyConflictError +from ..errors import ( + ConcurrentRunError, + IdempotencyConflictError, + RunFinalizingError, + RunResumeConflictError, + RunSupersededError, + RunTerminalError, +) +from ..steering import ( + MAX_STEERING_PAYLOAD_BYTES, + SteeringConsumption, + validate_steering_payload, +) from ..types import MultitaskStrategy, RunStatus from .models import ( Assistant, @@ -18,6 +31,7 @@ Run, RunCreate, RunEvent, + RunSteeringEvent, Schedule, ScheduleCreate, SchedulePatch, @@ -60,6 +74,8 @@ def __init__(self, *, limits: RepositoryLimits | None = None) -> None: self._events: dict[tuple[str, str], list[RunEvent]] = {} self._schedules: dict[tuple[str, str], Schedule] = {} self._audits: list[AuditRecord] = [] + self._steering: dict[tuple[str, str], list[RunSteeringEvent]] = {} + self._steering_keys: dict[tuple[str, str, str], str] = {} self._lock = asyncio.Lock() self._changed = asyncio.Condition() @@ -168,83 +184,106 @@ async def create_run( request_digest: str | None = None, ) -> Run: async with self._lock: - if idempotency_key is not None: - existing = next( - ( - run - for run in self._runs.values() - if run.tenant_id == tenant_id - and run.idempotency_key == idempotency_key - ), - None, - ) - if existing is not None: - if existing.request_digest != request_digest: - raise IdempotencyConflictError( - "idempotency key was already used for a different run request" - ) - return existing.model_copy(deep=True) - tenant_runs = [run for run in self._runs.values() if run.tenant_id == tenant_id] - active = [run for run in tenant_runs if enum_value(run.status) in ACTIVE] - queued = [ - run - for run in tenant_runs - if enum_value(run.status) == RunStatus.PENDING.value - ] - if len(active) >= self.limits.max_active_runs: - raise ConcurrentRunError("tenant active-run quota exceeded") - if len(queued) >= self.limits.max_queued_runs: - raise ConcurrentRunError("tenant queued-run quota exceeded") - same_thread = [ - run - for run in active - if thread_id is not None and run.thread_id == thread_id - ] - strategy = MultitaskStrategy(request.multitask_strategy) - if same_thread and strategy is MultitaskStrategy.REJECT: - raise ConcurrentRunError("thread already has an active run") - if same_thread and strategy is MultitaskStrategy.CANCEL_PREVIOUS: - for run in same_thread: - self._runs[(tenant_id, run.id)] = run.model_copy( - update={"status": RunStatus.CANCELLING.value} - ) - run = Run( - tenant_id=tenant_id, - thread_id=thread_id, - assistant_id=assistant.id, - graph_id=assistant.graph_id, - graph_version=assistant.graph_version, + run = self._create_run_locked( + tenant_id, + thread_id, + assistant, + request, idempotency_key=idempotency_key, request_digest=request_digest, - input=request.input, - context={**assistant.context, **request.context}, - config={**assistant.config, **request.config}, - metadata=request.metadata, - resume=request.resume, - update=request.update, - goto=request.goto, - durability=request.durability, ) - if request.run_timeout is not None: - run.config["run_timeout"] = request.run_timeout - run.config.setdefault("max_state_bytes", self.limits.max_state_bytes) - for budget_name in ("max_model_calls", "max_tool_calls", "max_tokens", "max_cost"): - budget_value = getattr(request, budget_name) - if budget_value is not None: - run.config[budget_name] = budget_value - self._runs[(tenant_id, run.id)] = run - self._events[(tenant_id, run.id)] = [] await self._notify() return run.model_copy(deep=True) + def _create_run_locked( + self, + tenant_id: str, + thread_id: str | None, + assistant: Assistant, + request: RunCreate, + *, + idempotency_key: str | None = None, + request_digest: str | None = None, + ) -> Run: + """The body of :meth:`create_run` -- assumes ``self._lock`` is held. + + Factored out so :meth:`resume_run_with_pending_steering` can create + the resumed Run and migrate its paused-run steering under a single + lock acquisition (see issue #16 PR #17 review point 1): otherwise a + worker's own lock acquisition (``claim_run``) could interleave + between "new Run committed" and "steering migrated", claiming (and + even finishing) the new Run before the migration ever happens. + """ + + if idempotency_key is not None: + existing = next( + ( + run + for run in self._runs.values() + if run.tenant_id == tenant_id and run.idempotency_key == idempotency_key + ), + None, + ) + if existing is not None: + if existing.request_digest != request_digest: + raise IdempotencyConflictError( + "idempotency key was already used for a different run request" + ) + return existing + tenant_runs = [run for run in self._runs.values() if run.tenant_id == tenant_id] + active = [run for run in tenant_runs if enum_value(run.status) in ACTIVE] + queued = [ + run for run in tenant_runs if enum_value(run.status) == RunStatus.PENDING.value + ] + if len(active) >= self.limits.max_active_runs: + raise ConcurrentRunError("tenant active-run quota exceeded") + if len(queued) >= self.limits.max_queued_runs: + raise ConcurrentRunError("tenant queued-run quota exceeded") + same_thread = [ + run for run in active if thread_id is not None and run.thread_id == thread_id + ] + strategy = MultitaskStrategy(request.multitask_strategy) + if same_thread and strategy is MultitaskStrategy.REJECT: + raise ConcurrentRunError("thread already has an active run") + if same_thread and strategy is MultitaskStrategy.CANCEL_PREVIOUS: + for run in same_thread: + self._runs[(tenant_id, run.id)] = run.model_copy( + update={"status": RunStatus.CANCELLING.value} + ) + run = Run( + tenant_id=tenant_id, + thread_id=thread_id, + assistant_id=assistant.id, + graph_id=assistant.graph_id, + graph_version=assistant.graph_version, + idempotency_key=idempotency_key, + request_digest=request_digest, + input=request.input, + context={**assistant.context, **request.context}, + config={**assistant.config, **request.config}, + metadata=request.metadata, + resume=request.resume, + update=request.update, + goto=request.goto, + durability=request.durability, + ) + if request.run_timeout is not None: + run.config["run_timeout"] = request.run_timeout + run.config.setdefault("max_state_bytes", self.limits.max_state_bytes) + for budget_name in ("max_model_calls", "max_tool_calls", "max_tokens", "max_cost"): + budget_value = getattr(request, budget_name) + if budget_value is not None: + run.config[budget_name] = budget_value + self._runs[(tenant_id, run.id)] = run + self._events[(tenant_id, run.id)] = [] + return run + async def get_run(self, tenant_id: str, run_id: str) -> Run | None: async with self._lock: value = self._runs.get((tenant_id, run_id)) return value.model_copy(deep=True) if value else None - async def list_runs( - self, tenant_id: str, *, thread_id: str | None = None - ) -> list[Run]: + async def list_runs(self, tenant_id: str, *, thread_id: str | None = None) -> list[Run]: async with self._lock: values = [ run.model_copy(deep=True) @@ -254,22 +293,53 @@ async def list_runs( ] return sorted(values, key=lambda run: run.created_at, reverse=True) - async def claim_run( - self, worker_id: str, *, lease_seconds: int = 30 - ) -> Run | None: + async def claim_run(self, worker_id: str, *, lease_seconds: int = 30) -> Run | None: now = utcnow() async with self._lock: for key, run in list(self._runs.items()): - if ( - enum_value(run.status) == RunStatus.RUNNING.value - and run.lease_expires_at is not None - and run.lease_expires_at <= now - ): + if run.lease_expires_at is None or run.lease_expires_at > now: + continue + # Lease-expiry recovery rule (issue #16 PR #17 review round + # 11, BLOCKER): an expired ``running`` lease is requeued to + # ``pending`` so a new worker can pick the run back up. An + # expired ``cancelling`` lease must NOT be requeued the same + # way -- that would silently erase the fact that the + # caller already asked to stop and could let business + # execution resume after cancellation was requested. It + # instead resolves straight to the terminal ``cancelled`` + # state, mirroring the same ``run_cancelled`` disposition + # ``finish_run_if_owned``/``retry_run_if_owned`` already use + # when cancellation wins during in-process finalization. + # This guarantees the run can never be stuck in + # ``cancelling`` forever and keeps its thread from being + # wedged for other queued runs. + if enum_value(run.status) == RunStatus.RUNNING.value: self._runs[key] = run.model_copy( update={ "status": RunStatus.PENDING.value, "lease_owner": None, "lease_expires_at": None, + # A fresh delivery attempt must start with + # steering admission open -- ``steering_closed`` + # only means anything for the attempt that set + # it (issue #16 PR #17 review round 6). + "steering_closed": False, + } + ) + elif enum_value(run.status) == RunStatus.CANCELLING.value: + self._runs[key] = run.model_copy( + update={ + "status": RunStatus.CANCELLED.value, + "lease_owner": None, + "lease_expires_at": None, + "finished_at": now, + "error": { + "code": "run_cancelled", + "message": ( + "run was cancelled but its worker's lease" + " expired before finalization completed" + ), + }, } ) pending = sorted( @@ -346,9 +416,292 @@ async def finish_run( await self._notify() return updated.model_copy(deep=True) + async def finish_run_if_owned( + self, + tenant_id: str, + run_id: str, + worker_id: str, + attempt: int, + status: RunStatus | str, + *, + output: dict[str, Any] | None = None, + error: dict[str, Any] | None = None, + ) -> Run | None: + """Fenced/CAS'd terminal-status write for a specific lease + attempt. + + Issue #16 PR #17 review round 6, point 1 (BLOCKER): a stale + worker whose lease has already expired (and been claimed by a new + owner) must never be able to overwrite that new owner's status. + This only writes when ``(lease_owner, attempt)`` still match the + caller's *and* the run is still in an active status -- if either + no longer holds, it returns ``None`` and touches nothing, and the + caller must abandon the attempt without any further status + mutation. + + Review round 6, point 2 (BLOCKER): re-checking the current + control state (``cancelling`` vs ``running``) happens inside this + *same* locked decision, atomically with the fence check -- if the + run is already ``cancelling``, cancellation wins and the actual + write is coerced to ``cancelled`` regardless of the caller's + requested ``status``, so a stale in-flight ``succeeded`` intent + can never overwrite a cancel that arrived during finalization. + """ + + async with self._lock: + key = (tenant_id, run_id) + run = self._runs.get(key) + if ( + run is None + or run.lease_owner != worker_id + or run.attempt != attempt + or enum_value(run.status) not in ACTIVE + ): + return None + final_status = RunStatus(status).value + final_output = output + final_error = error + if ( + enum_value(run.status) == RunStatus.CANCELLING.value + and final_status != RunStatus.CANCELLED.value + ): + final_status = RunStatus.CANCELLED.value + final_output = None + final_error = { + "code": "run_cancelled", + "message": "run was cancelled during finalization", + } + updated = run.model_copy( + update={ + "status": final_status, + "output": final_output, + "error": final_error, + "finished_at": utcnow(), + "lease_owner": None, + "lease_expires_at": None, + } + ) + self._runs[key] = updated + await self._notify() + return updated.model_copy(deep=True) + + async def retry_run_if_owned( + self, + tenant_id: str, + run_id: str, + worker_id: str, + attempt: int, + *, + error: dict[str, Any] | None = None, + ) -> Run | None: + """Fenced/CAS'd retry-to-pending write; see ``finish_run_if_owned``. + + A ``cancelling`` run must never be sent back to ``pending`` by a + stale worker's retry decision -- cancellation still wins here too, + resolving directly to ``cancelled`` instead. + """ + + async with self._lock: + key = (tenant_id, run_id) + run = self._runs.get(key) + if ( + run is None + or run.lease_owner != worker_id + or run.attempt != attempt + or enum_value(run.status) not in ACTIVE + ): + return None + if enum_value(run.status) == RunStatus.CANCELLING.value: + updated = run.model_copy( + update={ + "status": RunStatus.CANCELLED.value, + "error": { + "code": "run_cancelled", + "message": "run was cancelled during finalization", + }, + "finished_at": utcnow(), + "lease_owner": None, + "lease_expires_at": None, + } + ) + else: + updated = run.model_copy( + update={ + "status": RunStatus.PENDING.value, + "error": error, + "finished_at": None, + "lease_owner": None, + "lease_expires_at": None, + "steering_closed": False, + } + ) + self._runs[key] = updated + await self._notify() + return updated.model_copy(deep=True) + + async def retry_run_with_event_if_owned( + self, + tenant_id: str, + run_id: str, + worker_id: str, + attempt: int, + *, + error: dict[str, Any] | None = None, + max_attempts: int | None = None, + ) -> tuple[Run, RunEvent | None] | None: + """Merge the retry-to-pending transition and its ``worker_retrying`` + lifecycle event into one atomic operation. + + Issue #16 PR #17 review round 10, point 1 (BLOCKER): + ``retry_run_if_owned()`` (the status write) and the caller's + subsequent ``append_event("worker_retrying")`` used to be two + separate durable decisions/transactions. That reopened a TOCTOU + window: (race A) a concurrent cancel landing in the gap between the + status write committing and the event append could see this worker + append a now-stale ``worker_retrying`` event for a run that had + already, durably, moved on to ``cancelled``; (race B) a *new* + worker could claim the run (``pending -> running``, attempt N+1) + in that same gap and log its own attempt-N+1 events *before* the + stale attempt-N ``worker_retrying`` event finally landed, inverting + the durable event stream's causal order. Folding both writes into + one lock critical section (no ``await`` between the status + transition and the event append) makes both races impossible: the + instant ``pending``/``cancelled`` becomes visible, the matching + event (or lack of one, for ``cancelled``) is already durable too. + + Returns ``(updated_run, event)`` where ``event`` is the appended + ``worker_retrying`` :class:`RunEvent` when the run actually + transitioned to ``pending``, or ``(cancelled_run, None)`` when + cancellation won the race instead (mirroring + ``retry_run_if_owned``'s existing cancel-wins coercion -- no + ``worker_retrying`` event is ever appended for a run that is really + ``cancelled``). Returns ``None`` if the lease was no longer owned, + identically to ``retry_run_if_owned``. + """ + + async with self._lock: + key = (tenant_id, run_id) + run = self._runs.get(key) + if ( + run is None + or run.lease_owner != worker_id + or run.attempt != attempt + or enum_value(run.status) not in ACTIVE + ): + return None + if enum_value(run.status) == RunStatus.CANCELLING.value: + updated = run.model_copy( + update={ + "status": RunStatus.CANCELLED.value, + "error": { + "code": "run_cancelled", + "message": "run was cancelled during finalization", + }, + "finished_at": utcnow(), + "lease_owner": None, + "lease_expires_at": None, + } + ) + self._runs[key] = updated + event = None + else: + updated = run.model_copy( + update={ + "status": RunStatus.PENDING.value, + "error": error, + "finished_at": None, + "lease_owner": None, + "lease_expires_at": None, + "steering_closed": False, + } + ) + self._runs[key] = updated + run_events = self._events.setdefault(key, []) + event = RunEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=len(run_events) + 1, + kind="worker_retrying", + data={ + "attempt": attempt, + "max_attempts": max_attempts, + "error": error, + }, + ) + run_events.append(event) + await self._notify() + return updated.model_copy(deep=True), (event.model_copy(deep=True) if event else None) + + async def close_steering( + self, tenant_id: str, run_id: str, worker_id: str, attempt: int + ) -> bool: + """Atomically close new steering admission for this lease + attempt. + + Issue #16 PR #17 review round 6, point 3 (BLOCKER): once graph + execution has ended there is no safe point left for a newly + accepted steering event to ever be consumed, even though the Run + row may still read ``running``/``cancelling`` while the final + flush is in flight. The owning worker calls this the instant + execution ends (before the final flush starts) so a concurrent + ``/steer`` either lands before this gate closes (and gets synced + in as usual, or -- round 7, point 1 -- durably lands but is + detected as still-pending by the caller's post-flush disposition + call, see below) or after it closes (and is rejected with a + stable error) -- never durably accepted into a channel nobody + will ever drain again. Fenced the same way as + ``finish_run_if_owned``: a stale worker that has already lost its + lease cannot close the gate on the new owner's behalf. + + Issue #16 PR #17 review round 7, point 1 (BLOCKER), updated per + round 9, point 2: this alone is *not* sufficient to prevent a + permanently-pending steering row -- see + ``finalize_run_with_steering_disposition_if_owned()``, which the + Worker's post-flush finalization calls -- it is what actually + catches a row that was durably accepted a moment before this call + locked the run (and therefore was never, and can never be, + ingested by this attempt's channel), transitioning it to + ``superseded`` in the same transaction as the terminal status + write. (Round 7 originally described this check as a separate + ``Worker._execute`` post-flush ``list_pending_steering`` call; + round 9 merged that disposition into + ``finalize_run_with_steering_disposition_if_owned()`` itself.) + Deliberately *not* checked here: at the instant this call runs, + the graph may have already drained events into the channel's local + consumption log without those rows having been committed + (``status='consumed'``) yet -- that only happens moments later, in + the final flush this call precedes. Checking "any pending row + exists" here would misidentify perfectly ordinary, about-to-be- + flushed consumptions as a permanent race every single time. + """ + + async with self._lock: + key = (tenant_id, run_id) + run = self._runs.get(key) + if ( + run is None + or run.lease_owner != worker_id + or run.attempt != attempt + or enum_value(run.status) not in ACTIVE + ): + return False + if not run.steering_closed: + self._runs[key] = run.model_copy(update={"steering_closed": True}) + return True + async def retry_run( self, tenant_id: str, run_id: str, *, error: dict[str, Any] | None = None ) -> Run: + """Unfenced admin/back-compat retry-to-pending write. + + Restored per issue #16 PR #17 review round 9, point 5 (compatibility): + this is exported public API on both repository classes (see + ``lingxigraph.server.__init__``) and existed on ``main`` before this + PR -- it must not be deleted to satisfy a coverage gate. The + Worker's production finalization path uses only the fenced + ``retry_run_if_owned`` (see ``_commit_intent``/``_retry_or_dead_letter``); + this method remains available for callers outside that fenced + lease/attempt protocol. + """ + async with self._lock: key = (tenant_id, run_id) run = self._runs[key] @@ -359,6 +712,7 @@ async def retry_run( "finished_at": None, "lease_owner": None, "lease_expires_at": None, + "steering_closed": False, } ) self._runs[key] = updated @@ -382,6 +736,7 @@ async def redrive_run(self, tenant_id: str, run_id: str) -> Run | None: "finished_at": None, "lease_owner": None, "lease_expires_at": None, + "steering_closed": False, } ) self._runs[key] = updated @@ -394,9 +749,27 @@ async def request_cancel(self, tenant_id: str, run_id: str) -> bool: run = self._runs.get(key) if run is None or enum_value(run.status) in TERMINAL: return False + # Round 13 review (BLOCKER): a paused run that has since been + # resumed is only marked stale via the typed + # ``Run.superseded_by_run_id`` field -- its ``status`` + # deliberately stays ``paused`` so /steer's existing superseded + # gate keeps working. Cancel must honor that same signal + # *inside this lock*, or a cancel racing behind a resume would + # silently "succeed" against the old run while the real + # (resumed) descendant keeps executing untouched. + # Round 14 review (BLOCKER): this MUST be the typed field, not + # user-writable ``metadata`` -- a caller could otherwise forge + # ``metadata.superseded_by_run_id`` at run-creation time to + # falsely trigger this gate on a run that was never resumed. + superseded_by = run.superseded_by_run_id + if superseded_by is not None: + raise RunSupersededError( + f"run {run_id!r} was resumed as {superseded_by!r}; " + f"cancel the new run instead" + ) status = ( RunStatus.CANCELLED.value - if enum_value(run.status) == RunStatus.PENDING.value + if enum_value(run.status) in {RunStatus.PENDING.value, RunStatus.PAUSED.value} else RunStatus.CANCELLING.value ) self._runs[key] = run.model_copy( @@ -441,39 +814,688 @@ async def list_events( if event.sequence > after ] - async def create_schedule( - self, tenant_id: str, request: ScheduleCreate - ) -> Schedule: - schedule = Schedule(tenant_id=tenant_id, **request.model_dump()) + async def submit_steering( + self, + tenant_id: str, + run_id: str, + *, + kind: str, + payload: dict[str, Any], + metadata: dict[str, Any] | None = None, + idempotency_key: str | None = None, + max_payload_bytes: int = MAX_STEERING_PAYLOAD_BYTES, + ) -> tuple[RunSteeringEvent, bool]: + """Durably accept a steering event. Returns ``(event, created)``. + + Terminal runs raise :class:`RunTerminalError`. Running, queued, + cancelling, and paused runs each accept durably -- the *consuming* + safe point differs (see module docstring / issue #16 design doc), + but acceptance semantics are uniform: it means "written to the + durable inbox", never "the graph has processed it". + """ + + validate_steering_payload(payload, metadata, max_bytes=max_payload_bytes) async with self._lock: - self._schedules[(tenant_id, schedule.id)] = schedule - return schedule.model_copy(deep=True) + run = self._runs.get((tenant_id, run_id)) + if run is None: + raise KeyError(f"run {run_id!r} not found") + # Issue #16 PR #17 review round 7, point 3 (BLOCKER): the + # idempotency-key replay check must run *before* the terminal/ + # finalizing/superseded admission gates, not after. A client + # that durably submitted this exact event, then lost the 202 + # response and retried with the same Idempotency-Key after the + # run raced ahead to finalizing/terminal/superseded, must still + # get back the same event -- that is the entire point of an + # idempotency key. Gating replay on "the run is still active" + # turns every uncertain-outcome retry into a spurious 409. + if idempotency_key is not None: + existing_id = self._steering_keys.get((tenant_id, run_id, idempotency_key)) + if existing_id is not None: + existing = next( + event + for event in self._steering[(tenant_id, run_id)] + if event.id == existing_id + ) + # A prior attempt with this idempotency key may have + # committed the steering row but then failed before its + # ``run.steer.accepted`` event was recorded (review + # round 4, point 2) -- repair that gap on the retry + # instead of permanently skipping it. + self._ensure_steer_accepted_event_locked(tenant_id, run_id, existing) + return existing.model_copy(deep=True), False + # Only a genuinely new admission decision (no existing row for + # this idempotency key -- or none was given) is subject to the + # terminal/finalizing/superseded gates. + if enum_value(run.status) in TERMINAL: + raise RunTerminalError( + f"run {run_id!r} is in terminal state " + f"{enum_value(run.status)!r} and cannot accept new steering input" + ) + if run.steering_closed: + raise RunFinalizingError( + f"run {run_id!r} has finished executing and is finalizing; " + "no further steering input can be safely consumed" + ) + # Round 14 review (BLOCKER): read the typed field, not + # user-writable ``metadata`` -- see ``request_cancel`` above. + superseded_by = run.superseded_by_run_id + if superseded_by is not None: + raise RunSupersededError( + f"run {run_id!r} was resumed as {superseded_by!r}; " + f"steer the new run instead" + ) + events = self._steering.setdefault((tenant_id, run_id), []) + event = RunSteeringEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=len(events) + 1, + kind=kind, + payload=dict(payload), + metadata=dict(metadata or {}), + idempotency_key=idempotency_key, + status="pending", + ) + events.append(event) + if idempotency_key is not None: + self._steering_keys[(tenant_id, run_id, idempotency_key)] = event.id + self._ensure_steer_accepted_event_locked(tenant_id, run_id, event) + await self._notify() + return event.model_copy(deep=True), True + + def _ensure_steer_accepted_event_locked( + self, + tenant_id: str, + run_id: str, + steering_event: RunSteeringEvent, + *, + transferred_from_run_id: str | None = None, + ) -> RunEvent: + """Idempotently ensure ``run.steer.accepted`` exists for one event. + + Issue #16 PR #17 review round 4, point 2: appending the + ``run.steer.accepted`` lifecycle event used to be a second, + separately-committed step after ``submit_steering`` (or the + paused-resume steering migration) -- a transient failure on that + second step, followed by an idempotent-key retry that finds the + steering row already created, permanently skipped the event + forever. Doing both under the caller's already-held ``self._lock`` + and keying this on the steering event's id (never appending twice + for the same id) makes "accept the steering row" and "record the + accepted lifecycle event" a single atomic, idempotent unit -- a + retry (of the request, or of this call) always converges on + exactly one ``run.steer.accepted`` event per steering event id. + Assumes ``self._lock`` is already held. + """ + + run_events = self._events.setdefault((tenant_id, run_id), []) + for existing in run_events: + if ( + existing.kind == "run.steer.accepted" + and existing.data.get("steering_event_id") == steering_event.id + ): + return existing + data: dict[str, Any] = { + "steering_event_id": steering_event.id, + "sequence": steering_event.sequence, + "kind": steering_event.kind, + } + if transferred_from_run_id is not None: + data["transferred_from_run_id"] = transferred_from_run_id + data["source_event_id"] = steering_event.source_event_id + event = RunEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=len(run_events) + 1, + kind="run.steer.accepted", + data=data, + ) + run_events.append(event) + return event + + async def list_pending_steering( + self, tenant_id: str, run_id: str + ) -> list[RunSteeringEvent]: + """Read-only view of never-consumed steering events, in order.""" - async def list_schedules(self, tenant_id: str) -> list[Schedule]: async with self._lock: return [ - value.model_copy(deep=True) - for (tenant, _), value in self._schedules.items() - if tenant == tenant_id + event.model_copy(deep=True) + for event in self._steering.get((tenant_id, run_id), ()) + if event.status in ("pending", "delivered") ] - async def patch_schedule( - self, tenant_id: str, schedule_id: str, request: SchedulePatch - ) -> Schedule | None: + def _ensure_steer_superseded_event_locked( + self, + tenant_id: str, + run_id: str, + steering_event: RunSteeringEvent, + *, + reason: str, + superseded_by_run_id: str | None = None, + replacement_steering_event_id: str | None = None, + ) -> RunEvent: + """Idempotently ensure ``run.steer.superseded`` exists for one event. + + Issue #16 PR #17 review round 8, point 3 (BLOCKER): issue #16's + observability section requires a durable ``run.steer.superseded`` + lifecycle event for every steering row that transitions to the + ``superseded`` disposition -- both the paused-run resume-transfer + case and the late-boundary-race disposition (round 8, point 1). + Keyed on the steering event's id, never appending twice for the + same id, so a retried transaction (e.g. a resume attempt retried + after a transient failure, or a repeated late-race supersede call) + converges on exactly one event per steering event id -- the same + idempotency discipline as + :meth:`_ensure_steer_accepted_event_locked`. Assumes ``self._lock`` + is already held. + """ + + run_events = self._events.setdefault((tenant_id, run_id), []) + for existing in run_events: + if ( + existing.kind == "run.steer.superseded" + and existing.data.get("steering_event_id") == steering_event.id + ): + return existing + data: dict[str, Any] = { + "steering_event_id": steering_event.id, + "source_event_id": steering_event.source_event_id or steering_event.id, + # Issue #16 PR #17 review round 9, point 4 (REQUIRED): every + # other steering lifecycle event (``run.steer.accepted``, + # ``run.steer.consumed``) already carries the steering event's + # own ``sequence``/``kind`` -- ``run.steer.superseded`` was + # missing both, which issue #16's observability section + # requires at minimum for every steering lifecycle event. + "sequence": steering_event.sequence, + "kind": steering_event.kind, + "reason": reason, + "superseded_by_run_id": superseded_by_run_id, + "replacement_steering_event_id": replacement_steering_event_id, + } + event = RunEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=len(run_events) + 1, + kind="run.steer.superseded", + data=data, + ) + run_events.append(event) + return event + + async def finalize_run_with_steering_disposition_if_owned( + self, + tenant_id: str, + run_id: str, + worker_id: str, + attempt: int, + status: RunStatus | str, + *, + output: dict[str, Any] | None = None, + error: dict[str, Any] | None = None, + supersede_pending: bool = True, + supersede_reason: str = "unconsumed_at_final_boundary", + ) -> tuple[Run, list[RunEvent]] | None: + """Supersede leftover steering AND commit the Run's terminal status + as one durable unit. + + Issue #16 PR #17 review round 9, point 2 (BLOCKER): round 8's + terminal path called ``supersede_pending_steering_if_owned()`` (one + fenced write, committing the ``superseded`` dispositions and their + ``run.steer.superseded`` events) and then, moments later and + separately, ``finish_run_if_owned()`` (a second fenced write for the + Run's terminal status). Each write was individually fenced and + correct in isolation, but they were not the same durable boundary: + a crash, a failed DB call, or a reclaimed lease between the two + left steering permanently ``superseded`` while the Run itself never + actually reached a terminal status -- the next owner would see the + Run back in an active state with admission reopened, but the + "leftover" steering the previous attempt already disposed of could + never be recovered, even though that disposition's premise (the Run + is finishing right now) turned out to be false. + + This method makes only two outcomes possible: either everything + below rolls back together (a subsequent owner still sees the + steering rows as ``pending``/``delivered``, fully recoverable), or + the steering disposition and the terminal Run status commit + together, in the exact same critical section -- for + ``InMemoryRepository`` that is one lock acquisition with no + intervening ``await``; :class:`PostgresRepository` overrides this + with one real SQL transaction. Fenced identically to + ``finish_run_if_owned``/``supersede_pending_steering_if_owned``: a + ``None`` result means the caller's lease was already reclaimed by a + new owner, and nothing here was written. The cancel-wins re-check + from ``finish_run_if_owned`` still applies atomically inside this + same critical section. + + Returns ``(updated_run, superseded_events)`` on success, or + ``None`` if the lease was no longer owned. + """ + async with self._lock: - key = (tenant_id, schedule_id) - current = self._schedules.get(key) - if current is None: + key = (tenant_id, run_id) + run = self._runs.get(key) + if ( + run is None + or run.lease_owner != worker_id + or run.attempt != attempt + or enum_value(run.status) not in ACTIVE + ): return None - values = request.model_dump(exclude_none=True) - values["updated_at"] = utcnow() - updated = current.model_copy(update=values, deep=True) - self._schedules[key] = updated - return updated.model_copy(deep=True) + superseded_events: list[RunEvent] = [] + if supersede_pending: + events = self._steering.get((tenant_id, run_id), []) + for index, event in enumerate(events): + if event.status in ("pending", "delivered"): + events[index] = event.model_copy(update={"status": "superseded"}) + superseded_events.append( + self._ensure_steer_superseded_event_locked( + tenant_id, + run_id, + event, + reason=supersede_reason, + ) + ) + final_status = RunStatus(status).value + final_output = output + final_error = error + if ( + enum_value(run.status) == RunStatus.CANCELLING.value + and final_status != RunStatus.CANCELLED.value + ): + final_status = RunStatus.CANCELLED.value + final_output = None + final_error = { + "code": "run_cancelled", + "message": "run was cancelled during finalization", + } + updated = run.model_copy( + update={ + "status": final_status, + "output": final_output, + "error": final_error, + "finished_at": utcnow(), + "lease_owner": None, + "lease_expires_at": None, + } + ) + self._runs[key] = updated + await self._notify() + return updated.model_copy(deep=True), superseded_events + + async def supersede_pending_steering_if_owned( + self, tenant_id: str, run_id: str, worker_id: str, attempt: int + ) -> list[RunEvent] | None: + """Durably supersede any steering row still pending/delivered. + + Issue #16 PR #17 review round 8, point 1 (BLOCKER) -- CHOSEN FIX: + "seal admission at the safe boundary + supersede late events". + ``close_steering()`` already seals *new* admission the instant + graph execution ends; this closes the remaining gap round 7's fix + mishandled -- a steering event that durably lands *after* the + graph's last safe point but *before* (or racing) the seal itself. + Rather than forcing the run through an indefinite retry/dead-letter + loop (round 7's approach, which hardcoded an application decision + -- "does undrained steering mean rerun?" -- into the Runtime, in + violation of issue #16's Runtime/application boundary), any row + still ``pending``/``delivered`` once the final flush has completed + is durably transitioned to ``superseded`` here, in the same + lease/attempt-fenced call as everything else this finalization + window does. The run is then free to finalize with its originally + computed intent -- a graph that legitimately never calls + ``drain_steering()`` is not penalized just because a steering + event happened to exist. + + Fenced exactly like ``commit_steering_consumptions_if_owned``: a + ``None`` result means this worker's lease was already reclaimed by + a new owner between the final flush and this call, and the caller + must not treat any of this run's finalization as decided by this + attempt. + """ - async def delete_schedule(self, tenant_id: str, schedule_id: str) -> bool: async with self._lock: - return self._schedules.pop((tenant_id, schedule_id), None) is not None + run = self._runs.get((tenant_id, run_id)) + if ( + run is None + or run.lease_owner != worker_id + or run.attempt != attempt + or enum_value(run.status) not in ACTIVE + ): + return None + events = self._steering.get((tenant_id, run_id), []) + stale = [event for event in events if event.status in ("pending", "delivered")] + if not stale: + return [] + stored: list[RunEvent] = [] + for index, event in enumerate(events): + if event.status in ("pending", "delivered"): + events[index] = event.model_copy(update={"status": "superseded"}) + stored.append( + self._ensure_steer_superseded_event_locked( + tenant_id, + run_id, + event, + reason="unconsumed_at_final_boundary", + ) + ) + await self._notify() + return stored + + async def mark_steering_consumed( + self, tenant_id: str, run_id: str, event_ids: Sequence[str] + ) -> None: + """Mark events consumed once the graph has actually drained them.""" + + if not event_ids: + return + ids = set(event_ids) + async with self._lock: + events = self._steering.get((tenant_id, run_id), []) + for index, event in enumerate(events): + if event.id in ids and event.status != "consumed": + events[index] = event.model_copy( + update={"status": "consumed", "consumed_at": utcnow()} + ) + + async def commit_steering_consumptions( + self, + tenant_id: str, + run_id: str, + consumptions: Sequence[SteeringConsumption], + ) -> list[RunEvent]: + """Durably record consumption (status + lifecycle event), atomically. + + Issue #16 PR #17 review point 2: ``mark_steering_consumed`` plus a + separate ``append_event("run.steer.consumed")`` call left a window + where a caller that destructively popped its local consumption log + *before* calling either could lose the record forever on a + transient failure -- the DB row could end up stuck ``pending`` + forever (no ``on_drain``/dedup path re-surfaces it once + ``SteeringChannel._seen_ids`` has recorded the id), or the status + could flip to ``consumed`` while the ``run.steer.consumed`` + observability event silently never appears. This method updates + both under one lock acquisition so a caller (see + ``Worker._sync_steering_out``) can safely defer clearing its own + local bookkeeping until *after* this succeeds, and retry on the + next safe point if it raises. + """ + + if not consumptions: + return [] + async with self._lock: + stored = self._commit_steering_consumptions_locked(tenant_id, run_id, consumptions) + await self._notify() + return stored + + async def commit_steering_consumptions_if_owned( + self, + tenant_id: str, + run_id: str, + worker_id: str, + attempt: int, + consumptions: Sequence[SteeringConsumption], + ) -> list[RunEvent] | None: + """Fenced counterpart of :meth:`commit_steering_consumptions`. + + Issue #16 PR #17 review round 7, point 2 (BLOCKER): the unfenced + version only keys on ``(tenant_id, run_id, steering_ids)`` -- it + never checks ``lease_owner``/``attempt``. A worker whose lease has + already been reclaimed by a new owner (attempt N+1) can still + durably commit a stale consumption from attempt N *after* the new + owner has already re-ingested the same still-pending row into its + own channel and started processing it, corrupting the steering + inbox/lifecycle even though the stale worker's later + ``finish_run_if_owned``/``retry_run_if_owned`` call is correctly + rejected. + + This checks ``lease_owner == worker_id``, ``attempt == attempt``, + and the run is still active, inside the *same* lock acquisition as + the actual status/event writes -- on any mismatch it returns + ``None`` and touches nothing, so the caller must not locally ack + either. + """ + + if not consumptions: + return [] + async with self._lock: + run = self._runs.get((tenant_id, run_id)) + if ( + run is None + or run.lease_owner != worker_id + or run.attempt != attempt + or enum_value(run.status) not in ACTIVE + ): + return None + stored = self._commit_steering_consumptions_locked(tenant_id, run_id, consumptions) + await self._notify() + return stored + + def _commit_steering_consumptions_locked( + self, + tenant_id: str, + run_id: str, + consumptions: Sequence[SteeringConsumption], + ) -> list[RunEvent]: + """Body of the consumption commit; assumes ``self._lock`` is held.""" + + ids = {consumption.event.id for consumption in consumptions} + events = self._steering.get((tenant_id, run_id), []) + transitioned: set[str] = set() + for index, event in enumerate(events): + if event.id in ids and event.status != "consumed": + events[index] = event.model_copy( + update={"status": "consumed", "consumed_at": utcnow()} + ) + transitioned.add(event.id) + run_events = self._events.setdefault((tenant_id, run_id), []) + stored: list[RunEvent] = [] + for consumption in consumptions: + steering_event = consumption.event + if steering_event.id not in transitioned: + # Already consumed by a previous call (idempotent + # retry, e.g. a worker that re-sends the same batch + # after an ack it never observed) -- do not emit a + # second, semantically duplicate ``run.steer.consumed`` + # lifecycle event for it (issue #16 PR #17 review + # point 3). + continue + data: dict[str, Any] = { + "steering_event_id": steering_event.id, + "sequence": steering_event.sequence, + "kind": steering_event.kind, + "queue_latency_seconds": consumption.queue_latency_seconds, + "node": consumption.node, + "namespace": list(consumption.namespace), + "task_id": consumption.task_id, + } + if steering_event.source_event_id is not None: + data["source_event_id"] = steering_event.source_event_id + stored_event = RunEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=len(run_events) + 1, + kind="run.steer.consumed", + data=data, + ) + run_events.append(stored_event) + stored.append(stored_event.model_copy(deep=True)) + return stored + + async def list_steering(self, tenant_id: str, run_id: str) -> list[RunSteeringEvent]: + async with self._lock: + return [ + event.model_copy(deep=True) + for event in self._steering.get((tenant_id, run_id), ()) + ] + + def _transfer_pending_steering_locked( + self, tenant_id: str, old_run_id: str, new_run_id: str + ) -> list[RunSteeringEvent]: + """The body of the old-run -> new-run steering migration. + + Assumes ``self._lock`` is already held -- only called from + :meth:`resume_run_with_pending_steering`, which creates the new Run + and performs this migration under one lock acquisition (see issue + #16 PR #17 review point 1's "not atomic" finding: this must never + run as a second, separately-lockable step after the new Run is + already visible to :meth:`claim_run`). + + Every event still ``pending``/``delivered`` under ``old_run_id`` is + marked ``superseded`` in place (its terminal state -- it was never + drained by the paused run) and re-inserted as a fresh ``pending`` + event under ``new_run_id``, preserving order, ``kind``, ``payload``, + ``metadata`` and ``idempotency_key``. The new row's ``created_at`` + and ``source_event_id`` preserve the original event's identity + (review point 3): external callers correlate the id a paused-run + ``/steer`` call returned to the eventual ``consumed`` event via + ``source_event_id``, and ``queue_latency_seconds`` computed from the + preserved ``created_at`` includes the time spent waiting while + paused, not just time since the transfer. + """ + + old_run = self._runs.get((tenant_id, old_run_id)) + if old_run is not None: + # Round 14 review (BLOCKER): write the typed field, not + # user-writable ``metadata`` -- see ``request_cancel`` above. + self._runs[(tenant_id, old_run_id)] = old_run.model_copy( + update={"superseded_by_run_id": new_run_id} + ) + old_events = self._steering.get((tenant_id, old_run_id), []) + pending = sorted( + (event for event in old_events if event.status in ("pending", "delivered")), + key=lambda event: event.sequence, + ) + if not pending: + return [] + for index, event in enumerate(old_events): + if event.status in ("pending", "delivered"): + old_events[index] = event.model_copy(update={"status": "superseded"}) + new_events = self._steering.setdefault((tenant_id, new_run_id), []) + transferred: list[RunSteeringEvent] = [] + for event in pending: + new_event = RunSteeringEvent( + tenant_id=tenant_id, + run_id=new_run_id, + sequence=len(new_events) + 1, + kind=event.kind, + payload=dict(event.payload), + metadata=dict(event.metadata), + idempotency_key=event.idempotency_key, + status="pending", + source_event_id=event.source_event_id or event.id, + created_at=event.created_at, + ) + new_events.append(new_event) + if event.idempotency_key is not None: + self._steering_keys[(tenant_id, new_run_id, event.idempotency_key)] = ( + new_event.id + ) + # Record the transferred event's ``run.steer.accepted`` inside + # this same locked migration (review round 4, point 2) instead + # of leaving it to a second, separately-committed + # ``append_event`` call after the endpoint returns from this + # method -- otherwise a transient failure of that later call + # permanently loses the accepted record for the transferred run. + self._ensure_steer_accepted_event_locked( + tenant_id, new_run_id, new_event, transferred_from_run_id=old_run_id + ) + # Issue #16 PR #17 review round 8, point 3 (BLOCKER): the old + # row's transition to ``superseded`` above must be paired with + # a durable ``run.steer.superseded`` lifecycle event, recorded + # in this same locked migration -- otherwise an SSE/RunEvent- + # only client has no way to learn the old row stopped being + # pending or which new run/event now owns it. + self._ensure_steer_superseded_event_locked( + tenant_id, + old_run_id, + event, + reason="resume_transfer", + superseded_by_run_id=new_run_id, + replacement_steering_event_id=new_event.id, + ) + transferred.append(new_event.model_copy(deep=True)) + return transferred + + async def resume_run_with_pending_steering( + self, + tenant_id: str, + thread_id: str | None, + assistant: Assistant, + request: RunCreate, + old_run_id: str, + ) -> tuple[Run, list[RunSteeringEvent]]: + """Atomically create the resumed Run and migrate paused steering. + + Issue #16 PR #17 review point 1: creating the resumed Run and + transferring its predecessor's still-pending steering used to be + two separately-locked operations (``create_run`` then + ``transfer_pending_steering``); a worker could claim -- and even + finish -- the brand-new ``pending`` Run in the window between them, + so the migrated steering could land after nobody was left to + consume it. Doing both under one ``self._lock`` acquisition (no + ``await`` in between) means no other coroutine, including + ``claim_run``, can observe the new Run row before its steering + migration has also completed. + + Issue #16 PR #17 review round 4, point 1: the endpoint's own + ``GET old run -> status == paused`` pre-check happens *before* + this call and is therefore not atomic with it -- two concurrent + resume requests can both pass it. Re-validate, inside this same + lock acquisition, that the old Run is still ``paused`` and has no + ``superseded_by_run_id`` set yet; the loser of the race gets + :class:`RunResumeConflictError` instead of silently creating a + second descendant Run and overwriting the winner's + ``superseded_by_run_id``. + """ + + async with self._lock: + old_run = self._runs.get((tenant_id, old_run_id)) + if ( + old_run is None + or enum_value(old_run.status) != RunStatus.PAUSED.value + # Round 14 review (BLOCKER): typed field, not user-writable + # ``metadata`` -- see ``request_cancel`` above. + or old_run.superseded_by_run_id is not None + ): + raise RunResumeConflictError( + f"run {old_run_id!r} is no longer resumable " + "(it is not paused, or has already been resumed by a concurrent request)" + ) + run = self._create_run_locked(tenant_id, thread_id, assistant, request) + transferred = self._transfer_pending_steering_locked(tenant_id, old_run_id, run.id) + await self._notify() + return run.model_copy(deep=True), transferred + + async def create_schedule(self, tenant_id: str, request: ScheduleCreate) -> Schedule: + schedule = Schedule(tenant_id=tenant_id, **request.model_dump()) + async with self._lock: + self._schedules[(tenant_id, schedule.id)] = schedule + return schedule.model_copy(deep=True) + + async def list_schedules(self, tenant_id: str) -> list[Schedule]: + async with self._lock: + return [ + value.model_copy(deep=True) + for (tenant, _), value in self._schedules.items() + if tenant == tenant_id + ] + + async def patch_schedule( + self, tenant_id: str, schedule_id: str, request: SchedulePatch + ) -> Schedule | None: + async with self._lock: + key = (tenant_id, schedule_id) + current = self._schedules.get(key) + if current is None: + return None + values = request.model_dump(exclude_none=True) + values["updated_at"] = utcnow() + updated = current.model_copy(update=values, deep=True) + self._schedules[key] = updated + return updated.model_copy(deep=True) + + async def delete_schedule(self, tenant_id: str, schedule_id: str) -> bool: + async with self._lock: + return self._schedules.pop((tenant_id, schedule_id), None) is not None async def audit(self, record: AuditRecord) -> None: async with self._lock: @@ -493,12 +1515,8 @@ async def stats(self, tenant_id: str) -> dict[str, Any]: if tenant == tenant_id ), "threads": sum(tenant == tenant_id for tenant, _ in self._threads), - "assistants": sum( - tenant == tenant_id for tenant, _ in self._assistants - ), - "schedules": sum( - tenant == tenant_id for tenant, _ in self._schedules - ), + "assistants": sum(tenant == tenant_id for tenant, _ in self._assistants), + "schedules": sum(tenant == tenant_id for tenant, _ in self._schedules), } async def wait_for_change(self, timeout: float = 1.0) -> None: @@ -538,7 +1556,9 @@ def __init__( from psycopg.rows import dict_row from psycopg.types.json import Jsonb except ImportError as exc: # pragma: no cover - raise RuntimeError("install lingxigraph[postgres] to use PostgresRepository") from exc + raise RuntimeError( + "install lingxigraph[postgres] to use PostgresRepository" + ) from exc self._psycopg = psycopg self._dict_row = dict_row self._jsonb = Jsonb @@ -565,14 +1585,18 @@ def _healthcheck_sync(self) -> bool: def _setup_sync(self) -> None: from importlib.resources import files - migration = ( - files("lingxigraph.server") - .joinpath("migrations/0001_v1.sql") - .read_text(encoding="utf-8") - .replace("{{schema}}", self._schema) + migrations_dir = files("lingxigraph.server").joinpath("migrations") + names = sorted( + entry.name for entry in migrations_dir.iterdir() if entry.name.endswith(".sql") ) with self._connect() as conn, conn.cursor() as cursor: - cursor.execute(migration) + for name in names: + migration = ( + migrations_dir.joinpath(name) + .read_text(encoding="utf-8") + .replace("{{schema}}", self._schema) + ) + cursor.execute(migration) @staticmethod def _tenant(cursor, tenant_id: str) -> None: @@ -898,9 +1922,7 @@ def _heartbeat_sync(self, tenant_id, run_id, worker_id, lease_seconds): return cursor.rowcount > 0 async def finish_run(self, tenant_id, run_id, status, *, output=None, error=None): - await asyncio.to_thread( - self._finish_run_sync, tenant_id, run_id, status, output, error - ) + await asyncio.to_thread(self._finish_run_sync, tenant_id, run_id, status, output, error) value = await self.get_run(tenant_id, run_id) assert value is not None return value @@ -915,6 +1937,282 @@ async def redrive_run(self, tenant_id, run_id): changed = await asyncio.to_thread(self._retry_run_sync, tenant_id, run_id, None, True) return await self.get_run(tenant_id, run_id) if changed else None + async def finish_run_if_owned( + self, tenant_id, run_id, worker_id, attempt, status, *, output=None, error=None + ) -> Run | None: + row = await asyncio.to_thread( + self._finish_run_if_owned_sync, + tenant_id, + run_id, + worker_id, + attempt, + status, + output, + error, + ) + if row is None: + return None + return self._run_from_row(row) + + def _finish_run_if_owned_sync( + self, tenant_id, run_id, worker_id, attempt, status, output, error + ): # pragma: no cover -- exercised only against a live Postgres in the `postgres` CI job, not `quality`'s coverage gate + # Fenced/CAS'd terminal-status write (issue #16 PR #17 review round + # 6, point 1, BLOCKER). Selecting the row ``FOR UPDATE`` first -- + # filtered on lease_owner/attempt/status, mirroring the pattern + # ``_submit_steering_sync``/``_append_event_sync`` already use -- + # then issuing a plain follow-up ``UPDATE`` inside the same + # transaction is deliberately simpler (and easier to verify by + # inspection without a live Postgres) than trying to express the + # fencing *and* the cancellation-wins re-check as one combined + # CASE-heavy UPDATE statement. + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + cursor.execute( + f"""SELECT status FROM {self._schema}.runs + WHERE tenant_id=%s AND id=%s AND lease_owner=%s AND attempt=%s + AND status IN ('running','cancelling') FOR UPDATE""", + (tenant_id, run_id, worker_id, attempt), + ) + row = cursor.fetchone() + if row is None: + return False + final_status = RunStatus(status).value + final_output = output + final_error = error + # Review round 6, point 2 (BLOCKER): re-check the current + # control state inside this same locked transaction -- + # cancellation always wins over a stale non-cancel intent. + if row["status"] == "cancelling" and final_status != RunStatus.CANCELLED.value: + final_status = RunStatus.CANCELLED.value + final_output = None + final_error = { + "code": "run_cancelled", + "message": "run was cancelled during finalization", + } + # Issue #16 PR #17 review round 10, point 2 (REQUIRED): use + # ``RETURNING *`` so the row this async wrapper hands back to + # the caller is the *same transaction's own* resulting + # snapshot, not a separate, later ``get_run()`` read that a + # concurrent write (e.g. a racing ``/redrive`` or unfenced + # ``retry_run()``) could observe a different value through. + cursor.execute( + f"""UPDATE {self._schema}.runs SET status=%s, output=%s, error=%s, + finished_at=NOW(), lease_owner=NULL, lease_expires_at=NULL + WHERE tenant_id=%s AND id=%s RETURNING *""", + ( + final_status, + self._jsonb(final_output) if final_output is not None else None, + self._jsonb(final_error) if final_error is not None else None, + tenant_id, + run_id, + ), + ) + return cursor.fetchone() + + async def retry_run_if_owned( + self, tenant_id, run_id, worker_id, attempt, *, error=None + ) -> Run | None: + row = await asyncio.to_thread( + self._retry_run_if_owned_sync, tenant_id, run_id, worker_id, attempt, error + ) + if row is None: + return None + return self._run_from_row(row) + + def _retry_run_if_owned_sync( + self, tenant_id, run_id, worker_id, attempt, error + ): # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + cursor.execute( + f"""SELECT status FROM {self._schema}.runs + WHERE tenant_id=%s AND id=%s AND lease_owner=%s AND attempt=%s + AND status IN ('running','cancelling') FOR UPDATE""", + (tenant_id, run_id, worker_id, attempt), + ) + row = cursor.fetchone() + if row is None: + return None + # Issue #16 PR #17 review round 10, point 2 (REQUIRED): return + # the transaction's own ``RETURNING *`` snapshot, not a + # separate later ``get_run()`` read (see the matching comment + # on ``_finish_run_if_owned_sync``). + if row["status"] == "cancelling": + # Cancellation wins: never send a cancelling run back to + # pending because of a stale worker's retry decision. + cursor.execute( + f"""UPDATE {self._schema}.runs SET status='cancelled', error=%s, + finished_at=NOW(), lease_owner=NULL, lease_expires_at=NULL + WHERE tenant_id=%s AND id=%s RETURNING *""", + ( + self._jsonb( + { + "code": "run_cancelled", + "message": "run was cancelled during finalization", + } + ), + tenant_id, + run_id, + ), + ) + else: + cursor.execute( + f"""UPDATE {self._schema}.runs SET status='pending', + error=%s, finished_at=NULL, lease_owner=NULL, lease_expires_at=NULL, + steering_closed=FALSE + WHERE tenant_id=%s AND id=%s RETURNING *""", + (self._jsonb(error) if error is not None else None, tenant_id, run_id), + ) + return cursor.fetchone() + + async def retry_run_with_event_if_owned( + self, + tenant_id, + run_id, + worker_id, + attempt, + *, + error=None, + max_attempts=None, + ) -> tuple[Run, RunEvent | None] | None: + """SQL counterpart of + :meth:`InMemoryRepository.retry_run_with_event_if_owned` (issue + #16 PR #17 review round 10, point 1, BLOCKER). One real PostgreSQL + transaction covers: locking the run row, re-checking the fence and + control state (cancel-wins), writing the retry-to-pending (or + cancel-wins) status transition, and -- only when the transition + actually lands on ``pending`` -- inserting the matching + ``worker_retrying`` :class:`RunEvent` in the *same* transaction, so + both the status write and the event become externally visible + together or not at all. This closes the race a separate + ``retry_run_if_owned()`` + ``append_event()`` pair left open: a + concurrent cancel or a new worker's claim landing in the gap + between two independently-committed writes could previously + observe (or produce) a stale/out-of-order ``worker_retrying`` + event. + """ + + result = await asyncio.to_thread( + self._retry_run_with_event_if_owned_sync, + tenant_id, + run_id, + worker_id, + attempt, + error, + max_attempts, + ) + if result is None: + return None + row, event = result + return self._run_from_row(row), event + + def _retry_run_with_event_if_owned_sync( # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + self, tenant_id, run_id, worker_id, attempt, error, max_attempts + ): + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + cursor.execute( + f"""SELECT status FROM {self._schema}.runs + WHERE tenant_id=%s AND id=%s AND lease_owner=%s AND attempt=%s + AND status IN ('running','cancelling') FOR UPDATE""", + (tenant_id, run_id, worker_id, attempt), + ) + row = cursor.fetchone() + if row is None: + return None + if row["status"] == "cancelling": + # Cancellation wins: never send a cancelling run back to + # pending, and never emit a ``worker_retrying`` event for a + # run that is really cancelled. + cursor.execute( + f"""UPDATE {self._schema}.runs SET status='cancelled', error=%s, + finished_at=NOW(), lease_owner=NULL, lease_expires_at=NULL + WHERE tenant_id=%s AND id=%s RETURNING *""", + ( + self._jsonb( + { + "code": "run_cancelled", + "message": "run was cancelled during finalization", + } + ), + tenant_id, + run_id, + ), + ) + updated_row = cursor.fetchone() + return updated_row, None + cursor.execute( + f"""UPDATE {self._schema}.runs SET status='pending', + error=%s, finished_at=NULL, lease_owner=NULL, lease_expires_at=NULL, + steering_closed=FALSE + WHERE tenant_id=%s AND id=%s RETURNING *""", + (self._jsonb(error) if error is not None else None, tenant_id, run_id), + ) + updated_row = cursor.fetchone() + # Same transaction, same lock: allocate the next RunEvent + # sequence and insert ``worker_retrying`` here, not via a + # later, separately-committed ``append_event()`` call. + cursor.execute( + f"""SELECT COALESCE(MAX(sequence),0)+1 AS next + FROM {self._schema}.run_events WHERE tenant_id=%s AND run_id=%s""", + (tenant_id, run_id), + ) + sequence = int(cursor.fetchone()["next"]) + data: dict[str, Any] = { + "attempt": attempt, + "max_attempts": max_attempts, + "error": error, + } + event = RunEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=sequence, + kind="worker_retrying", + data=data, + ) + cursor.execute( + f"""INSERT INTO {self._schema}.run_events + (id,tenant_id,run_id,sequence,kind,data,created_at) + VALUES (%s,%s,%s,%s,%s,%s,%s)""", + ( + event.id, + tenant_id, + run_id, + sequence, + "worker_retrying", + self._jsonb(data), + event.created_at, + ), + ) + return updated_row, event + + async def close_steering(self, tenant_id, run_id, worker_id, attempt) -> bool: + return await asyncio.to_thread( + self._close_steering_sync, tenant_id, run_id, worker_id, attempt + ) + + def _close_steering_sync( + self, tenant_id, run_id, worker_id, attempt + ) -> bool: # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + """SQL counterpart of + :meth:`InMemoryRepository.close_steering` -- see its docstring for + why no "undrained" check happens here (issue #16 PR #17 review + round 7, point 1, BLOCKER; the real check, as of round 9, point 2, + is ``finalize_run_with_steering_disposition_if_owned()``, which the + Worker's post-flush finalization calls). + """ + + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + cursor.execute( + f"""UPDATE {self._schema}.runs SET steering_closed=TRUE + WHERE tenant_id=%s AND id=%s AND lease_owner=%s AND attempt=%s + AND status IN ('running','cancelling')""", + (tenant_id, run_id, worker_id, attempt), + ) + return cursor.rowcount > 0 + def _retry_run_sync(self, tenant_id, run_id, error, reset_attempt): with self._connect() as conn, conn.cursor() as cursor: self._tenant(cursor, tenant_id) @@ -922,7 +2220,8 @@ def _retry_run_sync(self, tenant_id, run_id, error, reset_attempt): attempt = "attempt=0," if reset_attempt else "" cursor.execute( f"""UPDATE {self._schema}.runs SET status='pending', {attempt} - error=%s, finished_at=NULL, lease_owner=NULL, lease_expires_at=NULL + error=%s, finished_at=NULL, lease_owner=NULL, lease_expires_at=NULL, + steering_closed=FALSE WHERE tenant_id=%s AND id=%s {allowed}""", (self._jsonb(error) if error is not None else None, tenant_id, run_id), ) @@ -947,15 +2246,53 @@ def _finish_run_sync(self, tenant_id, run_id, status, output, error): async def request_cancel(self, tenant_id, run_id): return await asyncio.to_thread(self._request_cancel_sync, tenant_id, run_id) - def _request_cancel_sync(self, tenant_id, run_id): + def _request_cancel_sync( + self, tenant_id, run_id + ): # pragma: no cover -- Postgres-only, covered by the `postgres` CI job with self._connect() as conn, conn.cursor() as cursor: self._tenant(cursor, tenant_id) + # Round 13 review (BLOCKER): lock the row first and check the + # typed ``superseded_by_run_id`` column *inside this same + # lock*, before deciding the status transition. A resumed-away + # paused run keeps status='paused' (by design, so /steer's + # existing superseded gate works) -- cancel must honor that + # same signal atomically, or a cancel racing behind a resume + # could falsely report success against the stale run while the + # real (resumed) descendant keeps running untouched. Doing + # this as a pre-read outside the lock would reopen the same + # TOCTOU class of bug already fixed for double-resume. + # Round 14 review (BLOCKER): this reads the typed + # ``superseded_by_run_id`` column, not user-writable + # ``metadata`` -- a caller could otherwise forge + # ``metadata.superseded_by_run_id`` at run-creation time to + # falsely trigger this gate on a run that was never resumed. + cursor.execute( + f"""SELECT status, superseded_by_run_id FROM {self._schema}.runs + WHERE tenant_id=%s AND id=%s FOR UPDATE""", + (tenant_id, run_id), + ) + row = cursor.fetchone() + if row is None: + return False + if row["status"] in { + "succeeded", + "failed", + "cancelled", + "timed_out", + "dead_letter", + }: + return False + superseded_by = row["superseded_by_run_id"] + if superseded_by is not None: + raise RunSupersededError( + f"run {run_id!r} was resumed as {superseded_by!r}; " + f"cancel the new run instead" + ) cursor.execute( f"""UPDATE {self._schema}.runs SET - status=CASE WHEN status='pending' THEN 'cancelled' ELSE 'cancelling' END, - finished_at=CASE WHEN status='pending' THEN NOW() ELSE finished_at END - WHERE tenant_id=%s AND id=%s - AND status NOT IN ('succeeded','failed','cancelled','timed_out','dead_letter')""", + status=CASE WHEN status IN ('pending','paused') THEN 'cancelled' ELSE 'cancelling' END, + finished_at=CASE WHEN status IN ('pending','paused') THEN NOW() ELSE finished_at END + WHERE tenant_id=%s AND id=%s""", (tenant_id, run_id), ) return cursor.rowcount > 0 @@ -968,9 +2305,7 @@ async def is_cancel_requested(self, tenant_id, run_id): } async def append_event(self, tenant_id, run_id, kind, data): - return await asyncio.to_thread( - self._append_event_sync, tenant_id, run_id, kind, data - ) + return await asyncio.to_thread(self._append_event_sync, tenant_id, run_id, kind, data) def _append_event_sync(self, tenant_id, run_id, kind, data): event = RunEvent(tenant_id=tenant_id, run_id=run_id, sequence=0, kind=kind, data=data) @@ -1012,39 +2347,936 @@ async def list_events(self, tenant_id, run_id, *, after=0): ) return [RunEvent.model_validate(row) for row in rows] - async def create_schedule(self, tenant_id, request): - value = Schedule(tenant_id=tenant_id, **request.model_dump()) - await asyncio.to_thread(self._insert_schedule, value) - return value + async def submit_steering( + self, + tenant_id, + run_id, + *, + kind, + payload, + metadata=None, + idempotency_key=None, + max_payload_bytes: int = MAX_STEERING_PAYLOAD_BYTES, + ): + validate_steering_payload(payload, metadata, max_bytes=max_payload_bytes) + return await asyncio.to_thread( + self._submit_steering_sync, + tenant_id, + run_id, + kind, + payload, + metadata or {}, + idempotency_key, + ) - def _insert_schedule(self, value): + def _submit_steering_sync( + self, tenant_id, run_id, kind, payload, metadata, idempotency_key + ): # pragma: no cover -- Postgres-only, covered by the `postgres` CI job with self._connect() as conn, conn.cursor() as cursor: - self._tenant(cursor, value.tenant_id) + self._tenant(cursor, tenant_id) cursor.execute( - f"""INSERT INTO {self._schema}.schedules - (id,tenant_id,assistant_id,cron,timezone,input,enabled,metadata, - created_at,updated_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", - ( - value.id, - value.tenant_id, - value.assistant_id, - value.cron, - value.timezone, - self._jsonb(value.input), - value.enabled, - self._jsonb(value.metadata), - value.created_at, - value.updated_at, - ), + f"""SELECT status, steering_closed, superseded_by_run_id + FROM {self._schema}.runs + WHERE tenant_id=%s AND id=%s FOR UPDATE""", + (tenant_id, run_id), ) - - async def list_schedules(self, tenant_id): - rows = await asyncio.to_thread( - self._fetch_all, - tenant_id, - f"SELECT * FROM {self._schema}.schedules WHERE tenant_id=%s ORDER BY created_at DESC", - (tenant_id,), - ) + run_row = cursor.fetchone() + if run_row is None: + raise KeyError(f"run {run_id!r} not found") + # Issue #16 PR #17 review round 7, point 3 (BLOCKER): check the + # idempotency key for an existing row *before* the terminal/ + # finalizing/superseded admission gates below -- see the + # matching comment in ``InMemoryRepository.submit_steering``. + if idempotency_key is not None: + cursor.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", + (f"{tenant_id}:{run_id}:{idempotency_key}",), + ) + cursor.execute( + f"""SELECT * FROM {self._schema}.run_steering_events + WHERE tenant_id=%s AND run_id=%s AND idempotency_key=%s""", + (tenant_id, run_id, idempotency_key), + ) + existing = cursor.fetchone() + if existing is not None: + existing_event = RunSteeringEvent.model_validate(existing) + # A prior attempt with this idempotency key may have + # committed the steering row but then failed before its + # ``run.steer.accepted`` event was recorded (issue #16 + # PR #17 review round 4, point 2) -- repair that gap on + # the retry instead of permanently skipping it. + self._ensure_steer_accepted_event_sync( + cursor, tenant_id, run_id, existing_event + ) + return existing_event, False + # Only a genuinely new admission decision (no existing row for + # this idempotency key -- or none was given) is subject to the + # terminal/finalizing/superseded gates. + if run_row["status"] in TERMINAL: + raise RunTerminalError( + f"run {run_id!r} is in terminal state {run_row['status']!r} " + "and cannot accept new steering input" + ) + if run_row.get("steering_closed"): + raise RunFinalizingError( + f"run {run_id!r} has finished executing and is finalizing; " + "no further steering input can be safely consumed" + ) + # Round 14 review (BLOCKER): typed column, not user-writable + # ``metadata`` -- see ``_request_cancel_sync`` above. + superseded_by = run_row.get("superseded_by_run_id") + if superseded_by is not None: + raise RunSupersededError( + f"run {run_id!r} was resumed as {superseded_by!r}; " + f"steer the new run instead" + ) + cursor.execute( + f"""SELECT COALESCE(MAX(sequence),0)+1 AS next + FROM {self._schema}.run_steering_events WHERE tenant_id=%s AND run_id=%s""", + (tenant_id, run_id), + ) + sequence = int(cursor.fetchone()["next"]) + event = RunSteeringEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=sequence, + kind=kind, + payload=dict(payload), + metadata=dict(metadata or {}), + idempotency_key=idempotency_key, + status="pending", + ) + cursor.execute( + f"""INSERT INTO {self._schema}.run_steering_events + (id,tenant_id,run_id,sequence,kind,payload,metadata,idempotency_key, + status,created_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + event.id, + tenant_id, + run_id, + event.sequence, + kind, + self._jsonb(event.payload), + self._jsonb(event.metadata), + idempotency_key, + event.status, + event.created_at, + ), + ) + # Accept the steering row and record its ``run.steer.accepted`` + # lifecycle event in the same transaction (issue #16 PR #17 + # review round 4, point 2) -- both commit together, or neither + # does, so a retry can never observe the row without its + # accepted event. + self._ensure_steer_accepted_event_sync(cursor, tenant_id, run_id, event) + return event, True + + def _ensure_steer_accepted_event_sync( + self, + cursor, + tenant_id: str, + run_id: str, + steering_event: RunSteeringEvent, + *, + transferred_from_run_id: str | None = None, + ) -> None: # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + """Idempotently ensure ``run.steer.accepted`` exists for one event. + + Must be called with the target run's row already locked (``FOR + UPDATE`` / an implicit lock from an ``INSERT`` earlier in this same + transaction) so concurrent sequence-number assignment for + ``run_events`` serializes -- same discipline as + ``_append_event_sync`` / ``_commit_steering_consumptions_sync``. + """ + + cursor.execute( + f"""SELECT 1 FROM {self._schema}.run_events + WHERE tenant_id=%s AND run_id=%s AND kind='run.steer.accepted' + AND data->>'steering_event_id'=%s""", + (tenant_id, run_id, steering_event.id), + ) + if cursor.fetchone() is not None: + return + cursor.execute( + f"""SELECT COALESCE(MAX(sequence),0)+1 AS next + FROM {self._schema}.run_events WHERE tenant_id=%s AND run_id=%s""", + (tenant_id, run_id), + ) + sequence = int(cursor.fetchone()["next"]) + data: dict[str, Any] = { + "steering_event_id": steering_event.id, + "sequence": steering_event.sequence, + "kind": steering_event.kind, + } + if transferred_from_run_id is not None: + data["transferred_from_run_id"] = transferred_from_run_id + data["source_event_id"] = steering_event.source_event_id + run_event = RunEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=sequence, + kind="run.steer.accepted", + data=data, + ) + cursor.execute( + f"""INSERT INTO {self._schema}.run_events + (id,tenant_id,run_id,sequence,kind,data,created_at) + VALUES (%s,%s,%s,%s,%s,%s,%s)""", + ( + run_event.id, + tenant_id, + run_id, + sequence, + "run.steer.accepted", + self._jsonb(data), + run_event.created_at, + ), + ) + + def _ensure_steer_superseded_event_sync( + self, + cursor, + tenant_id: str, + run_id: str, + steering_event: RunSteeringEvent, + *, + reason: str, + superseded_by_run_id: str | None = None, + replacement_steering_event_id: str | None = None, + ) -> None: # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + """SQL counterpart of + :meth:`InMemoryRepository._ensure_steer_superseded_event_locked` + (issue #16 PR #17 review round 8, point 3, BLOCKER). Must be + called with the target run's row already locked (``FOR UPDATE`` / + an implicit lock from an earlier ``INSERT``/``UPDATE`` in this + same transaction), same discipline as + ``_ensure_steer_accepted_event_sync``. + """ + + cursor.execute( + f"""SELECT 1 FROM {self._schema}.run_events + WHERE tenant_id=%s AND run_id=%s AND kind='run.steer.superseded' + AND data->>'steering_event_id'=%s""", + (tenant_id, run_id, steering_event.id), + ) + if cursor.fetchone() is not None: + return + cursor.execute( + f"""SELECT COALESCE(MAX(sequence),0)+1 AS next + FROM {self._schema}.run_events WHERE tenant_id=%s AND run_id=%s""", + (tenant_id, run_id), + ) + sequence = int(cursor.fetchone()["next"]) + data: dict[str, Any] = { + "steering_event_id": steering_event.id, + "source_event_id": steering_event.source_event_id or steering_event.id, + # Issue #16 PR #17 review round 9, point 4 (REQUIRED): see the + # matching comment in + # ``InMemoryRepository._ensure_steer_superseded_event_locked``. + "sequence": steering_event.sequence, + "kind": steering_event.kind, + "reason": reason, + "superseded_by_run_id": superseded_by_run_id, + "replacement_steering_event_id": replacement_steering_event_id, + } + run_event = RunEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=sequence, + kind="run.steer.superseded", + data=data, + ) + cursor.execute( + f"""INSERT INTO {self._schema}.run_events + (id,tenant_id,run_id,sequence,kind,data,created_at) + VALUES (%s,%s,%s,%s,%s,%s,%s)""", + ( + run_event.id, + tenant_id, + run_id, + sequence, + "run.steer.superseded", + self._jsonb(data), + run_event.created_at, + ), + ) + + async def finalize_run_with_steering_disposition_if_owned( + self, + tenant_id, + run_id, + worker_id, + attempt, + status, + *, + output=None, + error=None, + supersede_pending: bool = True, + supersede_reason: str = "unconsumed_at_final_boundary", + ) -> tuple[Run, list[RunEvent]] | None: + result = await asyncio.to_thread( + self._finalize_run_with_steering_disposition_if_owned_sync, + tenant_id, + run_id, + worker_id, + attempt, + status, + output, + error, + supersede_pending, + supersede_reason, + ) + if result is None: + return None + row, stored_events = result + return self._run_from_row(row), stored_events + + def _finalize_run_with_steering_disposition_if_owned_sync( + self, + tenant_id: str, + run_id: str, + worker_id: str, + attempt: int, + status, + output, + error, + supersede_pending: bool, + supersede_reason: str, + ): # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + """SQL counterpart of + :meth:`InMemoryRepository.finalize_run_with_steering_disposition_if_owned` + (issue #16 PR #17 review round 9, point 2, BLOCKER). One real + PostgreSQL transaction covers: locking the run row, re-checking the + fence and control state (cancel-wins), locking and superseding any + still-``pending``/``delivered`` steering rows plus their + ``run.steer.superseded`` events, and writing the run's terminal + status -- all committed together by the surrounding ``with + self._connect()`` block, or all rolled back together if anything + raises before it exits normally. + """ + + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + cursor.execute( + f"""SELECT status FROM {self._schema}.runs + WHERE tenant_id=%s AND id=%s AND lease_owner=%s AND attempt=%s + AND status IN ('running','cancelling') FOR UPDATE""", + (tenant_id, run_id, worker_id, attempt), + ) + row = cursor.fetchone() + if row is None: + return None + + stored: list[RunEvent] = [] + if supersede_pending: + cursor.execute( + f"""SELECT * FROM {self._schema}.run_steering_events + WHERE tenant_id=%s AND run_id=%s AND status IN ('pending','delivered') + ORDER BY sequence FOR UPDATE""", + (tenant_id, run_id), + ) + stale_rows = cursor.fetchall() + if stale_rows: + stale_ids = [stale_row["id"] for stale_row in stale_rows] + cursor.execute( + f"""UPDATE {self._schema}.run_steering_events + SET status='superseded' WHERE tenant_id=%s AND id=ANY(%s)""", + (tenant_id, stale_ids), + ) + for stale_row in stale_rows: + steering_event = RunSteeringEvent.model_validate(stale_row) + self._ensure_steer_superseded_event_sync( + cursor, + tenant_id, + run_id, + steering_event, + reason=supersede_reason, + ) + cursor.execute( + f"""SELECT * FROM {self._schema}.run_events + WHERE tenant_id=%s AND run_id=%s AND kind='run.steer.superseded' + AND data->>'steering_event_id'=%s""", + (tenant_id, run_id, steering_event.id), + ) + stored_row = cursor.fetchone() + if stored_row is not None: + stored.append(RunEvent.model_validate(stored_row)) + + final_status = RunStatus(status).value + final_output = output + final_error = error + if row["status"] == "cancelling" and final_status != RunStatus.CANCELLED.value: + final_status = RunStatus.CANCELLED.value + final_output = None + final_error = { + "code": "run_cancelled", + "message": "run was cancelled during finalization", + } + # Issue #16 PR #17 review round 10, point 2 (REQUIRED): return + # this transaction's own ``RETURNING *`` snapshot alongside the + # superseded events, instead of a separate later ``get_run()`` + # a concurrent write (e.g. ``/redrive`` or the unfenced + # ``retry_run()``) could race ahead of. + cursor.execute( + f"""UPDATE {self._schema}.runs SET status=%s, output=%s, error=%s, + finished_at=NOW(), lease_owner=NULL, lease_expires_at=NULL + WHERE tenant_id=%s AND id=%s RETURNING *""", + ( + final_status, + self._jsonb(final_output) if final_output is not None else None, + self._jsonb(final_error) if final_error is not None else None, + tenant_id, + run_id, + ), + ) + return cursor.fetchone(), stored + + async def supersede_pending_steering_if_owned( + self, tenant_id, run_id, worker_id, attempt + ) -> list[RunEvent] | None: + return await asyncio.to_thread( + self._supersede_pending_steering_if_owned_sync, + tenant_id, + run_id, + worker_id, + attempt, + ) + + def _supersede_pending_steering_if_owned_sync( + self, tenant_id: str, run_id: str, worker_id: str, attempt: int + ) -> ( + list[RunEvent] | None + ): # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + """SQL counterpart of + :meth:`InMemoryRepository.supersede_pending_steering_if_owned` -- + see its docstring (issue #16 PR #17 review round 8, point 1, + BLOCKER) for the "seal + supersede" design this implements. + """ + + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + cursor.execute( + f"""SELECT status FROM {self._schema}.runs + WHERE tenant_id=%s AND id=%s AND lease_owner=%s AND attempt=%s + AND status IN ('running','cancelling') FOR UPDATE""", + (tenant_id, run_id, worker_id, attempt), + ) + if cursor.fetchone() is None: + return None + cursor.execute( + f"""SELECT * FROM {self._schema}.run_steering_events + WHERE tenant_id=%s AND run_id=%s AND status IN ('pending','delivered') + ORDER BY sequence FOR UPDATE""", + (tenant_id, run_id), + ) + stale_rows = cursor.fetchall() + if not stale_rows: + return [] + stale_ids = [row["id"] for row in stale_rows] + cursor.execute( + f"""UPDATE {self._schema}.run_steering_events + SET status='superseded' WHERE tenant_id=%s AND id=ANY(%s)""", + (tenant_id, stale_ids), + ) + stored: list[RunEvent] = [] + for row in stale_rows: + steering_event = RunSteeringEvent.model_validate(row) + self._ensure_steer_superseded_event_sync( + cursor, + tenant_id, + run_id, + steering_event, + reason="unconsumed_at_final_boundary", + ) + cursor.execute( + f"""SELECT * FROM {self._schema}.run_events + WHERE tenant_id=%s AND run_id=%s AND kind='run.steer.superseded' + AND data->>'steering_event_id'=%s""", + (tenant_id, run_id, steering_event.id), + ) + stored_row = cursor.fetchone() + if stored_row is not None: + stored.append(RunEvent.model_validate(stored_row)) + return stored + + async def list_pending_steering(self, tenant_id, run_id): + rows = await asyncio.to_thread( + self._fetch_all, + tenant_id, + f"""SELECT * FROM {self._schema}.run_steering_events + WHERE tenant_id=%s AND run_id=%s AND status IN ('pending','delivered') + ORDER BY sequence""", + (tenant_id, run_id), + ) + return [RunSteeringEvent.model_validate(row) for row in rows] + + async def list_steering(self, tenant_id, run_id): + rows = await asyncio.to_thread( + self._fetch_all, + tenant_id, + f"""SELECT * FROM {self._schema}.run_steering_events + WHERE tenant_id=%s AND run_id=%s ORDER BY sequence""", + (tenant_id, run_id), + ) + return [RunSteeringEvent.model_validate(row) for row in rows] + + async def mark_steering_consumed(self, tenant_id, run_id, event_ids): + if not event_ids: + return + await asyncio.to_thread( + self._mark_steering_consumed_sync, tenant_id, run_id, list(event_ids) + ) + + def _mark_steering_consumed_sync( + self, tenant_id, run_id, event_ids + ): # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + cursor.execute( + f"""UPDATE {self._schema}.run_steering_events + SET status='consumed', consumed_at=NOW() + WHERE tenant_id=%s AND run_id=%s AND id=ANY(%s) AND status!='consumed'""", + (tenant_id, run_id, event_ids), + ) + + async def commit_steering_consumptions(self, tenant_id, run_id, consumptions): + if not consumptions: + return [] + return await asyncio.to_thread( + self._commit_steering_consumptions_sync, tenant_id, run_id, list(consumptions) + ) + + def _commit_steering_consumptions_sync( + self, tenant_id: str, run_id: str, consumptions: list[SteeringConsumption] + ) -> list[RunEvent]: # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + """SQL counterpart of + :meth:`InMemoryRepository.commit_steering_consumptions` -- see its + docstring (issue #16 PR #17 review point 2) for why the status + update and the ``run.steer.consumed`` event append must commit + together, in the same transaction, before the caller acks its + local consumption log. + """ + + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + # Same locking discipline as ``_append_event_sync``: lock the + # run row first so concurrent event appenders for this run + # serialize their sequence assignment instead of racing. + cursor.execute( + f"SELECT id FROM {self._schema}.runs WHERE tenant_id=%s AND id=%s FOR UPDATE", + (tenant_id, run_id), + ) + return self._commit_steering_consumptions_body_sync( + cursor, tenant_id, run_id, consumptions + ) + + async def commit_steering_consumptions_if_owned( + self, tenant_id, run_id, worker_id, attempt, consumptions + ) -> list[RunEvent] | None: + if not consumptions: + return [] + return await asyncio.to_thread( + self._commit_steering_consumptions_if_owned_sync, + tenant_id, + run_id, + worker_id, + attempt, + list(consumptions), + ) + + def _commit_steering_consumptions_if_owned_sync( + self, + tenant_id: str, + run_id: str, + worker_id: str, + attempt: int, + consumptions: list[SteeringConsumption], + ) -> ( + list[RunEvent] | None + ): # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + """Fenced counterpart of ``_commit_steering_consumptions_sync``. + + Issue #16 PR #17 review round 7, point 2 (BLOCKER): see the + matching docstring on + :meth:`InMemoryRepository.commit_steering_consumptions_if_owned`. + The run row is locked ``FOR UPDATE`` first, filtered on + ``lease_owner``/``attempt``/active status -- exactly the same + pattern as ``_finish_run_if_owned_sync``/``_close_steering_sync`` -- + *before* any steering row is touched, so a concurrent + ``claim_run()`` (which updates ``lease_owner``/``attempt`` under + its own ``FOR UPDATE`` on the same row) fully serializes against + this fencing check: a worker that has already lost its lease can + never observe a stale "still owned" result here. + """ + + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + cursor.execute( + f"""SELECT status FROM {self._schema}.runs + WHERE tenant_id=%s AND id=%s AND lease_owner=%s AND attempt=%s + AND status IN ('running','cancelling') FOR UPDATE""", + (tenant_id, run_id, worker_id, attempt), + ) + if cursor.fetchone() is None: + return None + return self._commit_steering_consumptions_body_sync( + cursor, tenant_id, run_id, consumptions + ) + + def _commit_steering_consumptions_body_sync( + self, + cursor, + tenant_id: str, + run_id: str, + consumptions: list[SteeringConsumption], + ) -> list[RunEvent]: # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + """Shared body; assumes the run row is already locked ``FOR UPDATE`` + on ``cursor``'s transaction (by the caller, with or without lease + fencing applied first).""" + + ids = [consumption.event.id for consumption in consumptions] + cursor.execute( + f"""UPDATE {self._schema}.run_steering_events + SET status='consumed', consumed_at=NOW() + WHERE tenant_id=%s AND run_id=%s AND id=ANY(%s) AND status!='consumed' + RETURNING id""", + (tenant_id, run_id, ids), + ) + # Only ids that *actually* transitioned pending/delivered -> + # consumed in this call. A retried batch (e.g. a worker that + # re-sends consumptions after a commit whose ack it never saw) + # must not emit a second, semantically duplicate + # ``run.steer.consumed`` lifecycle event for a row some + # earlier call already consumed (issue #16 PR #17 review + # point 3). + transitioned = {row["id"] for row in cursor.fetchall()} + cursor.execute( + f"""SELECT COALESCE(MAX(sequence),0) AS next + FROM {self._schema}.run_events WHERE tenant_id=%s AND run_id=%s""", + (tenant_id, run_id), + ) + next_sequence = int(cursor.fetchone()["next"]) + stored: list[RunEvent] = [] + for consumption in consumptions: + event = consumption.event + if event.id not in transitioned: + continue + next_sequence += 1 + data: dict[str, Any] = { + "steering_event_id": event.id, + "sequence": event.sequence, + "kind": event.kind, + "queue_latency_seconds": consumption.queue_latency_seconds, + "node": consumption.node, + "namespace": list(consumption.namespace), + "task_id": consumption.task_id, + } + if event.source_event_id is not None: + data["source_event_id"] = event.source_event_id + run_event = RunEvent( + tenant_id=tenant_id, + run_id=run_id, + sequence=next_sequence, + kind="run.steer.consumed", + data=data, + ) + cursor.execute( + f"""INSERT INTO {self._schema}.run_events + (id,tenant_id,run_id,sequence,kind,data,created_at) + VALUES (%s,%s,%s,%s,%s,%s,%s)""", + ( + run_event.id, + tenant_id, + run_id, + run_event.sequence, + run_event.kind, + self._jsonb(run_event.data), + run_event.created_at, + ), + ) + stored.append(run_event) + return stored + + async def resume_run_with_pending_steering( + self, tenant_id, thread_id, assistant, request, old_run_id + ): + return await asyncio.to_thread( + self._resume_run_with_pending_steering_sync, + tenant_id, + thread_id, + assistant, + request, + old_run_id, + ) + + def _resume_run_with_pending_steering_sync( + self, tenant_id, thread_id, assistant, request, old_run_id + ): # pragma: no cover -- Postgres-only, covered by the `postgres` CI job + """SQL counterpart of + :meth:`InMemoryRepository.resume_run_with_pending_steering`. + + Issue #16 PR #17 review point 1: the resumed Run's INSERT and the + old Run's steering migration must commit as one all-or-nothing unit + so no other transaction can ever see the new Run row without its + migrated steering already alongside it. Using a single connection + for both means they share one PostgreSQL transaction (autocommit is + off; the ``with self._connect()`` block only commits when it exits + normally) -- ``claim_run`` runs its own ``UPDATE ... FOR UPDATE + SKIP LOCKED`` against ``runs`` in a *different* connection/ + transaction, so it cannot observe the freshly INSERTed new-Run row + at all until this transaction commits, by which point the + migration below has already happened. + + This also closes the second race the review flagged: an ordinary + concurrent ``/steer`` against the new run (``_submit_steering_sync``) + takes ``SELECT ... FOR UPDATE`` on the **runs** row for + ``new_run.id`` before computing ``MAX(sequence)+1`` over + ``run_steering_events``. Because the new run's row was INSERTed -- + and is therefore implicitly locked -- inside *this* transaction, + that concurrent ``FOR UPDATE`` blocks until this transaction + commits, so the two ``MAX(sequence)+1`` computations for the same + ``new_run_id`` can never race. + """ + + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, tenant_id) + # Lock the paused run first so two concurrent resume attempts + # against the same paused run_id serialize instead of both + # migrating (and superseding) the same steering rows. + cursor.execute( + f"""SELECT status, superseded_by_run_id FROM {self._schema}.runs + WHERE tenant_id=%s AND id=%s FOR UPDATE""", + (tenant_id, old_run_id), + ) + old_run_row = cursor.fetchone() + # Issue #16 PR #17 review round 4, point 1: the endpoint's own + # pre-check (``GET old run -> status == paused``) races with + # concurrent resumes and is not itself atomic with this call. + # Re-validate under the lock just acquired above -- still + # ``paused``, and not already superseded by a resume that won + # the race -- before creating a second descendant Run. + # Round 14 review (BLOCKER): typed column, not user-writable + # ``metadata`` -- see ``_request_cancel_sync`` above. + if old_run_row is None: + raise RunResumeConflictError(f"run {old_run_id!r} no longer exists") + if ( + old_run_row.get("status") != "paused" + or old_run_row.get("superseded_by_run_id") is not None + ): + raise RunResumeConflictError( + f"run {old_run_id!r} is no longer resumable " + "(it is not paused, or has already been resumed by a concurrent request)" + ) + + cursor.execute( + f"""SELECT + COUNT(*) FILTER (WHERE status IN ('running','cancelling')) AS active, + COUNT(*) FILTER (WHERE status='pending') AS queued + FROM {self._schema}.runs WHERE tenant_id=%s""", + (tenant_id,), + ) + counts = cursor.fetchone() + if counts["active"] >= self.limits.max_active_runs: + raise ConcurrentRunError("tenant active-run quota exceeded") + if counts["queued"] >= self.limits.max_queued_runs: + raise ConcurrentRunError("tenant queued-run quota exceeded") + if thread_id is not None: + cursor.execute( + f"""SELECT id FROM {self._schema}.runs WHERE tenant_id=%s + AND thread_id=%s AND status IN ('running','cancelling') FOR UPDATE""", + (tenant_id, thread_id), + ) + active = cursor.fetchall() + strategy = MultitaskStrategy(request.multitask_strategy) + if active and strategy is MultitaskStrategy.REJECT: + raise ConcurrentRunError("thread already has an active run") + if active and strategy is MultitaskStrategy.CANCEL_PREVIOUS: + cursor.execute( + f"""UPDATE {self._schema}.runs SET status='cancelling' + WHERE tenant_id=%s AND thread_id=%s AND status='running'""", + (tenant_id, thread_id), + ) + + run = Run( + tenant_id=tenant_id, + thread_id=thread_id, + assistant_id=assistant.id, + graph_id=assistant.graph_id, + graph_version=assistant.graph_version, + input=request.input, + context={**assistant.context, **request.context}, + config={**assistant.config, **request.config}, + metadata=request.metadata, + resume=request.resume, + update=request.update, + goto=request.goto, + durability=request.durability, + ) + if request.run_timeout is not None: + run.config["run_timeout"] = request.run_timeout + run.config.setdefault("max_state_bytes", self.limits.max_state_bytes) + for budget_name in ("max_model_calls", "max_tool_calls", "max_tokens", "max_cost"): + budget_value = getattr(request, budget_name) + if budget_value is not None: + run.config[budget_name] = budget_value + cursor.execute( + f"""INSERT INTO {self._schema}.runs + (id,tenant_id,thread_id,assistant_id,graph_id,graph_version,status, + idempotency_key,request_digest,input,context,config,metadata,resume,update,goto_node,durability, + attempt,created_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + run.id, + tenant_id, + thread_id, + run.assistant_id, + run.graph_id, + run.graph_version, + enum_value(run.status), + run.idempotency_key, + run.request_digest, + self._jsonb(run.input) if run.input is not None else None, + self._jsonb(run.context), + self._jsonb(run.config), + self._jsonb(run.metadata), + self._jsonb(run.resume) if run.resume is not None else None, + self._jsonb(run.update) if run.update is not None else None, + run.goto, + enum_value(run.durability), + run.attempt, + run.created_at, + ), + ) + + # Round 14 review (BLOCKER): write the typed column + # atomically as part of this same resume transaction, not + # user-writable ``metadata``. + cursor.execute( + f"""UPDATE {self._schema}.runs SET superseded_by_run_id=%s + WHERE tenant_id=%s AND id=%s""", + (run.id, tenant_id, old_run_id), + ) + + cursor.execute( + f"""SELECT * FROM {self._schema}.run_steering_events + WHERE tenant_id=%s AND run_id=%s AND status IN ('pending','delivered') + ORDER BY sequence FOR UPDATE""", + (tenant_id, old_run_id), + ) + pending_rows = cursor.fetchall() + transferred: list[RunSteeringEvent] = [] + if pending_rows: + pending_ids = [row["id"] for row in pending_rows] + cursor.execute( + f"""UPDATE {self._schema}.run_steering_events + SET status='superseded' WHERE tenant_id=%s AND id=ANY(%s)""", + (tenant_id, pending_ids), + ) + # Safe against a concurrent ordinary /steer on the new run: + # both this and ``_submit_steering_sync`` first take + # ``FOR UPDATE`` on the *new run's* ``runs`` row -- which + # this transaction already holds implicitly from the + # INSERT above -- before computing MAX(sequence)+1 here. + cursor.execute( + f"""SELECT COALESCE(MAX(sequence),0) AS next + FROM {self._schema}.run_steering_events WHERE tenant_id=%s AND run_id=%s""", + (tenant_id, run.id), + ) + next_sequence = int(cursor.fetchone()["next"]) + for row in pending_rows: + next_sequence += 1 + event = RunSteeringEvent( + tenant_id=tenant_id, + run_id=run.id, + sequence=next_sequence, + kind=row["kind"], + payload=dict(row["payload"] or {}), + metadata=dict(row["metadata"] or {}), + idempotency_key=row["idempotency_key"], + status="pending", + source_event_id=row["source_event_id"] or row["id"], + created_at=row["created_at"], + ) + cursor.execute( + f"""INSERT INTO {self._schema}.run_steering_events + (id,tenant_id,run_id,sequence,kind,payload,metadata,idempotency_key, + status,created_at,source_event_id) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + event.id, + tenant_id, + run.id, + event.sequence, + event.kind, + self._jsonb(event.payload), + self._jsonb(event.metadata), + event.idempotency_key, + event.status, + event.created_at, + event.source_event_id, + ), + ) + # Record the transferred event's ``run.steer.accepted`` + # inside this same migration transaction (review round + # 4, point 2) instead of a separate ``append_event`` + # call after this method returns. + self._ensure_steer_accepted_event_sync( + cursor, + tenant_id, + run.id, + event, + transferred_from_run_id=old_run_id, + ) + # Issue #16 PR #17 review round 8, point 3 (BLOCKER): + # the old row's transition to ``superseded`` above must + # be paired with a durable ``run.steer.superseded`` + # lifecycle event in this same migration transaction. + self._ensure_steer_superseded_event_sync( + cursor, + tenant_id, + old_run_id, + RunSteeringEvent.model_validate(row), + reason="resume_transfer", + superseded_by_run_id=run.id, + replacement_steering_event_id=event.id, + ) + transferred.append(event) + return run, transferred + + async def create_schedule(self, tenant_id, request): + value = Schedule(tenant_id=tenant_id, **request.model_dump()) + await asyncio.to_thread(self._insert_schedule, value) + return value + + def _insert_schedule(self, value): + with self._connect() as conn, conn.cursor() as cursor: + self._tenant(cursor, value.tenant_id) + cursor.execute( + f"""INSERT INTO {self._schema}.schedules + (id,tenant_id,assistant_id,cron,timezone,input,enabled,metadata, + created_at,updated_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + ( + value.id, + value.tenant_id, + value.assistant_id, + value.cron, + value.timezone, + self._jsonb(value.input), + value.enabled, + self._jsonb(value.metadata), + value.created_at, + value.updated_at, + ), + ) + + async def list_schedules(self, tenant_id): + rows = await asyncio.to_thread( + self._fetch_all, + tenant_id, + f"SELECT * FROM {self._schema}.schedules WHERE tenant_id=%s ORDER BY created_at DESC", + (tenant_id,), + ) return [Schedule.model_validate(row) for row in rows] async def patch_schedule(self, tenant_id, schedule_id, request): @@ -1152,8 +3384,7 @@ def _stats_sync(self, tenant_id: str) -> dict[str, Any]: counts: dict[str, int] = {} for name in ("run_events", "threads", "assistants", "schedules"): cursor.execute( - f"SELECT COUNT(*) AS count FROM {self._schema}.{name} " - "WHERE tenant_id=%s", + f"SELECT COUNT(*) AS count FROM {self._schema}.{name} WHERE tenant_id=%s", (tenant_id,), ) counts[name] = int(cursor.fetchone()["count"]) @@ -1172,9 +3403,39 @@ def _claim_run_sync(self, worker_id: str, lease_seconds: int) -> Run | None: with self._connect() as conn, conn.cursor() as cursor: cursor.execute( f"""UPDATE {self._schema}.runs SET status='pending', - lease_owner=NULL, lease_expires_at=NULL + lease_owner=NULL, lease_expires_at=NULL, steering_closed=FALSE WHERE status='running' AND lease_expires_at < NOW()""" ) + # Lease-expiry recovery rule (issue #16 PR #17 review round 11, + # BLOCKER): an expired ``cancelling`` lease must NOT be + # requeued to ``pending`` like an expired ``running`` lease + # above -- that would silently erase the fact that the caller + # already asked to stop and could let business execution + # resume after cancellation was requested. It instead resolves + # straight to the terminal ``cancelled`` state, mirroring the + # same ``run_cancelled`` disposition + # ``_finish_run_if_owned_sync``/``_retry_run_if_owned_sync`` + # already use when cancellation wins during in-process + # finalization. This guarantees the run can never be stuck in + # ``cancelling`` forever and keeps its thread from being + # wedged for other queued runs on the same thread. + cursor.execute( + f"""UPDATE {self._schema}.runs SET status='cancelled', + lease_owner=NULL, lease_expires_at=NULL, finished_at=NOW(), + error=%s + WHERE status='cancelling' AND lease_expires_at < NOW()""", + ( + self._jsonb( + { + "code": "run_cancelled", + "message": ( + "run was cancelled but its worker's lease " + "expired before finalization completed" + ), + } + ), + ), + ) cursor.execute( f""" WITH candidate AS ( @@ -1207,11 +3468,14 @@ def _run_from_row(row: dict[str, Any]) -> Run: value = dict(row) value["goto"] = value.pop("goto_node", None) for name in ("input", "context", "config", "metadata", "error", "output"): - value[name] = value.get(name) or ({} if name in {"context", "config", "metadata"} else None) + value[name] = value.get(name) or ( + {} if name in {"context", "config", "metadata"} else None + ) return Run.model_validate(value) __all__ = [ + "ACTIVE", "InMemoryRepository", "PostgresRepository", "RepositoryLimits", diff --git a/src/lingxigraph/server/worker.py b/src/lingxigraph/server/worker.py index a92f989..262a574 100644 --- a/src/lingxigraph/server/worker.py +++ b/src/lingxigraph/server/worker.py @@ -24,10 +24,11 @@ from ..events import Event, EventKind from ..runtime import CancellationToken from ..serialization import JsonSerializer +from ..steering import SteeringEvent from ..store import BaseStore, InMemoryStore from ..types import Command, RunStatus from .eventbus import EventBus, InMemoryEventBus -from .models import Run +from .models import Run, RunEvent from .registry import GraphRegistry from .repository import InMemoryRepository @@ -130,14 +131,49 @@ async def drain(self, *, timeout: float = 60.0) -> bool: async def _execute(self, run: Run) -> None: token = CancellationToken() - heartbeat = asyncio.create_task(self._heartbeat(run, token)) + # ``with_runtime`` returns a distinct bound graph instance -- build + # it first so the heartbeat loop shares the exact same steering + # channel the executor actually reads from, not a different + # instance's. + graph = self.registry.get(run.graph_id, run.graph_version).with_runtime( + checkpointer=self.checkpointer, + store=self.store_factory(run.tenant_id), + cache=self.cache, + ) + # Register the steering channel *before* the graph ever executes a + # single task, even when there is nothing pending yet. This marks + # it server-owned (``owned_by_executor=False``, see + # SteeringChannel/CompiledStateGraph._run) so the executor's own + # embedded-run cleanup never releases it out from under us -- + # otherwise a run claimed with no steering pending yet would get an + # executor-owned channel by default, and steering that arrived + # later plus the worker's final flush could race the executor + # popping the channel the moment the run finishes. + graph.get_steering_channel(run.id) + # Recover any steering events that were durably accepted while this + # run was queued (or before a prior worker crashed) -- the channel + # is the same live object the executor reads from at every safe + # point, so this is the "recover after restart" and "queued run" + # cases from issue #16 in one code path. + await self._sync_steering_in(run, graph) + heartbeat = asyncio.create_task(self._heartbeat(run, token, graph)) output: dict[str, Any] | None = None + + # Issue #16 PR #17 review round 5, point 1: the *intended* outcome + # is computed here, purely in memory -- it is deliberately NOT + # written to the repository yet. Writing ``finish_run``/ + # ``retry_run`` here (as round <=4 did) makes a terminal or paused + # status externally observable via GET/join/SSE *before* we know + # whether the run's final steering consumptions actually made it + # to durable storage below. If that flush then failed, overriding + # the already-visible status back to pending/retrying breaks the + # single most important Run state-machine invariant: once a + # terminal/paused status is externally observed, the run must + # never appear non-terminal again. So instead we settle on + # ``_Intent`` here, stop the heartbeat, durably flush steering, and + # only then -- once -- commit the outcome to the repository. + intent: Worker._Intent try: - graph = self.registry.get(run.graph_id, run.graph_version).with_runtime( - checkpointer=self.checkpointer, - store=self.store_factory(run.tenant_id), - cache=self.cache, - ) config = { **run.config, "configurable": { @@ -174,36 +210,27 @@ async def _execute(self, run: Run) -> None: **dict(snapshot.values), "__interrupt__": [dataclasses.asdict(item) for item in snapshot.interrupts], } - await self.repository.finish_run( - run.tenant_id, run.id, RunStatus.PAUSED, output=output - ) + intent = Worker._Intent(kind="finish", status=RunStatus.PAUSED, output=output) else: - await self.repository.finish_run( - run.tenant_id, run.id, RunStatus.SUCCEEDED, output=output or {} - ) - logger.info( - "run succeeded", - extra={"run_id": run.id, "tenant_id": run.tenant_id, "status": "succeeded"}, + intent = Worker._Intent( + kind="finish", status=RunStatus.SUCCEEDED, output=output or {} ) except GraphCancelledError as exc: - await self.repository.finish_run( - run.tenant_id, - run.id, - RunStatus.CANCELLED, + intent = Worker._Intent( + kind="finish", + status=RunStatus.CANCELLED, error={"code": "run_cancelled", "message": str(exc)}, ) except GraphTimeoutError as exc: - await self.repository.finish_run( - run.tenant_id, - run.id, - RunStatus.TIMED_OUT, + intent = Worker._Intent( + kind="finish", + status=RunStatus.TIMED_OUT, error={"code": "run_timed_out", "message": str(exc)}, ) except BudgetExceededError as exc: - await self.repository.finish_run( - run.tenant_id, - run.id, - RunStatus.FAILED, + intent = Worker._Intent( + kind="finish", + status=RunStatus.FAILED, error={"code": "budget_exceeded", "message": str(exc)}, ) except Exception as exc: @@ -211,54 +238,522 @@ async def _execute(self, run: Run) -> None: retryable = self._is_retryable(exc) if retryable and run.attempt < self.max_delivery_attempts: error["code"] = "delivery_retry" - await self.repository.retry_run(run.tenant_id, run.id, error=error) - stored = await self.repository.append_event( - run.tenant_id, - run.id, - "worker_retrying", - { - "attempt": run.attempt, - "max_attempts": self.max_delivery_attempts, - "error": error, - }, - ) - await self.event_bus.publish(run.tenant_id, run.id, stored.sequence) - logger.warning( - "run delivery scheduled for retry", - extra={ - "run_id": run.id, - "tenant_id": run.tenant_id, - "status": "pending", - "error_type": type(exc).__name__, - }, - ) + intent = Worker._Intent(kind="retry", status=RunStatus.PENDING, error=error) else: status = RunStatus.DEAD_LETTER if retryable else RunStatus.FAILED error["code"] = "dead_letter" if retryable else "run_failed" - await self.repository.finish_run( - run.tenant_id, - run.id, - status, - error=error, - ) - logger.error( - "run delivery failed", - extra={ - "run_id": run.id, - "tenant_id": run.tenant_id, - "status": status.value, - "error_type": type(exc).__name__, - }, - ) + intent = Worker._Intent(kind="finish", status=status, error=error) finally: heartbeat.cancel() await asyncio.gather(heartbeat, return_exceptions=True) - async def _heartbeat(self, run: Run, token: CancellationToken) -> None: + # Issue #16 PR #17 review round 6, point 3 (BLOCKER): close new + # steering admission the instant graph execution ends and *before* + # the final flush/commit start -- there is no safe point left for + # the graph to ever consume a steering event accepted after this. + # This is fenced on our own (lease_owner, attempt): if we no + # longer hold the lease (e.g. it already expired and a new worker + # claimed the run), this fails and we must abandon the attempt + # without touching the run's status at all -- the new owner is now + # solely responsible for it (review round 6, point 1, BLOCKER). + # + # A ``paused`` outcome is deliberately excluded: pausing does not + # mean "no safe point left" the way a terminal outcome does -- + # ``/steer`` against a paused run is an intentional, documented + # part of issue #16's design (the pending steering is transferred + # to the resumed run at ``/resume`` time), so it must keep + # accepting new steering input, not be permanently gated shut by + # a worker that is merely yielding control, not finishing. + # + # Issue #16 PR #17 review round 9, point 1 (BLOCKER): a ``retry`` + # intent is likewise NOT terminal finalization -- the next delivery + # attempt is a legitimate future safe point, not the end of the + # line. Closing/superseding steering on retry meant any steering + # durably accepted but not yet drained by this attempt was marked + # ``superseded`` here and then, moments later, the Run was sent + # back to ``pending`` by ``retry_run_with_event_if_owned`` -- so the next + # attempt's ``_sync_steering_in`` (which only re-ingests + # ``pending``/``delivered`` rows) could never see it again, + # permanently losing steering on the very next delivery attempt. + # Only a genuinely terminal ``finish`` (i.e. not ``paused``) closes + # admission and supersedes leftovers; ``retry`` leaves steering + # fully open and recoverable, exactly like an ordinary mid-run + # failure that predates this finalization step entirely. + closing_steering = intent.kind == "finish" and intent.status is not RunStatus.PAUSED + if closing_steering: + gate_closed = await self.repository.close_steering( + run.tenant_id, run.id, self.worker_id, run.attempt + ) + if not gate_closed: + logger.warning( + "run finalization abandoned: lease no longer owned before steering close", + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + ) + return + + # Issue #16 PR #17 review round 6, point 1 (BLOCKER): the lease + # lifecycle must stay alive through the *entire* finalization + # window, independent of the now-cancelled steering/event + # heartbeat -- the final flush can legitimately retry for a long + # time, and without this the lease can simply expire mid-flush, + # letting a new worker claim and even finish the run while we are + # still blocked, unaware our writes are no longer safe. + renewal = asyncio.create_task(self._lease_renewal_loop(run)) + try: + # Final flush: anything drained between the last heartbeat tick + # and run completion must still be recorded as consumed, + # durably, before the run's outcome above is committed. See + # ``_final_steering_flush`` for why this retries for as long as + # it holds the lease rather than giving up after a handful of + # attempts (issue #16 PR #17 review round 5, point 2). + flushed = await self._final_steering_flush(run, graph) + if flushed: + # Issue #16 PR #17 review round 8, point 1 (BLOCKER): + # CHOSEN FIX -- "seal admission at the safe boundary + + # supersede late events". Round 7's fix forced an + # indefinite retry/dead-letter loop onto any run that ever + # had undrained steering, even when the graph legitimately + # never calls ``drain_steering()`` at all -- hardcoding an + # application decision ("does undrained steering mean + # rerun?") into the Runtime in violation of issue #16's + # Runtime/application boundary. The flush above commits + # everything this attempt's channel actually drained, but + # it cannot know about a steering row that was durably + # accepted (by a concurrent ``/steer`` racing + # ``close_steering``'s lock, or simply too late for any + # safe point this attempt reached) and therefore was never + # ingested into the channel at all. Any such row is now + # durably transitioned to a ``superseded`` disposition -- + # never silently dropped, never forcing a rerun -- and the + # run finalizes with its originally computed intent. Only + # relevant when we actually closed admission -- a + # ``paused`` outcome intentionally leaves steering open for + # the eventual ``/resume`` transfer instead, and a + # ``retry`` intent never closed admission in the first + # place (review round 9, point 1, BLOCKER) so there is + # nothing to supersede. + if closing_steering: + # Issue #16 PR #17 review round 9, point 2 (BLOCKER): + # the leftover-steering disposition and the Run's + # terminal status commit must be the *same* durable + # write, not two separate fenced calls -- a crash or + # lease loss between them used to be able to leave + # steering permanently ``superseded`` while the Run + # itself never actually reached a terminal status (see + # ``finalize_run_with_steering_disposition_if_owned``'s + # docstring for the full failure mode). ``intent.kind`` + # is always ``"finish"`` here -- ``closing_steering`` + # is only true for a genuinely terminal finish. + result = await self.repository.finalize_run_with_steering_disposition_if_owned( + run.tenant_id, + run.id, + self.worker_id, + run.attempt, + intent.status, + output=intent.output, + error=intent.error, + ) + if result is None: + # Lease was reclaimed by a new owner between the + # final flush and this call -- same as any other + # fenced write losing the race: abandon without + # touching the run further, the new owner is now + # solely responsible for it. + logger.warning( + "run finalization abandoned: lease no longer owned before " + "finalizing with steering disposition", + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + ) + return + updated, superseded = result + for stored in superseded: + await self.event_bus.publish(run.tenant_id, run.id, stored.sequence) + self._log_finalize_outcome(run, updated, intent) + graph.forget_steering(run.id) + return + committed = await self._commit_intent(run, intent) + if committed: + graph.forget_steering(run.id) + return + + # The flush never durably succeeded (the worker is draining, or + # this run's lease was lost to another worker) -- the intended + # outcome computed above must NEVER be written; doing so would + # either expose a false terminal/paused status or silently + # strand the drained-but-uncommitted steering consumptions. + # Route the run into the same retry/dead-letter path an + # ordinary delivery failure uses, so a later delivery attempt + # resyncs and retries the commit from the still-``pending`` DB + # row. Every write below is fenced on (lease_owner, attempt) -- + # see ``finish_run_if_owned``/``retry_run_with_event_if_owned`` -- so a + # worker that has lost its lease mid-flush can never revert a + # new owner's run back to pending or overwrite its status + # (review round 6, point 1, BLOCKER). + flush_error = { + "code": "steering_flush_failed", + "message": ( + "durable steering consumption commit could not complete before this " + "worker gave up its lease; run finalization was withheld to avoid " + "reporting a false status or losing the consumption" + ), + } + await self._retry_or_dead_letter(run, flush_error) + # Do NOT forget_steering here. Not because this channel's local + # consumption log will be reused later -- ``with_runtime()`` + # builds a brand new graph/channel on every delivery attempt + # (issue #16 PR #17 review round 5, point 2), so this exact + # object is discarded regardless. Recovery is driven entirely + # by the still-``pending`` DB row a future attempt re-reads via + # ``list_pending_steering``, not by anything held in this + # channel across the attempt boundary -- forgetting here is + # simply unnecessary, not unsafe. + finally: + renewal.cancel() + await asyncio.gather(renewal, return_exceptions=True) + + @dataclasses.dataclass + class _Intent: + """The finalization outcome ``_execute`` intends to commit. + + Held purely in memory until the final steering flush durably + succeeds -- see the long comment in ``_execute`` for why. + """ + + kind: str # "finish" or "retry" + status: RunStatus + output: dict[str, Any] | None = None + error: dict[str, Any] | None = None + + async def _commit_intent(self, run: Run, intent: "Worker._Intent") -> bool: + """Write the previously-computed intended outcome, exactly once. + + Only ever called after the final steering flush has durably + succeeded, so this is the single point where a terminal/paused + status first becomes externally observable. + + Issue #16 PR #17 review round 6, points 1 and 2 (BLOCKER): this + write is fenced on our own ``(lease_owner, attempt)`` via + ``finish_run_if_owned``/``retry_run_with_event_if_owned`` -- if we no longer + hold the lease (a new worker claimed the run while our flush was + still in flight), the write is refused entirely and this returns + ``False`` so the caller does not call ``forget_steering()`` on a + channel whose consumption record may still matter. The fenced + write also re-checks the run's current control state atomically: + if it is ``cancelling``, cancellation wins over a stale + ``succeeded``/``failed`` intent regardless of arrival order. + """ + + if intent.kind == "retry": + result = await self.repository.retry_run_with_event_if_owned( + run.tenant_id, + run.id, + self.worker_id, + run.attempt, + error=intent.error, + max_attempts=self.max_delivery_attempts, + ) + if result is None: + logger.warning( + "run retry commit abandoned: lease no longer owned", + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + ) + return False + retried_run, event = result + await self._log_retry_outcome_event(run, retried_run, event) + return True + + updated = await self.repository.finish_run_if_owned( + run.tenant_id, + run.id, + self.worker_id, + run.attempt, + intent.status, + output=intent.output, + error=intent.error, + ) + if updated is None: + logger.warning( + "run finalize commit abandoned: lease no longer owned", + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + ) + return False + self._log_finalize_outcome(run, updated, intent) + return True + + @staticmethod + def _log_finalize_outcome(run: Run, updated: Run, intent: "Worker._Intent") -> None: + """Log the actual resulting status of a terminal/paused finalize + write -- factored out so both ``_commit_intent`` (the ``paused`` + and non-steering-closing paths) and the merged + ``finalize_run_with_steering_disposition_if_owned`` path (issue + #16 PR #17 review round 9, point 2, BLOCKER) log identically. + """ + + final_status = updated.status if isinstance(updated.status, str) else updated.status.value + if final_status == RunStatus.SUCCEEDED.value: + logger.info( + "run succeeded", + extra={"run_id": run.id, "tenant_id": run.tenant_id, "status": "succeeded"}, + ) + elif final_status == RunStatus.CANCELLED.value and intent.status != RunStatus.CANCELLED: + logger.info( + "run cancelled during finalization, overriding stale %s intent", + intent.status.value if hasattr(intent.status, "value") else intent.status, + extra={"run_id": run.id, "tenant_id": run.tenant_id, "status": "cancelled"}, + ) + elif final_status in (RunStatus.DEAD_LETTER.value, RunStatus.FAILED.value) and intent.error: + logger.error( + "run delivery failed", + extra={ + "run_id": run.id, + "tenant_id": run.tenant_id, + "status": final_status, + }, + ) + + async def _log_retry_outcome_event( + self, run: Run, updated: Run, event: RunEvent | None + ) -> None: + """Publish/log the outcome of a merged retry-with-event write. + + Issue #16 PR #17 review round 10, point 1 (BLOCKER): the status + transition and the ``worker_retrying`` event are now written + together, atomically, by + ``repository.retry_run_with_event_if_owned`` -- so unlike the old + ``_emit_retry_outcome_event`` this method never decides *whether* + to append an event; it only publishes the event the repository + already durably created (when the transition actually landed on + ``pending``) or logs the cancel-wins outcome (when it didn't, + ``event`` is ``None``). + """ + + final_status = updated.status if isinstance(updated.status, str) else updated.status.value + if event is None: + if final_status == RunStatus.CANCELLED.value: + logger.info( + "run cancelled during finalization, retry request superseded by cancel", + extra={"run_id": run.id, "tenant_id": run.tenant_id, "status": "cancelled"}, + ) + return + await self.event_bus.publish(run.tenant_id, run.id, event.sequence) + logger.warning( + "run delivery scheduled for retry", + extra={"run_id": run.id, "tenant_id": run.tenant_id, "status": "pending"}, + ) + + async def _retry_or_dead_letter(self, run: Run, error: dict[str, Any]) -> None: + """Route a run that could not safely finalize into the ordinary + retry/dead-letter path, fenced on ``(lease_owner, attempt)``. + + The only remaining caller is a final steering flush that never + durably succeeded (the worker is draining, or the lease was lost + to another worker mid-flush): the intended outcome computed in + ``_execute`` must never be written, so this instead resolves the + attempt the same way an ordinary mid-run delivery failure would -- + retry while attempts remain, dead-letter once they are exhausted. + + Historical note: round 7 also routed "steering landed too late to + be safely consumed" through this same path, forcing an indefinite + retry/dead-letter loop onto any run that ever had undrained + steering -- even a graph that legitimately never calls + ``drain_steering()`` at all. Round 8 replaced that with "seal + admission at the safe boundary + supersede late events" (see + ``finalize_run_with_steering_disposition_if_owned``): a steering + row still ``pending``/``delivered`` once the run reaches its + terminal finalization boundary is durably superseded, not routed + through a retry loop, so it no longer reaches this method at all. + And a genuine delivery ``retry`` intent (as opposed to a terminal + finalization failure) never closes steering admission in the first + place (review round 9, point 1, BLOCKER) -- it goes straight to + ``_commit_intent``, leaving steering fully open and recoverable by + the next delivery attempt, without ever passing through here. + """ + + if run.attempt < self.max_delivery_attempts: + result = await self.repository.retry_run_with_event_if_owned( + run.tenant_id, + run.id, + self.worker_id, + run.attempt, + error=error, + max_attempts=self.max_delivery_attempts, + ) + if result is None: + logger.warning( + "run finalization abandoned: lease lost during retry", + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + ) + return + retried_run, event = result + await self._log_retry_outcome_event(run, retried_run, event) + else: + updated = await self.repository.finish_run_if_owned( + run.tenant_id, + run.id, + self.worker_id, + run.attempt, + RunStatus.DEAD_LETTER, + error=error, + ) + if updated is None: + logger.warning( + "run finalization abandoned: lease lost before dead-lettering", + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + ) + return + logger.error( + "run dead-lettered: could not safely finalize after max attempts", + extra={ + "run_id": run.id, + "tenant_id": run.tenant_id, + "status": updated.status + if isinstance(updated.status, str) + else updated.status.value, + }, + ) + + async def _lease_renewal_loop(self, run: Run) -> None: + """Keep the run's lease alive through the whole finalization window. + + Issue #16 PR #17 review round 6, point 1 (BLOCKER): coupling lease + renewal solely to the ordinary steering/event heartbeat -- which + ``_execute`` cancels the moment the graph finishes -- lets the + lease simply expire while the final flush is still retrying, + letting a new worker claim (and even finish) the run out from + under us. This loop renews the lease on its own schedule for as + long as it runs; ``_execute`` cancels it once finalization (flush + + fenced commit) is fully done, one way or another. + """ + + try: + while True: + await asyncio.sleep(self.heartbeat_seconds) + alive = await self.repository.heartbeat( + run.tenant_id, run.id, self.worker_id, lease_seconds=self.lease_seconds + ) + if not alive: + return + except asyncio.CancelledError: + raise + + async def _final_steering_flush( + self, run: Run, graph: Any, *, base_delay: float = 0.5, max_delay: float = 10.0 + ) -> bool: + """Durable flush of drained steering before finalizing. + + Unlike :meth:`_sync_steering_out` (used at heartbeat ticks, where a + transient failure can simply be retried at the next tick), this is + called once the graph has already reached its terminal/paused + outcome and the heartbeat loop has stopped -- there is no later + safe point *within this delivery attempt*. Critically, there is + also no safe "next delivery attempt" to fall back on the way an + ordinary mid-run failure has: ``_execute`` returning discards this + locally-bound ``graph`` (and therefore its steering channel and + local consumption log) entirely, because ``with_runtime`` builds a + brand new ``CompiledStateGraph`` -- with a brand new, empty + ``_run_steering`` map -- on every call (issue #16 PR #17 review + round 5, point 2). A future redelivery would construct a fresh + channel with no memory of what this attempt already drained, + risking double-execution or a permanently stuck ``pending`` row. + + So instead of a small bounded number of attempts, this retries + with capped exponential backoff for as long as it continues to + hold the run's lease and the worker is not draining, renewing the + lease between attempts so another worker does not reclaim the run + (and construct its own competing channel) out from under it mid + retry. It only gives up -- returning ``False`` -- if the worker + itself is shutting down, or the lease is confirmed lost to another + worker; both are cases where holding on any longer would be + pointless or actively harmful, and the caller falls back to the + ordinary retry/dead-letter path. + """ + + channel = graph.get_steering_channel(run.id) + attempt = 0 + # Iterative capped growth rather than ``base_delay * 2 ** (attempt + # - 1)``: the latter computes a potentially enormous exponent + # before ``min()`` ever clamps it, which is wasted (and, given an + # unbounded retry count against a very long DB outage, + # eventually pathological) work. Doubling and clamping in place + # reaches the same steady-state ``max_delay`` without ever + # computing an intermediate value larger than that cap. + delay = base_delay while True: - await asyncio.sleep(self.heartbeat_seconds) + consumed = channel.peek_consumed() + if not consumed: + return True + attempt += 1 + try: + # Issue #16 PR #17 review round 7, point 2 (BLOCKER): fenced + # on our own (lease_owner, attempt) -- see + # ``commit_steering_consumptions_if_owned``'s docstring. A + # ``None`` result means our lease has already been reclaimed + # by a new owner; nothing was written, so we must not ack + # locally either -- fall through to the "lost the lease" + # exit below, same as an exhausted retry. + stored_events = await self.repository.commit_steering_consumptions_if_owned( + run.tenant_id, run.id, self.worker_id, run.attempt, consumed + ) + except Exception: + logger.warning( + "final steering flush attempt %d failed", + attempt, + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + exc_info=True, + ) + if self._stop.is_set(): + return False + await asyncio.sleep(delay) + delay = min(max_delay, delay * 2) + # Keep the lease alive while we keep retrying so another + # worker does not reclaim this run mid-flush and construct + # a competing graph/channel of its own. + alive = await self.repository.heartbeat( + run.tenant_id, run.id, self.worker_id, lease_seconds=self.lease_seconds + ) + if not alive: + return False + continue + if stored_events is None: + logger.warning( + "final steering flush abandoned: lease no longer owned", + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + ) + return False + channel.ack_consumed(consumption.event.id for consumption in consumed) + for stored in stored_events: + await self.event_bus.publish(run.tenant_id, run.id, stored.sequence) + return True + + async def _heartbeat(self, run: Run, token: CancellationToken, graph: Any) -> None: + while True: + # Issue #16 PR #17 review round 4, point 5: wait on the + # EventBus (Redis, when configured) instead of a plain + # ``asyncio.sleep`` so a ``/steer`` call's ``publish()`` can + # wake this loop early -- low-latency steering discovery -- + # while ``timeout=self.heartbeat_seconds`` still bounds the + # wait, so this remains a correctness-preserving fallback to + # the unconditional PostgreSQL poll below even if the publish + # is dropped, Redis is unavailable, or no EventBus wake ever + # arrives at all (``InMemoryEventBus``/``RedisEventBus.wait`` + # both return on timeout rather than raising). + await self.event_bus.wait(run.tenant_id, run.id, timeout=self.heartbeat_seconds) + # Guarantee a real scheduler yield every iteration regardless of + # how quickly a particular ``EventBus.wait`` implementation + # returns (a degenerate/no-op bus could otherwise return without + # ever suspending, starving the run's own coroutine). + await asyncio.sleep(0) if await self.repository.is_cancel_requested(run.tenant_id, run.id): + # Cancellation always takes priority; steering must never + # undo or delay it. We still sync steering below so an + # already-accepted event is not lost, but we do not let a + # steer block or reorder the cancel signal. token.cancel() + # PostgreSQL is the source of truth for steering; this poll + # keeps working even if a Redis ``run.steer.available`` notify + # was dropped or Redis is unavailable entirely. + await self._sync_steering_in(run, graph) + await self._sync_steering_out(run, graph) alive = await self.repository.heartbeat( run.tenant_id, run.id, @@ -269,6 +764,79 @@ async def _heartbeat(self, run: Run, token: CancellationToken) -> None: token.cancel() return + async def _sync_steering_in(self, run: Run, graph: Any) -> None: + """Pull durably-accepted-but-not-yet-delivered events into the graph.""" + + pending = await self.repository.list_pending_steering(run.tenant_id, run.id) + if not pending: + return + channel = graph.get_steering_channel(run.id) + for row in pending: + channel.ingest( + SteeringEvent( + id=row.id, + run_id=run.id, + sequence=row.sequence, + kind=row.kind, + payload=row.payload, + metadata=row.metadata, + created_at=row.created_at, + source_event_id=row.source_event_id, + ) + ) + + async def _sync_steering_out(self, run: Run, graph: Any) -> None: + """Flush events the graph has drained back to PostgreSQL + SSE. + + Reads (peeks) the channel's local consumption log, durably commits + it -- steering status *and* the ``run.steer.consumed`` lifecycle + event, together, in one repository call -- and only then acks + those entries out of the local log. If the durable commit fails + (e.g. a transient DB error), nothing is acked: the entries stay in + the channel's consumption log and are retried on the next sync + (next heartbeat tick, or the final flush in ``_execute``). This is + the issue #16 PR #17 review point 2 fix -- the previous + pop-then-write ordering could destroy the local record before the + durable write was known to have succeeded, permanently losing a + consumption (and its observability event) on a transient failure. + """ + + channel = graph.get_steering_channel(run.id) + consumed = channel.peek_consumed() + if not consumed: + return + try: + # Issue #16 PR #17 review round 7, point 2 (BLOCKER): fenced on + # our own (lease_owner, attempt) -- a lease takeover mid- + # heartbeat has the identical hole the final flush did. A + # ``None`` result means a new worker already claimed this run + # (attempt+1); nothing was written, and we must not ack this + # batch locally -- it stays in the channel's consumption log, + # which is about to be discarded anyway once this delivery + # attempt's ``_execute`` notices the lost lease. + stored_events = await self.repository.commit_steering_consumptions_if_owned( + run.tenant_id, run.id, self.worker_id, run.attempt, consumed + ) + except Exception: + logger.warning( + "steering consumption commit failed; will retry at the next safe point", + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + exc_info=True, + ) + return + if stored_events is None: + logger.warning( + "steering consumption commit skipped: lease no longer owned", + extra={"run_id": run.id, "tenant_id": run.tenant_id}, + ) + return + # Only ack the ids we actually just committed -- a concurrent + # drain() may have appended more to the log between the peek above + # and here, and those must be left for the next cycle. + channel.ack_consumed(consumption.event.id for consumption in consumed) + for stored in stored_events: + await self.event_bus.publish(run.tenant_id, run.id, stored.sequence) + async def _append_event(self, run: Run, event: Event) -> None: encoded = self._serializer.dumps(dataclasses.asdict(event)) if len(encoded) > self.repository.limits.max_event_bytes: diff --git a/src/lingxigraph/steering.py b/src/lingxigraph/steering.py new file mode 100644 index 0000000..e60abc1 --- /dev/null +++ b/src/lingxigraph/steering.py @@ -0,0 +1,331 @@ +"""Durable mid-run steering: provider-neutral data model + in-process channel. + +Steering lets an external caller inject new structured input into a Run +while it is executing. LingxiGraph guarantees durable delivery, ordering, +dedup and safe consumption; it never interprets what a steering event +*means* -- that is entirely up to the application graph (see +``runtime.drain_steering()`` in :mod:`lingxigraph.runtime`). + +This module is intentionally dependency-free so it works the same way in +embedded/library usage (no Agent Server, no PostgreSQL) as it does behind +the HTTP API -- the "degraded" implementation for SQLite/in-memory/embedded +runtimes *is* this module, not a separate code path. +""" + +from __future__ import annotations + +import threading +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +#: Default cap on the serialized size of a steering event payload+metadata. +MAX_STEERING_PAYLOAD_BYTES = 32_768 + + +class SteeringPayloadTooLarge(ValueError): + """Raised when a steering event payload exceeds the configured size cap.""" + + +@dataclass(frozen=True, slots=True) +class SteeringEvent: + """A single durable, ordered piece of external input for a Run. + + ``id`` doubles as the idempotency key used for dedup; ``sequence`` is a + monotonically increasing, per-run ordering position assigned at + acceptance time. ``payload``/``metadata`` must already be safe to + serialize through :mod:`lingxigraph.serialization`. + """ + + id: str + run_id: str + sequence: int + kind: str + payload: Mapping[str, Any] + metadata: Mapping[str, Any] = field(default_factory=dict) + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + #: Set when this event is a paused-run -> resumed-run transfer of an + #: originally-accepted event (see ``Repository.resume_run_with_pending_ + #: steering`` / issue #16 PR #17 review point 3): the id of the + #: original event a client's ``/steer`` call actually received, so + #: downstream ``run.steer.consumed`` observability can be correlated + #: back to it even though ``id`` itself changed across the transfer. + source_event_id: str | None = None + + +def validate_steering_payload( + payload: Mapping[str, Any], + metadata: Mapping[str, Any] | None = None, + *, + max_bytes: int = MAX_STEERING_PAYLOAD_BYTES, +) -> None: + """Raise :class:`SteeringPayloadTooLarge` if the payload is oversized. + + Also verifies the payload goes through the safe JSON serialization + boundary (no unsafe/unserializable values). + """ + + from .serialization import JsonSerializer, SerializationError + + serializer = JsonSerializer() + try: + encoded = serializer.dumps(dict(payload)) + encoded_metadata = serializer.dumps(dict(metadata or {})) + except SerializationError as exc: + raise SteeringPayloadTooLarge(f"steering payload is not safely serializable: {exc}") from exc + total = len(encoded) + len(encoded_metadata) + if total > max_bytes: + raise SteeringPayloadTooLarge( + f"steering payload size {total} exceeds max_bytes={max_bytes}" + ) + + +def new_steering_id() -> str: + return str(uuid4()) + + +@dataclass(frozen=True, slots=True) +class SteeringConsumption: + """Where and how long an event waited before a safe point drained it. + + Produced by :meth:`SteeringChannel.drain` for every event it returns, + and surfaced end-to-end as the ``run.steer.consumed`` observability + event's payload (see :mod:`lingxigraph.server.worker`) so a production + deployment can answer "where did this get consumed, and how long did it + queue" -- the two pieces of information issue #16's acceptance criteria + call out that a bare ``steering_event_id/sequence/kind`` record cannot + answer on its own. + """ + + event: SteeringEvent + consumed_at: datetime + #: The node (task) whose call to ``runtime.drain_steering()`` drained + #: this event -- ``None`` when drained outside node execution (e.g. a + #: direct ``channel.drain()`` call with no consumer context attached). + node: str | None = None + #: The subgraph namespace path of that node, e.g. ``("supervisor",)``. + namespace: tuple[str, ...] = () + #: The specific task id (distinguishes parallel/``Send`` fan-out tasks + #: that share one node name within the same superstep). + task_id: str | None = None + + @property + def queue_latency_seconds(self) -> float: + """Wall-clock time between durable acceptance and consumption.""" + + return max(0.0, (self.consumed_at - self.event.created_at).total_seconds()) + + +class SteeringChannel: + """Thread-safe, ordered, dedup'd inbox of :class:`SteeringEvent`. + + This is the concrete "safe-point" primitive used by + :class:`lingxigraph.runtime.Runtime`. It is deliberately simple: + + * ``submit`` is idempotent by ``event.id`` -- resubmitting the same id + is a no-op (supports at-least-once delivery from a durable inbox). + * ``drain`` atomically returns *and clears* all currently pending + events, in ascending ``sequence`` order. Once drained, an event is + never re-exposed by this channel again (though upstream at-least-once + delivery may still resubmit the same id from PostgreSQL after a + crash -- callers must dedup on ``event.id`` if that matters to them). + * ``peek`` is the read-only variant: it does not consume anything. + + A ``on_drain`` callback (sync, best-effort) can be supplied so a server + integration (e.g. the durable-repository-backed worker) can mark events + ``consumed`` in PostgreSQL when the graph actually drains them. + """ + + def __init__(self, run_id: str = "", *, owned_by_executor: bool = True) -> None: + self.run_id = run_id + self._lock = threading.Lock() + self._pending: dict[str, SteeringEvent] = {} + self._seen_ids: set[str] = set() + self._next_sequence = 1 + self._consumed_log: list[SteeringConsumption] = [] + self.on_drain: Any = None + #: Whether the graph executor's own embedded run lifecycle may + #: release (``forget``) this channel automatically once its run + #: finishes. ``True`` for channels created implicitly by + #: ``CompiledStateGraph.steer()``/``_run()`` (plain embedded + #: ``invoke``/``ainvoke`` with no server involved). A server + #: integration explicitly registers a channel ahead of time via + #: ``get_steering_channel()`` and flips this to ``False`` -- it owns + #: the channel's lifecycle end to end (see ``Worker._execute``'s + #: explicit ``forget_steering`` call) since a queued run may receive + #: steering before any worker has claimed it. + self.owned_by_executor = owned_by_executor + + def submit( + self, + *, + kind: str, + payload: Mapping[str, Any], + metadata: Mapping[str, Any] | None = None, + idempotency_key: str | None = None, + max_bytes: int = MAX_STEERING_PAYLOAD_BYTES, + ) -> tuple[SteeringEvent, bool]: + """Append a new event. Returns ``(event, created)``. + + ``created`` is ``False`` when ``idempotency_key`` matches an event + already known to this channel (pending or already drained). + """ + + validate_steering_payload(payload, metadata, max_bytes=max_bytes) + event_id = idempotency_key or new_steering_id() + with self._lock: + existing = self._pending.get(event_id) + if existing is not None: + return existing, False + if event_id in self._seen_ids: + # Already drained/consumed previously -- durable dedup. + return SteeringEvent( + id=event_id, + run_id=self.run_id, + sequence=-1, + kind=kind, + payload=payload, + metadata=metadata or {}, + ), False + event = SteeringEvent( + id=event_id, + run_id=self.run_id, + sequence=self._next_sequence, + kind=kind, + payload=dict(payload), + metadata=dict(metadata or {}), + ) + self._next_sequence += 1 + self._pending[event_id] = event + self._seen_ids.add(event_id) + return event, True + + def ingest(self, event: SteeringEvent) -> bool: + """Accept an already-constructed durable event (server integration). + + Preserves the caller-provided sequence number when it is larger + than anything seen so far, so ordering follows the durable source + of truth (PostgreSQL) rather than local submission order. + """ + + with self._lock: + if event.id in self._seen_ids: + return False + self._seen_ids.add(event.id) + self._pending[event.id] = event + self._next_sequence = max(self._next_sequence, event.sequence + 1) + return True + + @property + def has_pending(self) -> bool: + with self._lock: + return bool(self._pending) + + def peek(self) -> tuple[SteeringEvent, ...]: + with self._lock: + return tuple(sorted(self._pending.values(), key=lambda item: item.sequence)) + + def drain( + self, + *, + node: str | None = None, + namespace: tuple[str, ...] = (), + task_id: str | None = None, + ) -> tuple[SteeringEvent, ...]: + """Atomically consume and return pending events, in order. + + ``node``/``namespace``/``task_id`` identify *where* the drain + happened -- the executor's safe point (see + :meth:`lingxigraph.runtime.Runtime.drain_steering`) passes its own + task coordinates through so the consumption record (see + :meth:`pop_consumed`) can answer "which node consumed this, in + which subgraph namespace" for observability. Callers outside node + execution (e.g. a bare ``channel.drain()``) may omit them. + """ + + now = datetime.now(UTC) + with self._lock: + drained = tuple(sorted(self._pending.values(), key=lambda item: item.sequence)) + self._pending.clear() + self._consumed_log.extend( + SteeringConsumption( + event=event, consumed_at=now, node=node, namespace=namespace, task_id=task_id + ) + for event in drained + ) + if drained and self.on_drain is not None: + try: + self.on_drain(drained) + except Exception: + # Observability/persistence side-effects must never break + # graph execution -- the DB inbox remains the source of + # truth and a later safe point can retry marking consumed. + pass + return drained + + def pop_consumed(self) -> tuple[SteeringConsumption, ...]: + """Return and clear the log of events drained since the last call. + + Destructive: callers that must durably persist a consumption + before it is safe to forget it locally (any server-mode worker -- + see :meth:`peek_consumed`/:meth:`ack_consumed`) must **not** use + this method, since a transient failure between the pop and the + durable write would silently lose the record (issue #16 PR #17 + review point 2). This remains the right call for a caller that + either has nothing durable to write (plain embedded/library usage) + or has already durably committed and just wants to drain its own + bookkeeping. Each entry is a :class:`SteeringConsumption`, + carrying queue latency and the consuming node/namespace/task_id + alongside the raw event. + """ + + with self._lock: + consumed = tuple(self._consumed_log) + self._consumed_log.clear() + return consumed + + def peek_consumed(self) -> tuple[SteeringConsumption, ...]: + """Read-only view of the not-yet-acked consumption log. + + Use with :meth:`ack_consumed` for durable consumers: read the + entries, durably commit them (DB status update + lifecycle event), + and only then ack the specific ids that succeeded. Unlike + :meth:`pop_consumed` this never destroys data a failed durable + write could not yet account for. + """ + + with self._lock: + return tuple(self._consumed_log) + + def ack_consumed(self, event_ids: Iterable[str]) -> None: + """Remove specific entries from the consumption log once durably committed. + + Only entries whose ``event.id`` is in ``event_ids`` are removed -- + anything appended by a concurrent :meth:`drain` between the + matching :meth:`peek_consumed` and this call is left alone so it + gets picked up on the next cycle instead of being silently + dropped. + """ + + ids = set(event_ids) + if not ids: + return + with self._lock: + if not self._consumed_log: + return + self._consumed_log[:] = [ + entry for entry in self._consumed_log if entry.event.id not in ids + ] + + +__all__ = [ + "MAX_STEERING_PAYLOAD_BYTES", + "SteeringChannel", + "SteeringConsumption", + "SteeringEvent", + "SteeringPayloadTooLarge", + "new_steering_id", + "validate_steering_payload", +] diff --git a/tests/test_agent_server.py b/tests/test_agent_server.py index 4dc30b8..d8608e6 100644 --- a/tests/test_agent_server.py +++ b/tests/test_agent_server.py @@ -286,6 +286,186 @@ def test_resource_store_schedule_and_cancel_lifecycle(self) -> None: 204, ) + def test_cancel_paused_run_terminates_immediately(self) -> None: + """Issue #16 PR #17 review round 12 (BLOCKER): cancelling a ``paused`` + run must resolve to ``cancelled`` immediately instead of flipping to + a lease-less ``cancelling`` that no recovery mechanism (including + round 11's expired-lease recovery) can ever unstick, because a + paused run never held a lease in the first place. + """ + app = create_app( + registry=make_registry(), + authenticator=Authenticator.insecure_dev(), + embedded_worker=True, + ) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", + headers=headers, + json={"graph_id": "approval", "name": "approval"}, + ).json() + thread = client.post("/v1/threads", headers=headers, json={}).json() + paused = client.post( + f"/v1/threads/{thread['id']}/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"value": 0}}, + ).json() + paused = wait_for_status(client, paused["id"], headers, {"paused"}).json() + self.assertEqual(paused["status"], "paused") + + # Requirement 4: a paused run with pending steering must keep + # that steering durable -- this fix must not implicitly + # consume/replay/supersede it. + steer = client.post( + f"/v1/runs/{paused['id']}/steer", + headers=headers, + json={"kind": "note", "payload": {"text": "still relevant"}}, + ) + self.assertEqual(steer.status_code, 202) + + # Regression 1 (InMemory, direct repository call against a + # genuinely paused run produced by the Worker, not a + # hand-faked status). + repository = app.state.repository + self.assertIsNone(asyncio.run(repository.get_run("acme", paused["id"])).lease_owner) + cancel_result = asyncio.run(repository.request_cancel("acme", paused["id"])) + self.assertTrue(cancel_result) + after_cancel = asyncio.run(repository.get_run("acme", paused["id"])) + self.assertEqual(after_cancel.status, "cancelled") + self.assertIsNotNone(after_cancel.finished_at) + self.assertIsNone(after_cancel.lease_owner) + self.assertIsNone(after_cancel.lease_expires_at) + claimed = asyncio.run(repository.claim_run("worker", lease_seconds=30)) + self.assertIsNone(claimed) + + # Steering submitted while paused stays durable history -- not + # implicitly dropped by the cancel-while-paused fix. + events = asyncio.run(repository.list_events("acme", paused["id"])) + self.assertTrue(any(event.kind == "run.steer.accepted" for event in events)) + + def test_cancel_paused_run_http_e2e(self) -> None: + """Regression 2 (HTTP E2E): the full public control-API surface + must observe the paused-run cancel as an immediate terminal state, + with resume and a new-idempotency-key steer both rejected 409. + """ + app = create_app( + registry=make_registry(), + authenticator=Authenticator.insecure_dev(), + embedded_worker=True, + ) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", + headers=headers, + json={"graph_id": "approval", "name": "approval"}, + ).json() + thread = client.post("/v1/threads", headers=headers, json={}).json() + paused = client.post( + f"/v1/threads/{thread['id']}/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"value": 0}}, + ).json() + paused = wait_for_status(client, paused["id"], headers, {"paused"}).json() + self.assertEqual(paused["status"], "paused") + + cancelled = client.post(f"/v1/runs/{paused['id']}/cancel", headers=headers) + self.assertEqual(cancelled.status_code, 200, cancelled.text) + self.assertEqual(cancelled.json()["status"], "cancelled") + + joined = client.get(f"/v1/runs/{paused['id']}/join", headers=headers) + self.assertEqual(joined.status_code, 200, joined.text) + self.assertEqual(joined.json()["status"], "cancelled") + + resume_after_cancel = client.post( + f"/v1/runs/{paused['id']}/resume", + headers=headers, + json={"resume": 7}, + ) + self.assertEqual(resume_after_cancel.status_code, 409) + + steer_after_cancel = client.post( + f"/v1/runs/{paused['id']}/steer", + headers={**headers, "Idempotency-Key": "post-cancel-key"}, + json={"kind": "note", "payload": {"text": "too late"}}, + ) + self.assertEqual(steer_after_cancel.status_code, 409) + self.assertEqual(steer_after_cancel.json()["code"], "run_terminal") + + def test_cancel_of_a_resumed_run_returns_409_and_the_new_run_is_still_cancellable( + self, + ) -> None: + """Issue #16 PR #17 review round 13 (BLOCKER), HTTP E2E: resuming + ``A`` into ``B`` and then calling ``/cancel`` on the now-stale + ``A`` must return ``409 run_superseded`` -- not a false-success 200 + -- and ``B`` (the run actually still executing) must remain + genuinely cancellable afterwards. + """ + app = create_app( + registry=make_registry(), + authenticator=Authenticator.insecure_dev(), + embedded_worker=True, + ) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", + headers=headers, + json={"graph_id": "approval", "name": "approval"}, + ).json() + thread = client.post("/v1/threads", headers=headers, json={}).json() + paused = client.post( + f"/v1/threads/{thread['id']}/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"value": 0}}, + ).json() + paused = wait_for_status(client, paused["id"], headers, {"paused"}).json() + self.assertEqual(paused["status"], "paused") + + resumed = client.post( + f"/v1/runs/{paused['id']}/resume", + headers=headers, + json={"resume": 7}, + ) + self.assertEqual(resumed.status_code, 202, resumed.text) + new_run_id = resumed.json()["id"] + self.assertNotEqual(new_run_id, paused["id"]) + + # Cancelling the stale, resumed-away run must be rejected as + # superseded -- never a false-success 200 -- and the caller + # must be told exactly which run_id to target instead. + cancel_old = client.post(f"/v1/runs/{paused['id']}/cancel", headers=headers) + self.assertEqual(cancel_old.status_code, 409, cancel_old.text) + self.assertEqual(cancel_old.json()["code"], "run_superseded") + self.assertIn(new_run_id, cancel_old.json()["detail"]) + + # The old run must not have been mutated to a terminal state + # by the rejected cancel attempt. + old_after = client.get(f"/v1/runs/{paused['id']}", headers=headers).json() + self.assertEqual(old_after["status"], "paused") + + # Cancelling the actual resumed descendant genuinely works + # (unless the tiny "approval" graph already raced to a + # terminal success before the cancel landed -- either way, + # it must never be the superseded 409 the old run gets). + new_run = client.get(f"/v1/runs/{new_run_id}", headers=headers).json() + self.assertNotEqual(new_run["status"], "cancelled") + cancel_new = client.post(f"/v1/runs/{new_run_id}/cancel", headers=headers) + # It must never be rejected as *superseded* -- that would mean + # this endpoint's own descendant is (wrongly) being treated as + # stale. A plain 409 "conflict" is acceptable if the tiny + # "approval" graph already raced to a terminal success before + # the cancel landed. + self.assertNotEqual( + cancel_new.json().get("code"), "run_superseded", cancel_new.text + ) + if cancel_new.status_code == 200: + joined_new = wait_for_status( + client, new_run_id, headers, {"cancelled", "succeeded"} + ).json() + self.assertIn(joined_new["status"], {"cancelled", "succeeded"}) + def test_interrupt_resume_and_protocol_gateways(self) -> None: app = create_app( registry=make_registry(), diff --git a/tests/test_mvp_hardening.py b/tests/test_mvp_hardening.py index 4a72620..8f2c2b4 100644 --- a/tests/test_mvp_hardening.py +++ b/tests/test_mvp_hardening.py @@ -552,6 +552,20 @@ def test_api_idempotency_request_limit_and_readiness(self) -> None: content=b"x" * 513, ) self.assertEqual(understated.status_code, 413) + invalid_length = client.post( + "/v1/runs", + headers={**headers, "content-length": "not-a-number"}, + content=b"{}", + ) + self.assertEqual(invalid_length.status_code, 400) + self.assertEqual(invalid_length.json()["code"], "invalid_request") + negative_length = client.post( + "/v1/runs", + headers={**headers, "content-length": "-1"}, + content=b"{}", + ) + self.assertEqual(negative_length.status_code, 400) + self.assertEqual(negative_length.json()["code"], "invalid_request") class UnhealthyRepository(InMemoryRepository): async def healthcheck(self) -> bool: diff --git a/tests/test_platform_units.py b/tests/test_platform_units.py index f34a34e..4376b39 100644 --- a/tests/test_platform_units.py +++ b/tests/test_platform_units.py @@ -223,6 +223,53 @@ async def aclose(self): asyncio.run(bus.close()) redis.publish.assert_awaited_once() + def test_redis_wait_does_not_busy_loop_when_get_message_raises(self) -> None: + """Issue #16 PR #17 review round 7, point 4: if Redis disconnects + right after ``subscribe()`` succeeds, every subsequent + ``get_message()`` call can raise immediately. Before this fix, + ``_wait_cancellation_safe`` folded that exception into ``None`` -- + indistinguishable from "no message yet" -- so ``RedisEventBus. + wait()``'s deadline loop just kept calling ``get_message()`` again + with no backoff for the rest of the timeout window. The exception + must now propagate out to ``wait()``'s own outer ``except + Exception: return``, so ``wait()`` returns promptly and + ``get_message`` is called only once.""" + + call_count = 0 + + class FlakyPubSub: + async def subscribe(self, _channel): + return None + + async def get_message(self, **_kwargs): + nonlocal call_count + call_count += 1 + raise ConnectionError("simulated redis disconnect") + + async def aclose(self): + return None + + redis = SimpleNamespace( + publish=AsyncMock(return_value=1), + pubsub=lambda: FlakyPubSub(), + aclose=AsyncMock(), + ) + with patch("redis.asyncio.Redis.from_url", return_value=redis): + bus = RedisEventBus("redis://test") + + async def scenario(): + start = asyncio.get_event_loop().time() + await bus.wait("tenant", "run", timeout=5.0) + elapsed = asyncio.get_event_loop().time() - start + return elapsed + + elapsed = asyncio.run(scenario()) + # A busy-loop would burn through the whole 5s timeout window + # calling get_message() thousands of times; the fix returns almost + # immediately after the single raising call. + self.assertEqual(call_count, 1) + self.assertLess(elapsed, 1.0) + def test_redis_cache_roundtrip_clear_and_sync_wrappers(self) -> None: serializer = JsonSerializer() diff --git a/tests/test_postgres_integration.py b/tests/test_postgres_integration.py index 4c13212..3261f67 100644 --- a/tests/test_postgres_integration.py +++ b/tests/test_postgres_integration.py @@ -108,7 +108,7 @@ class State(dict): ) conn.execute(f'GRANT USAGE ON SCHEMA "{self.schema}" TO "{role}"') conn.execute( - f'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ' + f"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA " f'"{self.schema}" TO "{role}"' ) conn.execute( @@ -132,6 +132,1388 @@ class State(dict): conn.execute(f'DROP OWNED BY "{role}"') conn.execute(f'DROP ROLE "{role}"') + def test_resume_run_with_pending_steering_is_atomic_and_race_free(self) -> None: + """Issue #16 PR #17 review point 1, PostgreSQL path. + + Regresses both halves of the finding against a real PostgreSQL + transaction: + + * A worker hammering ``claim_run`` concurrently with + ``resume_run_with_pending_steering`` must never observe the + resumed Run before its migrated steering is already present -- + the previous two-transaction implementation (separate + ``create_run`` + ``transfer_pending_steering`` commits) allowed a + real window where the new Run was claimable (and even + finishable) with nothing migrated onto it. + * Sequence numbers assigned to the migrated events and to an + ordinary steer submitted against the resumed run afterwards must + never collide -- the previous ``_transfer_pending_steering_sync`` + only locked the *old* run row, not the new one, so its + ``MAX(sequence)+1`` could race a concurrent ``_submit_steering_ + sync`` doing the same computation for the new run. + """ + + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + old_run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + await repo.submit_steering("acme", old_run.id, kind="user_input", payload={"m": 1}) + await repo.submit_steering("acme", old_run.id, kind="user_input", payload={"m": 2}) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + claims: list[str] = [] + stop = asyncio.Event() + + async def hammer_claim() -> None: + while not stop.is_set(): + claimed = await repo.claim_run("worker-a", lease_seconds=30) + if claimed is not None: + claims.append(claimed.id) + if claimed.id != old_run.id: + pending = await repo.list_pending_steering("acme", claimed.id) + self.assertEqual( + len(pending), + 2, + "resumed run was claimable before its steering migrated", + ) + await asyncio.sleep(0) + + hammer = asyncio.create_task(hammer_claim()) + request = RunCreate(assistant_id=assistant.id, resume=1) + new_run, transferred = await repo.resume_run_with_pending_steering( + "acme", thread.id, assistant, request, old_run.id + ) + self.assertEqual([event.sequence for event in transferred], [1, 2]) + for _ in range(1000): + if new_run.id in claims: + break + await asyncio.sleep(0.002) + stop.set() + await hammer + self.assertIn(new_run.id, claims) + + extra, _ = await repo.submit_steering( + "acme", new_run.id, kind="user_input", payload={"m": 3} + ) + self.assertEqual(extra.sequence, 3) + all_events = await repo.list_steering("acme", new_run.id) + self.assertEqual(sorted(event.sequence for event in all_events), [1, 2, 3]) + + # Identity survives the transfer (review point 3). + self.assertTrue( + all(event.source_event_id for event in all_events if event.sequence in (1, 2)) + ) + + asyncio.run(scenario()) + + def test_concurrent_resume_of_the_same_paused_run_creates_exactly_one_descendant( + self, + ) -> None: + """Issue #16 PR #17 review round 4, point 1, PostgreSQL path. + + Two real concurrent transactions both calling + ``resume_run_with_pending_steering`` against the same paused run + must not both succeed: PostgreSQL's ``FOR UPDATE`` on the old run + row serializes them, but the loser must be rejected by the locked + revalidation (still ``paused``, no ``superseded_by_run_id`` yet) + rather than silently creating a second descendant Run and + overwriting the winner's ``superseded_by_run_id``. + """ + + from lingxigraph.errors import RunResumeConflictError + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + old_run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + request = RunCreate(assistant_id=assistant.id, resume=1) + results = await asyncio.gather( + repo.resume_run_with_pending_steering( + "acme", thread.id, assistant, request, old_run.id + ), + repo.resume_run_with_pending_steering( + "acme", thread.id, assistant, request, old_run.id + ), + return_exceptions=True, + ) + successes = [r for r in results if not isinstance(r, BaseException)] + failures = [r for r in results if isinstance(r, BaseException)] + self.assertEqual(len(successes), 1, results) + self.assertEqual(len(failures), 1, results) + self.assertIsInstance(failures[0], RunResumeConflictError) + + winner_run, _ = successes[0] + all_runs = await repo.list_runs("acme", thread_id=thread.id) + descendants = [r for r in all_runs if r.id != old_run.id] + self.assertEqual(len(descendants), 1) + self.assertEqual(descendants[0].id, winner_run.id) + + final_old = await repo.get_run("acme", old_run.id) + self.assertEqual(final_old.superseded_by_run_id, winner_run.id) + + asyncio.run(scenario()) + + def test_concurrent_resume_vs_cancel_never_false_succeeds(self) -> None: + """Issue #16 PR #17 review round 13 (BLOCKER), PostgreSQL path. + + Races a real ``resume_run_with_pending_steering`` transaction + against a real ``request_cancel`` transaction for the same paused + run's row lock. PostgreSQL's ``FOR UPDATE`` linearizes the two, but + under *either* winning order the caller must never be lied to: + + * cancel wins the lock first -> old run cancelled, resume then + fails as a resume conflict (no descendant created). + * resume wins the lock first -> exactly one descendant created, + cancel is rejected as superseded (never a false-success True). + + Under no interleaving may ``request_cancel`` return ``True`` while + a resumed descendant exists and keeps running untouched. + """ + + from lingxigraph.errors import RunResumeConflictError, RunSupersededError + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + old_run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + request = RunCreate(assistant_id=assistant.id, resume=1) + resume_result, cancel_result = await asyncio.gather( + repo.resume_run_with_pending_steering( + "acme", thread.id, assistant, request, old_run.id + ), + repo.request_cancel("acme", old_run.id), + return_exceptions=True, + ) + + all_runs = await repo.list_runs("acme", thread_id=thread.id) + descendants = [r for r in all_runs if r.id != old_run.id] + final_old = await repo.get_run("acme", old_run.id) + + if isinstance(cancel_result, bool) and cancel_result: + # Cancel linearized first: old run cancelled, resume must + # have failed as a conflict, and no descendant exists. + self.assertEqual(final_old.status, "cancelled") + self.assertIsInstance(resume_result, RunResumeConflictError) + self.assertEqual(descendants, []) + else: + # Resume linearized first: exactly one descendant exists, + # and cancel must have been rejected as superseded -- + # never a silent False either, since that would also read + # to a caller as "nothing to cancel" without pointing at + # the real successor. + self.assertIsInstance(cancel_result, RunSupersededError) + self.assertEqual(len(descendants), 1) + new_run, _transferred = resume_result + self.assertEqual(descendants[0].id, new_run.id) + self.assertEqual(final_old.status, "paused") + self.assertEqual(final_old.superseded_by_run_id, new_run.id) + + # The real descendant remains genuinely cancellable. + really_cancelled = await repo.request_cancel("acme", new_run.id) + self.assertTrue(really_cancelled) + + asyncio.run(scenario()) + + def test_superseded_by_run_id_typed_column_against_live_postgres(self) -> None: + """Issue #16 PR #17 review round 14 (BLOCKER), PostgreSQL path. + + Exercises the real ``runs.superseded_by_run_id`` column (added by + the ``0005_superseded_by_run_id`` migration) end to end against a + live database: forged ``metadata.superseded_by_run_id`` must be + inert for /steer, /cancel, and /resume, and a real resume must + write the typed column atomically in the same transaction that + creates the descendant run and migrates its steering. + """ + + from lingxigraph.errors import RunSupersededError + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + + # A run created with a forged ``metadata.superseded_by_run_id`` + # must never have that reflected in the typed column, and must + # never be spuriously superseded on /steer or /cancel. + forged = RunCreate( + assistant_id=assistant.id, + input={}, + metadata={"superseded_by_run_id": "some-other-run-that-never-existed"}, + ) + forged_run = await repo.create_run("acme", thread.id, assistant, forged) + self.assertIsNone(forged_run.superseded_by_run_id) + self.assertEqual( + forged_run.metadata.get("superseded_by_run_id"), + "some-other-run-that-never-existed", + ) + event, created = await repo.submit_steering( + "acme", forged_run.id, kind="user_input", payload={"m": 1} + ) + self.assertTrue(created) + self.assertEqual(event.status, "pending") + cancelled = await repo.request_cancel("acme", forged_run.id) + self.assertTrue(cancelled) + + # The same forged metadata on a genuinely ``paused`` run must + # not block a real /resume with a false RunResumeConflictError. + forged_thread = await repo.create_thread("acme", ThreadCreate()) + forged_paused = await repo.create_run("acme", forged_thread.id, assistant, forged) + await repo.finish_run("acme", forged_paused.id, "paused", output={}) + resume_request = RunCreate(assistant_id=assistant.id, resume=1) + resumed_run, _ = await repo.resume_run_with_pending_steering( + "acme", forged_thread.id, assistant, resume_request, forged_paused.id + ) + self.assertIsNotNone(resumed_run.id) + + # A REAL resume must write the typed column atomically and + # gate /steer and /cancel on it. + real_thread = await repo.create_thread("acme", ThreadCreate()) + real_old = await repo.create_run( + "acme", + real_thread.id, + assistant, + RunCreate(assistant_id=assistant.id, input={}), + ) + await repo.finish_run("acme", real_old.id, "paused", output={}) + real_new, _ = await repo.resume_run_with_pending_steering( + "acme", real_thread.id, assistant, resume_request, real_old.id + ) + real_old_after = await repo.get_run("acme", real_old.id) + self.assertEqual(real_old_after.superseded_by_run_id, real_new.id) + self.assertEqual(real_old_after.metadata, {}) + with self.assertRaises(RunSupersededError): + await repo.submit_steering( + "acme", real_old.id, kind="user_input", payload={"m": 1} + ) + with self.assertRaises(RunSupersededError): + await repo.request_cancel("acme", real_old.id) + + asyncio.run(scenario()) + + def test_steer_accept_and_accepted_event_commit_atomically(self) -> None: + """Issue #16 PR #17 review round 4, point 2, PostgreSQL path. + + Injects a failure between the steering row commit and its + ``run.steer.accepted`` append by directly deleting the accepted + event row after a normal ``submit_steering`` call (simulating the + pre-fix failure window where the row committed but the event append + transiently failed), then verifies an idempotency-key retry repairs + it -- exactly once, never duplicated -- and that consumption still + produces a complete accepted -> consumed pair. + """ + + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + from lingxigraph.steering import SteeringConsumption, SteeringEvent + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + + event, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1}, idempotency_key="key-1" + ) + self.assertTrue(created) + accepted = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.accepted" + ] + self.assertEqual(len(accepted), 1) + + # Simulate the pre-fix failure window: the steering row + # committed, but its ``run.steer.accepted`` event never made it + # (e.g. a transient failure right after the row's commit). + import psycopg + + with psycopg.connect(POSTGRES_URL, autocommit=True) as conn: + conn.execute( + f'DELETE FROM "{self.schema}".run_events ' + "WHERE tenant_id=%s AND run_id=%s AND kind='run.steer.accepted'", + ("acme", run.id), + ) + gap = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.accepted" + ] + self.assertEqual(gap, []) + + # Retry with the same Idempotency-Key: the row already exists + # (``created`` is False) but the gap must be repaired. + retried_event, retried_created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1}, idempotency_key="key-1" + ) + self.assertFalse(retried_created) + self.assertEqual(retried_event.id, event.id) + repaired = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.accepted" + ] + self.assertEqual(len(repaired), 1) + self.assertEqual(repaired[0].data["steering_event_id"], event.id) + + # A further retry must not duplicate it. + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1}, idempotency_key="key-1" + ) + final_accepted = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.accepted" + ] + self.assertEqual(len(final_accepted), 1) + + # After consumption, a complete accepted -> consumed pair + # exists (never duplicated by the repair above). + consumption = SteeringConsumption( + event=SteeringEvent( + id=event.id, + run_id=run.id, + sequence=event.sequence, + kind=event.kind, + payload=event.payload, + metadata=event.metadata, + created_at=event.created_at, + ), + consumed_at=event.created_at, + node="n", + namespace=(), + task_id="t-0", + ) + await repo.commit_steering_consumptions("acme", run.id, [consumption]) + all_events = await repo.list_events("acme", run.id) + self.assertEqual(sum(1 for e in all_events if e.kind == "run.steer.accepted"), 1) + self.assertEqual(sum(1 for e in all_events if e.kind == "run.steer.consumed"), 1) + + asyncio.run(scenario()) + + def test_commit_steering_consumptions_is_atomic(self) -> None: + """Issue #16 PR #17 review point 2, PostgreSQL path: status update + and the ``run.steer.consumed`` lifecycle event must land in the + same transaction.""" + + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + from lingxigraph.steering import SteeringConsumption, SteeringEvent + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + event, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + steering_event = SteeringEvent( + id=event.id, + run_id=run.id, + sequence=event.sequence, + kind=event.kind, + payload=event.payload, + metadata=event.metadata, + created_at=event.created_at, + ) + consumption = SteeringConsumption( + event=steering_event, + consumed_at=steering_event.created_at, + node="n", + namespace=(), + task_id="t", + ) + stored = await repo.commit_steering_consumptions("acme", run.id, [consumption]) + self.assertEqual(len(stored), 1) + self.assertEqual(stored[0].kind, "run.steer.consumed") + all_steering = await repo.list_steering("acme", run.id) + self.assertEqual(all_steering[0].status, "consumed") + all_events = await repo.list_events("acme", run.id) + self.assertEqual( + [e.kind for e in all_events if e.kind == "run.steer.consumed"], + ["run.steer.consumed"], + ) + + asyncio.run(scenario()) + + def test_alembic_upgrade_head_applies_source_event_id(self) -> None: + """Issue #16 PR #17 review round 3, point 1; round 15 (REQUIRED + VERIFICATION): explicitly asserts the *current* migration head + (0005_superseded_by_run_id), not just the round-3-era 0003 finding. + + ``PostgresRepository._setup_sync()`` applies the SQL migration + files directly and is *not* a substitute for driving the real + Alembic revision chain that a production deployment uses + (``alembic upgrade head``). This regresses that ``0002_steering`` + -> ``0003_steering_source_event`` -> ``0004_steering_closed`` -> + ``0005_superseded_by_run_id`` is reachable through Alembic itself + and actually creates every column/index the production SQL in + this PR hard-depends on (``run_steering_events.source_event_id``, + ``runs.steering_closed``, ``runs.superseded_by_run_id``) -- so a + future accidental break in the revision chain, a mis-registered + SQL resource, or a stale assertion left over from an earlier round + cannot silently drift the "tested head" away from the real one. + """ + + import psycopg + from alembic import command + from alembic.config import Config + + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + cfg = Config(os.path.join(repo_root, "alembic.ini")) + cfg.set_main_option("script_location", os.path.join(repo_root, "migrations")) + cfg.set_main_option("sqlalchemy.url", POSTGRES_URL) + os.environ["LINGXIGRAPH_POSTGRES_SCHEMA"] = self.schema + + with psycopg.connect(POSTGRES_URL, autocommit=True) as conn: + conn.execute(f'CREATE SCHEMA IF NOT EXISTS "{self.schema}"') + try: + command.upgrade(cfg, "0002_steering") + with psycopg.connect(POSTGRES_URL) as conn: + exists = conn.execute( + """SELECT 1 FROM information_schema.columns + WHERE table_schema=%s AND table_name='run_steering_events' + AND column_name='source_event_id'""", + (self.schema,), + ).fetchone() + self.assertIsNone(exists, "source_event_id should not exist before head") + + command.upgrade(cfg, "head") + with psycopg.connect(POSTGRES_URL) as conn: + column = conn.execute( + """SELECT 1 FROM information_schema.columns + WHERE table_schema=%s AND table_name='run_steering_events' + AND column_name='source_event_id'""", + (self.schema,), + ).fetchone() + self.assertIsNotNone(column, "alembic head must create source_event_id") + index = conn.execute( + """SELECT 1 FROM pg_indexes + WHERE schemaname=%s AND indexname='run_steering_events_source'""", + (self.schema,), + ).fetchone() + self.assertIsNotNone( + index, "alembic head must create the source_event_id index" + ) + steering_closed = conn.execute( + """SELECT 1 FROM information_schema.columns + WHERE table_schema=%s AND table_name='runs' + AND column_name='steering_closed'""", + (self.schema,), + ).fetchone() + self.assertIsNotNone( + steering_closed, "alembic head must create runs.steering_closed" + ) + superseded_by_run_id = conn.execute( + """SELECT 1 FROM information_schema.columns + WHERE table_schema=%s AND table_name='runs' + AND column_name='superseded_by_run_id'""", + (self.schema,), + ).fetchone() + self.assertIsNotNone( + superseded_by_run_id, + "alembic head must create runs.superseded_by_run_id", + ) + head_revision = conn.execute( + "SELECT version_num FROM alembic_version" + ).fetchone() + self.assertEqual( + head_revision[0], + "0005_superseded_by_run_id", + "alembic head must be 0005_superseded_by_run_id, not an earlier " + "revision -- if this fails, the revision chain or the head " + "assertion has drifted apart from the real production head", + ) + finally: + os.environ.pop("LINGXIGRAPH_POSTGRES_SCHEMA", None) + + def test_pause_resume_pause_resume_preserves_root_source_event_id(self) -> None: + """Issue #16 PR #17 review round 3, point 2. + + A -> B (resume) -> C (resume): ``source_event_id`` on C's migrated + steering event must still point at A, not B, and the eventual + ``run.steer.consumed`` event's ``queue_latency_seconds`` must be + computed from A's original acceptance ``created_at``. + """ + + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + from lingxigraph.steering import SteeringConsumption, SteeringEvent + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + + run_a = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + event_a, _ = await repo.submit_steering( + "acme", run_a.id, kind="user_input", payload={"m": 1} + ) + self.assertIsNone(event_a.source_event_id) + await repo.finish_run("acme", run_a.id, "paused", output={}) + + run_b, transferred_b = await repo.resume_run_with_pending_steering( + "acme", + thread.id, + assistant, + RunCreate(assistant_id=assistant.id, resume=1), + run_a.id, + ) + self.assertEqual(len(transferred_b), 1) + event_b = transferred_b[0] + self.assertEqual(event_b.source_event_id, event_a.id) + self.assertEqual(event_b.created_at, event_a.created_at) + await repo.finish_run("acme", run_b.id, "paused", output={}) + + run_c, transferred_c = await repo.resume_run_with_pending_steering( + "acme", + thread.id, + assistant, + RunCreate(assistant_id=assistant.id, resume=1), + run_b.id, + ) + self.assertEqual(len(transferred_c), 1) + event_c = transferred_c[0] + # Root identity A survives, not the intermediate hop B. + self.assertEqual(event_c.source_event_id, event_a.id) + self.assertEqual(event_c.created_at, event_a.created_at) + + steering_event = SteeringEvent( + id=event_c.id, + run_id=run_c.id, + sequence=event_c.sequence, + kind=event_c.kind, + payload=event_c.payload, + metadata=event_c.metadata, + created_at=event_c.created_at, + source_event_id=event_c.source_event_id, + ) + consumption = SteeringConsumption( + event=steering_event, + consumed_at=steering_event.created_at, + node="n", + namespace=(), + task_id="t", + ) + stored = await repo.commit_steering_consumptions("acme", run_c.id, [consumption]) + self.assertEqual(len(stored), 1) + self.assertEqual(stored[0].data["source_event_id"], event_a.id) + + asyncio.run(scenario()) + + def test_commit_steering_consumptions_is_idempotent_on_retry(self) -> None: + """Issue #16 PR #17 review round 3, point 3: a retried commit of + the same consumption batch (simulating a worker that resends after + an ack it never observed) must not produce a second + ``run.steer.consumed`` lifecycle event.""" + + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + from lingxigraph.steering import SteeringConsumption, SteeringEvent + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + event, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + steering_event = SteeringEvent( + id=event.id, + run_id=run.id, + sequence=event.sequence, + kind=event.kind, + payload=event.payload, + metadata=event.metadata, + created_at=event.created_at, + ) + consumption = SteeringConsumption( + event=steering_event, + consumed_at=steering_event.created_at, + node="n", + namespace=(), + task_id="t", + ) + first = await repo.commit_steering_consumptions("acme", run.id, [consumption]) + self.assertEqual(len(first), 1) + second = await repo.commit_steering_consumptions("acme", run.id, [consumption]) + self.assertEqual(second, []) + + all_events = await repo.list_events("acme", run.id) + consumed = [e for e in all_events if e.kind == "run.steer.consumed"] + self.assertEqual(len(consumed), 1) + + asyncio.run(scenario()) + + def test_stale_worker_cannot_commit_steering_consumptions_after_lease_takeover( + self, + ) -> None: + """Issue #16 PR #17 review round 7, point 2 (BLOCKER), PostgreSQL + path: a worker whose lease has been reclaimed by a new owner must + never be able to durably commit a steering consumption -- its + write must be rejected atomically, touching nothing.""" + + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + from lingxigraph.steering import SteeringConsumption, SteeringEvent + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + event, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + + claimed_a = await repo.claim_run("worker-a", lease_seconds=1) + assert claimed_a is not None + self.assertEqual(claimed_a.attempt, 1) + + steering_event = SteeringEvent( + id=event.id, + run_id=run.id, + sequence=event.sequence, + kind=event.kind, + payload=event.payload, + metadata=event.metadata, + created_at=event.created_at, + ) + consumption = SteeringConsumption( + event=steering_event, + consumed_at=steering_event.created_at, + node="n", + namespace=(), + task_id="t", + ) + + # A's lease expires; B claims the same run at a new attempt. + await asyncio.sleep(1.2) + claimed_b = await repo.claim_run("worker-b", lease_seconds=30) + assert claimed_b is not None + self.assertEqual(claimed_b.attempt, 2) + + # A's now-stale commit must be rejected outright. + result = await repo.commit_steering_consumptions_if_owned( + "acme", run.id, "worker-a", claimed_a.attempt, [consumption] + ) + self.assertIsNone(result) + + still_pending = await repo.list_pending_steering("acme", run.id) + self.assertEqual([row.id for row in still_pending], [event.id]) + consumed_events = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.consumed" + ] + self.assertEqual(consumed_events, []) + + # B, the current owner, commits it normally. + stored = await repo.commit_steering_consumptions_if_owned( + "acme", run.id, "worker-b", claimed_b.attempt, [consumption] + ) + self.assertEqual(len(stored), 1) + self.assertEqual(await repo.list_pending_steering("acme", run.id), []) + + asyncio.run(scenario()) + + def test_claim_run_recovers_expired_cancelling_lease_to_cancelled_and_thread_unwedges( + self, + ) -> None: + """Issue #16 PR #17 review round 11 (BLOCKER), PostgreSQL path: + a run stuck in ``cancelling`` whose owning worker crashed before + finalizing (lease expired) must never be permanently stranded -- + ``claim_run`` must resolve it to terminal ``cancelled`` on the + very next claim pass, clear its lease, and -- crucially -- stop + wedging its thread so a separate queued run on the SAME thread + can subsequently be claimed.""" + + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + # A second run queued on the same thread -- it must remain + # blocked while ``run`` is active, and become claimable once + # the stranded ``cancelling`` run is recovered. + queued = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + + accepted, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + self.assertTrue(created) + + claimed = await repo.claim_run("worker-a", lease_seconds=1) + assert claimed is not None + self.assertEqual(claimed.id, run.id) + + cancel_requested = await repo.request_cancel("acme", run.id) + self.assertTrue(cancel_requested) + mid_flight = await repo.get_run("acme", run.id) + self.assertEqual(mid_flight.status, "cancelling") + + closed = await repo.close_steering("acme", run.id, "worker-a", claimed.attempt) + self.assertTrue(closed) + + # Worker A crashes here, never reaching the fenced terminal + # commit. Let the lease expire, then simulate worker B's next + # claim attempt. + await asyncio.sleep(1.2) + + reclaimed = await repo.claim_run("worker-b", lease_seconds=30) + + final = await repo.get_run("acme", run.id) + self.assertEqual(final.status, "cancelled") + self.assertIsNone(final.lease_owner) + self.assertIsNone(final.lease_expires_at) + self.assertIsNotNone(final.finished_at) + assert final.error is not None + self.assertEqual(final.error["code"], "run_cancelled") + + # The queued run on the same thread is no longer wedged -- + # either this very claim call already picked it up, or the + # next one does. + if reclaimed is not None and reclaimed.id == queued.id: + thread_unwedged_run = reclaimed + else: + self.assertIsNone(reclaimed) + thread_unwedged_run = await repo.claim_run("worker-c", lease_seconds=30) + assert thread_unwedged_run is not None + self.assertEqual(thread_unwedged_run.id, queued.id) + self.assertEqual(thread_unwedged_run.status, "running") + + # Ordinary cancel already leaves pending steering as durable + # history (never implicitly consumed/replayed) -- the + # lease-recovery path must behave the same way here. + pending_steering = await repo.list_pending_steering("acme", run.id) + self.assertEqual([row.id for row in pending_steering], [accepted.id]) + + asyncio.run(scenario()) + + def test_cancel_paused_run_terminates_immediately_postgres_path(self) -> None: + """Issue #16 PR #17 review round 12 (BLOCKER), PostgreSQL path: + cancelling a genuinely ``paused`` run (produced by running a real + interrupt graph through the Worker against live Postgres, not a + hand-faked status) must resolve to ``cancelled`` immediately -- + never a lease-less ``cancelling`` that no recovery mechanism can + ever unstick. Also confirms a same-thread queued run is not + permanently wedged by this fix.""" + + from typing import TypedDict + + from fastapi.testclient import TestClient + + from lingxigraph import END, START, StateGraph, interrupt + from lingxigraph.server import GraphRegistry, create_app + from lingxigraph.server.repository import PostgresRepository + from lingxigraph.server.security import Authenticator + + class State(TypedDict): + value: int + + def make_registry() -> GraphRegistry: + paused = StateGraph(State, name="interrupt-test", version="1.0.0") + + def approval(_state): + return {"value": int(interrupt({"question": "new value?"}))} + + paused.add_node("approval", approval) + paused.add_edge(START, "approval") + paused.add_edge("approval", END) + return GraphRegistry({"approval": paused.compile()}) + + def wait_for_status(client, run_id, headers, expected): + value = None + for _ in range(200): + value = client.get(f"/v1/runs/{run_id}", headers=headers) + if value.json()["status"] in expected: + return value + asyncio.run(asyncio.sleep(0.01)) + return value + + repository = PostgresRepository(POSTGRES_URL, schema=self.schema) + asyncio.run(repository.setup()) + app = create_app( + registry=make_registry(), + repository=repository, + authenticator=Authenticator.insecure_dev(), + embedded_worker=True, + ) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", + headers=headers, + json={"graph_id": "approval", "name": "approval"}, + ).json() + thread = client.post("/v1/threads", headers=headers, json={}).json() + paused = client.post( + f"/v1/threads/{thread['id']}/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"value": 0}}, + ).json() + paused = wait_for_status(client, paused["id"], headers, {"paused"}).json() + self.assertEqual(paused["status"], "paused") + + # A second run queued on the same thread -- 'cancelling' counts + # as ACTIVE, so it must not stay permanently wedged by this fix. + queued = client.post( + f"/v1/threads/{thread['id']}/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"value": 1}}, + ).json() + + cancelled = client.post(f"/v1/runs/{paused['id']}/cancel", headers=headers) + self.assertEqual(cancelled.status_code, 200, cancelled.text) + self.assertEqual(cancelled.json()["status"], "cancelled") + self.assertIsNone(cancelled.json()["lease_owner"]) + + final = asyncio.run(repository.get_run("acme", paused["id"])) + self.assertEqual(final.status, "cancelled") + self.assertIsNotNone(final.finished_at) + self.assertIsNone(final.lease_owner) + self.assertIsNone(final.lease_expires_at) + + resume_after_cancel = client.post( + f"/v1/runs/{paused['id']}/resume", + headers=headers, + json={"resume": 7}, + ) + self.assertEqual(resume_after_cancel.status_code, 409) + + # The queued sibling run on the same thread eventually runs to + # completion -- it is not wedged by the now-terminal paused run. + queued_final = wait_for_status( + client, queued["id"], headers, {"paused", "succeeded", "failed"} + ).json() + self.assertIn(queued_final["status"], {"paused", "succeeded"}) + + def test_idempotency_key_replay_safe_across_finalizing_and_terminal_gates(self) -> None: + """Issue #16 PR #17 review round 7, point 3, PostgreSQL path: a + same-Idempotency-Key replay must return the existing event, never + a fresh 409, even after the run has gone finalizing or terminal -- + only a genuinely new key against such a run still gets 409.""" + + from lingxigraph.errors import RunFinalizingError, RunTerminalError + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + + # --- finalizing gate --- + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + original, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1}, idempotency_key="key-1" + ) + self.assertTrue(created) + gate_closed = await repo.close_steering("acme", run.id, "worker-a", claimed.attempt) + self.assertTrue(gate_closed) + + replayed, replayed_created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1}, idempotency_key="key-1" + ) + self.assertFalse(replayed_created) + self.assertEqual(replayed.id, original.id) + self.assertEqual(replayed.sequence, original.sequence) + + with self.assertRaises(RunFinalizingError): + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 2}, idempotency_key="key-2" + ) + + # --- terminal gate --- + terminal_run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + terminal_original, terminal_created = await repo.submit_steering( + "acme", + terminal_run.id, + kind="user_input", + payload={"m": 1}, + idempotency_key="key-3", + ) + self.assertTrue(terminal_created) + await repo.finish_run("acme", terminal_run.id, "succeeded", output={}) + + terminal_replayed, terminal_replayed_created = await repo.submit_steering( + "acme", + terminal_run.id, + kind="user_input", + payload={"m": 1}, + idempotency_key="key-3", + ) + self.assertFalse(terminal_replayed_created) + self.assertEqual(terminal_replayed.id, terminal_original.id) + self.assertEqual(terminal_replayed.sequence, terminal_original.sequence) + + with self.assertRaises(RunTerminalError): + await repo.submit_steering( + "acme", + terminal_run.id, + kind="user_input", + payload={"m": 2}, + idempotency_key="key-4", + ) + + asyncio.run(scenario()) + + def test_supersede_pending_steering_if_owned_postgres_path(self) -> None: + """Issue #16 PR #17 review round 8, point 1 (BLOCKER), PostgreSQL + path: the fenced ``supersede_pending_steering_if_owned`` must + durably transition still-pending steering rows to ``superseded`` + and record a matching ``run.steer.superseded`` lifecycle event, and + must be rejected outright for a worker whose lease has already been + reclaimed by a new owner.""" + + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + event, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + + claimed_a = await repo.claim_run("worker-a", lease_seconds=1) + assert claimed_a is not None + + # A's lease expires; B claims the same run at a new attempt. + await asyncio.sleep(1.2) + claimed_b = await repo.claim_run("worker-b", lease_seconds=30) + assert claimed_b is not None + self.assertEqual(claimed_b.attempt, claimed_a.attempt + 1) + + # A's now-stale attempt must be rejected outright, touching + # nothing. + stale_result = await repo.supersede_pending_steering_if_owned( + "acme", run.id, "worker-a", claimed_a.attempt + ) + self.assertIsNone(stale_result) + self.assertEqual( + [row.id for row in await repo.list_pending_steering("acme", run.id)], + [event.id], + ) + + # B, the current owner, supersedes it normally. + stored = await repo.supersede_pending_steering_if_owned( + "acme", run.id, "worker-b", claimed_b.attempt + ) + self.assertEqual(len(stored), 1) + self.assertEqual(stored[0].kind, "run.steer.superseded") + self.assertEqual(stored[0].data["steering_event_id"], event.id) + self.assertEqual(stored[0].data["reason"], "unconsumed_at_final_boundary") + self.assertIsNone(stored[0].data["superseded_by_run_id"]) + + self.assertEqual(await repo.list_pending_steering("acme", run.id), []) + all_steering = await repo.list_steering("acme", run.id) + self.assertEqual(all_steering[0].status, "superseded") + + # Idempotent under retry: calling again must not duplicate the + # lifecycle event (nothing left pending to supersede either). + second = await repo.supersede_pending_steering_if_owned( + "acme", run.id, "worker-b", claimed_b.attempt + ) + self.assertEqual(second, []) + superseded_events = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.superseded" + ] + self.assertEqual(len(superseded_events), 1) + + asyncio.run(scenario()) + + def test_finalize_run_with_steering_disposition_if_owned_postgres_path(self) -> None: + """Issue #16 PR #17 review round 9, point 2 (BLOCKER), PostgreSQL + path: the merged ``finalize_run_with_steering_disposition_if_owned`` + must durably supersede leftover steering AND commit the run's + terminal status together, and must be rejected outright for a + worker whose lease has already been reclaimed by a new owner.""" + + from lingxigraph.server.models import ( + AssistantCreate, + RunCreate, + RunStatus, + ThreadCreate, + ) + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + event, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + + claimed_a = await repo.claim_run("worker-a", lease_seconds=1) + assert claimed_a is not None + + # A's lease expires; B claims the same run at a new attempt. + await asyncio.sleep(1.2) + claimed_b = await repo.claim_run("worker-b", lease_seconds=30) + assert claimed_b is not None + self.assertEqual(claimed_b.attempt, claimed_a.attempt + 1) + + # A's now-stale attempt must be rejected outright, touching + # neither the steering row nor the run's status. + stale_result = await repo.finalize_run_with_steering_disposition_if_owned( + "acme", run.id, "worker-a", claimed_a.attempt, RunStatus.SUCCEEDED, output={} + ) + self.assertIsNone(stale_result) + self.assertEqual( + [row.id for row in await repo.list_pending_steering("acme", run.id)], + [event.id], + ) + still_running = await repo.get_run("acme", run.id) + assert still_running is not None + self.assertEqual(still_running.status, "running") + + # B, the current owner, finalizes normally: steering disposition + # and terminal status commit together. + result = await repo.finalize_run_with_steering_disposition_if_owned( + "acme", run.id, "worker-b", claimed_b.attempt, RunStatus.SUCCEEDED, output={} + ) + assert result is not None + updated, superseded = result + self.assertEqual(updated.status, "succeeded") + self.assertEqual(len(superseded), 1) + self.assertEqual(superseded[0].data["steering_event_id"], event.id) + self.assertEqual(superseded[0].data["reason"], "unconsumed_at_final_boundary") + self.assertEqual(superseded[0].data["sequence"], event.sequence) + self.assertEqual(superseded[0].data["kind"], event.kind) + + final = await repo.get_run("acme", run.id) + assert final is not None + self.assertEqual(final.status, "succeeded") + self.assertEqual(await repo.list_pending_steering("acme", run.id), []) + all_steering = await repo.list_steering("acme", run.id) + self.assertEqual(all_steering[0].status, "superseded") + + asyncio.run(scenario()) + + def test_finalize_run_with_steering_disposition_rolls_back_atomically_on_failure( + self, + ) -> None: + """Issue #16 PR #17 review round 9, point 2 (BLOCKER) -- the real + Postgres rollback/takeover regression the review explicitly asked + for: deliberately fail partway through the merged transaction + (after the steering row has been superseded in-transaction, but + before the run's terminal UPDATE and the transaction's COMMIT) and + assert the WHOLE transaction rolls back -- the steering row is + still pending/delivered and recoverable by the next owner, and the + run's status is untouched. This must run against a real PostgreSQL + connection to prove actual transactional atomicity; an + InMemoryRepository ordering test cannot demonstrate this.""" + + from lingxigraph.server.models import ( + AssistantCreate, + RunCreate, + RunStatus, + ThreadCreate, + ) + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + event, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + class _InjectedFailure(Exception): + pass + + real_ensure = repo._ensure_steer_superseded_event_sync + + def failing_ensure(cursor, tenant_id, run_id, steering_event, **kwargs): + # Let the steering row's own UPDATE (to 'superseded') and + # the superseded-event insert happen first -- exactly what + # a real "fails right before the run's terminal UPDATE and + # COMMIT" crash would leave in-flight -- then blow up + # before the surrounding ``with self._connect()`` block + # ever reaches the run status UPDATE or exits normally. + real_ensure(cursor, tenant_id, run_id, steering_event, **kwargs) + raise _InjectedFailure("simulated crash before terminal status commit") + + repo._ensure_steer_superseded_event_sync = failing_ensure # type: ignore[method-assign] + + with self.assertRaises(_InjectedFailure): + await repo.finalize_run_with_steering_disposition_if_owned( + "acme", run.id, "worker-a", claimed.attempt, RunStatus.SUCCEEDED, output={} + ) + + repo._ensure_steer_superseded_event_sync = real_ensure # type: ignore[method-assign] + + # Everything must have rolled back together: the run is still + # running (not succeeded), the steering row is still + # pending/delivered (never left stranded as superseded), and no + # run.steer.superseded event was recorded. + still_running = await repo.get_run("acme", run.id) + assert still_running is not None + self.assertEqual(still_running.status, "running") + self.assertEqual( + [row.id for row in await repo.list_pending_steering("acme", run.id)], + [event.id], + ) + superseded_events = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.superseded" + ] + self.assertEqual(superseded_events, []) + + # A subsequent owner (the same worker, still holding its lease + # here) can still finalize normally and recover the row -- it + # was never lost. + result = await repo.finalize_run_with_steering_disposition_if_owned( + "acme", run.id, "worker-a", claimed.attempt, RunStatus.SUCCEEDED, output={} + ) + assert result is not None + updated, superseded = result + self.assertEqual(updated.status, "succeeded") + self.assertEqual(len(superseded), 1) + + asyncio.run(scenario()) + + def test_retry_with_event_if_owned_postgres_path(self) -> None: + """Issue #16 PR #17 review round 10, point 1 (BLOCKER), PostgreSQL + path: the merged ``retry_run_with_event_if_owned`` must return the + transaction's own resulting snapshot directly (no separate later + ``get_run()`` read), reject a stale attempt outright, and -- the + review's race B -- durably guarantee that ``worker_retrying``'s + RunEvent sequence is strictly earlier than any event a subsequent + owner's next attempt appends, because both writes are the same + real SQL transaction.""" + + from lingxigraph.server.models import ( + AssistantCreate, + RunCreate, + ThreadCreate, + ) + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + claimed_a = await repo.claim_run("worker-a", lease_seconds=1) + assert claimed_a is not None + + # A's lease expires; B claims the same run at a new attempt. + await asyncio.sleep(1.2) + claimed_b = await repo.claim_run("worker-b", lease_seconds=30) + assert claimed_b is not None + self.assertEqual(claimed_b.attempt, claimed_a.attempt + 1) + + # A's now-stale attempt must be rejected outright. + stale_result = await repo.retry_run_with_event_if_owned( + "acme", + run.id, + "worker-a", + claimed_a.attempt, + error={"code": "delivery_retry", "message": "transient"}, + max_attempts=5, + ) + self.assertIsNone(stale_result) + still_running = await repo.get_run("acme", run.id) + assert still_running is not None + self.assertEqual(still_running.status, "running") + + # B retries normally: status transition and worker_retrying + # event commit together, and the returned Run is the + # transaction's own snapshot. + result = await repo.retry_run_with_event_if_owned( + "acme", + run.id, + "worker-b", + claimed_b.attempt, + error={"code": "delivery_retry", "message": "transient"}, + max_attempts=5, + ) + assert result is not None + updated, event = result + self.assertEqual(updated.status, "pending") + assert event is not None + self.assertEqual(event.kind, "worker_retrying") + + final = await repo.get_run("acme", run.id) + assert final is not None + self.assertEqual(final.status, "pending") + + # A third worker claims the retried run (attempt N+1 relative + # to B's retry) and appends its own execution event -- proving + # the earlier worker_retrying event's sequence is strictly + # earlier, i.e. the durable event stream's causal order is + # preserved even across a real Postgres transaction boundary. + claimed_c = await repo.claim_run("worker-c", lease_seconds=30) + assert claimed_c is not None + next_event = await repo.append_event( + "acme", run.id, "node_started", {"attempt": claimed_c.attempt} + ) + self.assertLess(event.sequence, next_event.sequence) + + asyncio.run(scenario()) + + def test_resume_transfer_emits_superseded_event_idempotently_postgres_path(self) -> None: + """Issue #16 PR #17 review round 8, point 3 (BLOCKER), PostgreSQL + path: the paused-run resume-transfer migration must record exactly + one ``run.steer.superseded`` event per transferred steering row, in + the same transaction as the status transition.""" + + from lingxigraph.server.models import AssistantCreate, RunCreate, ThreadCreate + from lingxigraph.server.repository import PostgresRepository + + async def scenario() -> None: + repo = PostgresRepository(POSTGRES_URL, schema=self.schema) + await repo.setup() + assistant = await repo.create_assistant( + "acme", AssistantCreate(graph_id="graph", name="a"), "1.0.0" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + old_run = await repo.create_run( + "acme", thread.id, assistant, RunCreate(assistant_id=assistant.id, input={}) + ) + original, _ = await repo.submit_steering( + "acme", old_run.id, kind="user_input", payload={"m": 1} + ) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + new_run, transferred = await repo.resume_run_with_pending_steering( + "acme", + thread.id, + assistant, + RunCreate(assistant_id=assistant.id), + old_run.id, + ) + self.assertEqual(len(transferred), 1) + + old_events = await repo.list_events("acme", old_run.id) + superseded_events = [e for e in old_events if e.kind == "run.steer.superseded"] + self.assertEqual(len(superseded_events), 1) + self.assertEqual(superseded_events[0].data["steering_event_id"], original.id) + self.assertEqual(superseded_events[0].data["reason"], "resume_transfer") + self.assertEqual(superseded_events[0].data["superseded_by_run_id"], new_run.id) + self.assertEqual( + superseded_events[0].data["replacement_steering_event_id"], + transferred[0].id, + ) + + old_steering = await repo.list_steering("acme", old_run.id) + self.assertEqual(old_steering[0].status, "superseded") + + asyncio.run(scenario()) + @unittest.skipUnless(REDIS_URL, "Redis integration URL not configured") def test_redis_cache_pubsub_and_recovery_contract(self) -> None: from lingxigraph.cache_redis import RedisCache @@ -156,6 +1538,44 @@ async def scenario() -> None: asyncio.run(scenario()) + @unittest.skipUnless(REDIS_URL, "Redis integration URL not configured") + def test_redis_event_bus_wait_returns_promptly_when_heartbeat_is_cancelled( + self, + ) -> None: + """Issue #16 PR #17 review round 6, point 4 (REQUIRED consistency + fix): round 5 rewrote ``InMemoryEventBus.wait()`` away from + ``asyncio.wait_for(...)`` because cancelling the *caller* while it + is in-flight can hang forever on Python 3.11 -- but the production + ``RedisEventBus.wait()`` still used that exact pattern, and + ``Worker._heartbeat`` cancels a Redis-backed heartbeat exactly the + same way as an in-memory one. Reproduce that cancellation directly + against ``RedisEventBus.wait()`` (mirroring how ``Worker._execute`` + does ``heartbeat.cancel(); await asyncio.gather(heartbeat, ...)``) + and assert it unblocks promptly instead of hanging. + """ + + from lingxigraph.server.eventbus import RedisEventBus + + async def scenario() -> None: + prefix = "lingxigraph-test-" + uuid4().hex + bus = RedisEventBus(REDIS_URL, prefix=prefix) + try: + # A long timeout: if cancellation does not propagate + # promptly, this task hangs for (approximately) this long + # instead of returning almost immediately below. + waiter = asyncio.create_task(bus.wait("tenant", "run", timeout=30)) + await asyncio.sleep(0.1) + waiter.cancel() + start = asyncio.get_event_loop().time() + with self.assertRaises(asyncio.CancelledError): + await asyncio.wait_for(waiter, timeout=2) + elapsed = asyncio.get_event_loop().time() - start + self.assertLess(elapsed, 2.0) + finally: + await bus.close() + + asyncio.run(scenario()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_runtime_edge_cases.py b/tests/test_runtime_edge_cases.py new file mode 100644 index 0000000..393411e --- /dev/null +++ b/tests/test_runtime_edge_cases.py @@ -0,0 +1,98 @@ +"""Direct unit tests for lingxigraph.runtime.Runtime edge cases. + +These exercise Runtime as a plain dataclass -- no graph execution needed -- +targeting branches that graph-execution tests don't happen to hit (deadline +expiry, cancellation-token raise, empty steering channel, legacy stream +writer misuse, budget-less consume_* no-ops, get_config outside a node). +""" + +import unittest +from datetime import UTC, datetime, timedelta + +from lingxigraph.errors import GraphCancelledError, GraphTimeoutError +from lingxigraph.runtime import CancellationToken, Runtime, get_config, get_store + + +class CancellationTokenTests(unittest.TestCase): + def test_raise_if_cancelled_raises_after_cancel(self) -> None: + token = CancellationToken() + self.assertFalse(token.cancelled) + token.cancel() + self.assertTrue(token.cancelled) + with self.assertRaises(GraphCancelledError): + token.raise_if_cancelled() + + +class RuntimeEdgeCaseTests(unittest.TestCase): + def _runtime(self, **overrides: object) -> Runtime: + defaults: dict[str, object] = {"context": None, "config": {}} + defaults.update(overrides) + return Runtime(**defaults) # type: ignore[arg-type] + + def test_peek_and_drain_steering_are_empty_without_a_channel(self) -> None: + runtime = self._runtime() + self.assertEqual(runtime.peek_steering(), ()) + self.assertEqual(runtime.drain_steering(), ()) + self.assertFalse(runtime.has_steering) + + def test_raise_if_cancelled_raises_on_expired_deadline(self) -> None: + runtime = self._runtime(deadline=datetime.now(UTC) - timedelta(seconds=1)) + with self.assertRaises(GraphTimeoutError): + runtime.raise_if_cancelled() + + def test_raise_if_cancelled_delegates_to_cancellation_token(self) -> None: + token = CancellationToken() + token.cancel() + runtime = self._runtime(cancellation=token) + with self.assertRaises(GraphCancelledError): + runtime.raise_if_cancelled() + + def test_raise_if_cancelled_is_a_no_op_when_nothing_set(self) -> None: + runtime = self._runtime() + runtime.raise_if_cancelled() # must not raise + + def test_emit_rejects_empty_or_non_string_channel(self) -> None: + runtime = self._runtime() + with self.assertRaises(ValueError): + runtime.emit("", 1) + with self.assertRaises(ValueError): + runtime.emit(None, 1) # type: ignore[arg-type] + + def test_emit_without_a_sink_is_a_silent_no_op(self) -> None: + runtime = self._runtime() + runtime.emit("progress", {"a": 1}) # must not raise, no emitter attached + + def test_stream_writer_legacy_two_arg_form_requires_string_channel(self) -> None: + runtime = self._runtime() + writer = runtime.stream_writer + with self.assertRaises(TypeError): + writer(123, "extra", "too-many") # more than one legacy arg + with self.assertRaises(TypeError): + writer(123, "channel") # value must be the channel string in legacy form + + def test_stream_writer_legacy_form_emits_when_sink_present(self) -> None: + captured: list[tuple[str, object]] = [] + runtime = self._runtime(_emit=lambda channel, value: captured.append((channel, value))) + writer = runtime.stream_writer + writer("hello", "custom-channel") + self.assertEqual(captured, [("hello", "custom-channel")]) + + def test_consume_helpers_are_no_ops_without_a_budget(self) -> None: + runtime = self._runtime() + runtime.consume_tool_call("some_tool") + runtime.consume_model_usage({"tokens": 5}) + runtime.consume_model_call() # none of these should raise + + +class ContextAccessorTests(unittest.TestCase): + def test_get_config_outside_a_node_raises(self) -> None: + with self.assertRaises(RuntimeError): + get_config() + + def test_get_store_outside_a_node_raises(self) -> None: + with self.assertRaises(RuntimeError): + get_store() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 3afeb13..9c0ef1e 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -72,6 +72,7 @@ def test_all_resource_facades_and_sse(self) -> None: client.runs.get("run") client.runs.list("thread") client.runs.cancel("run") + client.runs.steer("run", payload={"message": "hi"}, idempotency_key="msg-1") client.runs.resume("run", True, goto="next") self.assertEqual(client.runs.join("run", poll_interval=0)["status"], "succeeded") self.assertEqual(list(client.runs.stream("run", after=2))[0]["sequence"], 3) @@ -129,6 +130,7 @@ async def scenario() -> None: await client.runs.get("run") await client.runs.list("thread") await client.runs.cancel("run") + await client.runs.steer("run", payload={"message": "hi"}, idempotency_key="msg-1") await client.runs.resume("run", True) self.assertEqual((await client.runs.join("run", poll_interval=0))["status"], "succeeded") events = [event async for event in client.runs.stream("run")] diff --git a/tests/test_steering.py b/tests/test_steering.py new file mode 100644 index 0000000..c94a347 --- /dev/null +++ b/tests/test_steering.py @@ -0,0 +1,3674 @@ +"""Tests for durable mid-run steering (issue #16).""" + +from __future__ import annotations + +import asyncio +import operator +import time +import unittest +from typing import Annotated, Any, TypedDict + +from fastapi.testclient import TestClient + +from lingxigraph import END, START, RetryPolicy, Runtime, Send, StateGraph +from lingxigraph.errors import RunResumeConflictError, RunSupersededError, RunTerminalError +from lingxigraph.server import GraphRegistry, create_app +from lingxigraph.server.models import ThreadCreate +from lingxigraph.server.repository import InMemoryRepository +from lingxigraph.server.security import Authenticator +from lingxigraph.steering import ( + MAX_STEERING_PAYLOAD_BYTES, + SteeringChannel, + SteeringConsumption, + SteeringPayloadTooLarge, + validate_steering_payload, +) +from lingxigraph.types import RunStatus + + +class State(TypedDict, total=False): + ticks: int + drained: list[str] + + +def wait_for_status(client, run_id, headers, expected, attempts: int = 200): + value = None + for _ in range(attempts): + value = client.get(f"/v1/runs/{run_id}", headers=headers) + if value.json()["status"] in expected: + return value + time.sleep(0.01) + return value + + +class SteeringChannelTests(unittest.TestCase): + """Core steering primitive: ordering, dedup, drain-once semantics.""" + + def test_ordering_and_drain_once(self) -> None: + channel = SteeringChannel("run-1") + for index in range(3): + channel.submit(kind="user_input", payload={"i": index}) + self.assertTrue(channel.has_pending) + drained = channel.drain() + self.assertEqual([event.payload["i"] for event in drained], [0, 1, 2]) + self.assertEqual([event.sequence for event in drained], [1, 2, 3]) + # Draining is one-shot: nothing left to drain again. + self.assertFalse(channel.has_pending) + self.assertEqual(channel.drain(), ()) + + def test_idempotency_key_dedup(self) -> None: + channel = SteeringChannel("run-1") + first, created_first = channel.submit( + kind="user_input", payload={"message": "a"}, idempotency_key="msg-1" + ) + second, created_second = channel.submit( + kind="user_input", + payload={"message": "duplicate-should-be-ignored"}, + idempotency_key="msg-1", + ) + self.assertTrue(created_first) + self.assertFalse(created_second) + self.assertEqual(first.id, second.id) + self.assertEqual(len(channel.peek()), 1) + + def test_dedup_survives_after_drain(self) -> None: + channel = SteeringChannel("run-1") + channel.submit(kind="user_input", payload={}, idempotency_key="msg-1") + channel.drain() + _, created = channel.submit(kind="user_input", payload={}, idempotency_key="msg-1") + self.assertFalse(created) + + def test_payload_size_cap(self) -> None: + huge = {"message": "x" * (MAX_STEERING_PAYLOAD_BYTES + 1)} + with self.assertRaises(SteeringPayloadTooLarge): + validate_steering_payload(huge) + + def test_ingest_preserves_durable_sequence(self) -> None: + channel = SteeringChannel("run-1") + from datetime import UTC, datetime + + from lingxigraph.steering import SteeringEvent + + channel.ingest( + SteeringEvent( + id="db-1", + run_id="run-1", + sequence=5, + kind="user_input", + payload={}, + metadata={}, + created_at=datetime.now(UTC), + ) + ) + self.assertTrue(channel.has_pending) + # A locally-submitted event after ingest gets a higher sequence. + event, _ = channel.submit(kind="user_input", payload={}) + self.assertGreater(event.sequence, 5) + + +class RuntimeDrainTests(unittest.TestCase): + """Runtime.has_steering / drain_steering / peek_steering (embedded mode).""" + + def test_embedded_graph_steer_drain_and_ordering(self) -> None: + seen: list[list[str]] = [] + + def node(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + events = runtime.drain_steering() + seen.append([event.payload["message"] for event in events]) + return {"ticks": state.get("ticks", 0) + 1} + + builder = StateGraph(State) + builder.add_node("node", node) + builder.add_edge(START, "node").add_edge("node", END) + graph = builder.compile() + + # Submit three steers in a row *before* the run starts -- they + # should all be visible, in order, at the very first safe point. + run_id = "embedded-run-1" + graph.steer(run_id, kind="user_input", payload={"message": "a"}) + graph.steer(run_id, kind="user_input", payload={"message": "b"}) + graph.steer(run_id, kind="user_input", payload={"message": "c"}) + self.assertTrue(graph.has_pending_steering(run_id)) + + graph.invoke({"ticks": 0}, {"configurable": {}}, run_id=run_id) + self.assertEqual(seen, [["a", "b", "c"]]) + self.assertFalse(graph.has_pending_steering(run_id)) + + def test_no_channel_is_a_safe_no_op(self) -> None: + def node(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + self_check = runtime.drain_steering() + assert self_check == () + assert runtime.has_steering is False + assert runtime.steering_pending is False + return {"ticks": 1} + + builder = StateGraph(State) + builder.add_node("node", node) + builder.add_edge(START, "node").add_edge("node", END) + result = builder.compile().invoke({"ticks": 0}, {"configurable": {}}) + self.assertEqual(result["ticks"], 1) + + +class SteeringConsumptionTests(unittest.TestCase): + """SteeringChannel.drain()'s observability record (issue #16 review point 4): + consuming node/namespace/task_id and queue latency, not just id/sequence/kind.""" + + def test_drain_records_consumer_location_and_latency(self) -> None: + channel = SteeringChannel("run-x") + channel.submit(kind="user_input", payload={"i": 1}) + time.sleep(0.01) + drained = channel.drain(node="worker", namespace=("team",), task_id="worker#0") + self.assertEqual(len(drained), 1) + consumed = channel.pop_consumed() + self.assertEqual(len(consumed), 1) + record = consumed[0] + self.assertIsInstance(record, SteeringConsumption) + self.assertEqual(record.event.id, drained[0].id) + self.assertEqual(record.node, "worker") + self.assertEqual(record.namespace, ("team",)) + self.assertEqual(record.task_id, "worker#0") + self.assertGreater(record.queue_latency_seconds, 0) + + def test_drain_without_consumer_context_defaults_to_none(self) -> None: + channel = SteeringChannel("run-x") + channel.submit(kind="user_input", payload={}) + channel.drain() + record = channel.pop_consumed()[0] + self.assertIsNone(record.node) + self.assertEqual(record.namespace, ()) + self.assertIsNone(record.task_id) + self.assertGreaterEqual(record.queue_latency_seconds, 0) + + def test_runtime_drain_steering_reports_the_calling_node(self) -> None: + """``Runtime.drain_steering()`` (the real node-facing API) must + thread the node/namespace/task_id through to the consumption + record automatically -- nodes never pass this by hand.""" + + captured: list[SteeringConsumption] = [] + + def node(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + runtime.drain_steering() + return {"ticks": 1} + + builder = StateGraph(State) + builder.add_node("node", node) + builder.add_edge(START, "node").add_edge("node", END) + graph = builder.compile() + run_id = "consumption-node-run" + graph.steer(run_id, kind="user_input", payload={}) + channel = graph.get_steering_channel(run_id) + graph.invoke({"ticks": 0}, {"configurable": {}}, run_id=run_id) + captured.extend(channel.pop_consumed()) + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0].node, "node") + self.assertEqual(captured[0].namespace, ()) + + +class EmbeddedSteeringLifecycleTests(unittest.TestCase): + """Issue #16 review point 2: embedded/library-mode steering channels + must not accumulate one entry per run forever on a long-lived compiled + graph, while a paused run's channel must still survive until resume + actually completes.""" + + def test_plain_invoke_never_using_steering_does_not_leak(self) -> None: + builder = StateGraph(State) + builder.add_node("node", lambda state: {"ticks": state.get("ticks", 0) + 1}) + builder.add_edge(START, "node").add_edge("node", END) + graph = builder.compile() + for _ in range(200): + graph.invoke({"ticks": 0}, {"configurable": {}}) + self.assertEqual(len(graph._run_steering), 0) + + def test_steer_before_invoke_still_releases_after_the_run_completes(self) -> None: + builder = StateGraph(State) + + def node(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + runtime.drain_steering() + return {"ticks": state.get("ticks", 0) + 1} + + builder.add_node("node", node) + builder.add_edge(START, "node").add_edge("node", END) + graph = builder.compile() + for index in range(50): + run_id = f"leak-check-{index}" + graph.steer(run_id, kind="user_input", payload={"i": index}) + graph.invoke({"ticks": 0}, {"configurable": {}}, run_id=run_id) + self.assertEqual(len(graph._run_steering), 0) + + def test_paused_run_channel_survives_pause_and_releases_after_resume(self) -> None: + from lingxigraph import Command, interrupt + from lingxigraph.checkpoint import InMemorySaver + + def node(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + value = interrupt("pause") + drained = [event.payload["m"] for event in runtime.drain_steering()] + return {"ticks": int(value), "drained": drained} + + builder = StateGraph(State) + builder.add_node("node", node) + builder.add_edge(START, "node").add_edge("node", END) + graph = builder.compile(checkpointer=InMemorySaver()) + config = {"configurable": {"thread_id": "leak-thread"}} + run_id = "leak-pause-resume" + + graph.invoke({"ticks": 0}, config, run_id=run_id) + # Paused: the channel must stay alive for a future resume/steer. + self.assertEqual(len(graph._run_steering), 1) + + graph.steer(run_id, kind="user_input", payload={"m": "hi"}) + result = graph.invoke(Command(resume="9"), config, run_id=run_id) + self.assertEqual(result["drained"], ["hi"]) + # The run truly finished (no further interrupt) -- released. + self.assertEqual(len(graph._run_steering), 0) + + +class ParallelAndSubgraphSteeringTests(unittest.TestCase): + """Issue #16 review point 3: node retry, parallel/superstep fan-out, and + subgraph namespace sharing were called out as required but missing.""" + + def test_retry_does_not_lose_steering_submitted_before_the_successful_attempt( + self, + ) -> None: + """A transient failure *before* draining must not swallow the event + -- it has to still be there for the attempt that actually succeeds.""" + + attempts: list[int] = [] + seen: list[list[str]] = [] + + def flaky(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + attempts.append(1) + if len(attempts) < 2: + raise ConnectionError("transient, before draining anything") + drained = [event.payload["m"] for event in runtime.drain_steering()] + seen.append(drained) + return {"ticks": state.get("ticks", 0) + 1} + + builder = StateGraph(State) + builder.add_node( + "flaky", flaky, retry=RetryPolicy(max_attempts=3, initial_interval=0, jitter=False) + ) + builder.add_edge(START, "flaky").add_edge("flaky", END) + graph = builder.compile() + run_id = "retry-not-swallowed" + graph.steer(run_id, kind="user_input", payload={"m": "hello"}) + graph.invoke({"ticks": 0}, {"configurable": {}}, run_id=run_id) + self.assertEqual(len(attempts), 2) + self.assertEqual(seen, [["hello"]]) + + def test_retry_does_not_re_expose_an_event_already_drained_by_a_failed_attempt( + self, + ) -> None: + """Documented scope cut (see ``SteeringChannel.pop_consumed``): + drain-once semantics apply per *channel read*, not per node + *attempt*. If an attempt drains an event and then fails, that + event is gone from the channel for good -- a retry must not see it + a second time (no duplicate exposure), even though the attempt + that originally read it never got to finish using it.""" + + attempts: list[int] = [] + seen: list[list[str]] = [] + + def flaky(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + attempts.append(1) + drained = [event.payload["m"] for event in runtime.drain_steering()] + seen.append(drained) + if len(attempts) < 2: + raise ConnectionError("transient, after already draining") + return {"ticks": state.get("ticks", 0) + 1} + + builder = StateGraph(State) + builder.add_node( + "flaky", flaky, retry=RetryPolicy(max_attempts=3, initial_interval=0, jitter=False) + ) + builder.add_edge(START, "flaky").add_edge("flaky", END) + graph = builder.compile() + run_id = "retry-no-duplicate" + graph.steer(run_id, kind="user_input", payload={"m": "once"}) + graph.invoke({"ticks": 0}, {"configurable": {}}, run_id=run_id) + self.assertEqual(len(attempts), 2) + self.assertEqual(seen, [["once"], []]) + + def test_parallel_send_fanout_drain_is_mutually_exclusive_and_ordered(self) -> None: + """Multiple nodes in the same superstep race to call + ``drain_steering()`` concurrently -- the channel's lock makes that + atomic: exactly one task observes the full, correctly ordered + batch and every other concurrent task observes nothing left.""" + + class ParallelState(TypedDict, total=False): + items: list[int] + drained_by_task: Annotated[list[tuple[int, tuple[str, ...]]], operator.add] + + async def worker(payload: dict[str, Any], runtime: Runtime[Any]) -> dict[str, Any]: + events = runtime.drain_steering() + return { + "drained_by_task": [ + (payload["idx"], tuple(event.payload["m"] for event in events)) + ] + } + + builder = StateGraph(ParallelState) + builder.add_node("plan", lambda state: {}) + builder.add_node("worker", worker) + builder.add_edge(START, "plan") + builder.add_conditional_edges( + "plan", + lambda state: [Send("worker", {"idx": item}) for item in state["items"]], + ) + builder.add_edge("worker", END) + graph = builder.compile() + run_id = "parallel-fanout-run" + graph.steer(run_id, kind="user_input", payload={"m": "a"}) + graph.steer(run_id, kind="user_input", payload={"m": "b"}) + result = graph.invoke({"items": [0, 1, 2, 3]}, {"configurable": {}}, run_id=run_id) + + non_empty = [batch for _, batch in result["drained_by_task"] if batch] + self.assertEqual(len(non_empty), 1, result["drained_by_task"]) + self.assertEqual(non_empty[0], ("a", "b")) + total_events = sum(len(batch) for _, batch in result["drained_by_task"]) + self.assertEqual(total_events, 2) + self.assertFalse(graph.has_pending_steering(run_id)) + + def test_subgraph_node_shares_the_parent_runs_steering_channel(self) -> None: + """A subgraph node's ``runtime.drain_steering()`` must observe (and + durably consume) the exact same per-run channel as the parent -- + steering is scoped per top-level run, not per namespace.""" + + class ChildState(TypedDict, total=False): + messages: Annotated[list[str], operator.add] + + class ParentState(TypedDict, total=False): + messages: Annotated[list[str], operator.add] + + def child_node(state: ChildState, runtime: Runtime[Any]) -> dict[str, Any]: + drained = [event.payload["m"] for event in runtime.drain_steering()] + self_namespace = runtime.namespace + return {"messages": [f"child-drained:{drained}:{self_namespace}"]} + + child = StateGraph(ChildState) + child.add_node("work", child_node) + child.add_edge(START, "work").add_edge("work", END) + + parent = StateGraph(ParentState) + parent.add_node("team", child.compile()) + parent.add_edge(START, "team").add_edge("team", END) + graph = parent.compile() + run_id = "subgraph-shared-channel" + graph.steer(run_id, kind="user_input", payload={"m": "x"}) + self.assertTrue(graph.has_pending_steering(run_id)) + + result = graph.invoke({"messages": []}, {"configurable": {}}, run_id=run_id) + self.assertEqual(result["messages"], ["child-drained:['x']:('team',)"]) + self.assertFalse(graph.has_pending_steering(run_id)) + # Top-level embedded run finished cleanly -- no leaked channel. + self.assertEqual(len(graph._run_steering), 0) + + +class RepositorySteeringTests(unittest.TestCase): + """Durable-inbox semantics against InMemoryRepository (Postgres-shaped API).""" + + def test_ordering_dedup_and_consumption(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + + e1, c1 = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"i": 1} + ) + e2, c2 = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"i": 2} + ) + e3, c3 = await repo.submit_steering( + "acme", + run.id, + kind="user_input", + payload={"i": 2}, + idempotency_key="dup", + ) + e3b, c3b = await repo.submit_steering( + "acme", + run.id, + kind="user_input", + payload={"different": True}, + idempotency_key="dup", + ) + self.assertTrue(c1 and c2 and c3) + self.assertFalse(c3b) + self.assertEqual(e3.id, e3b.id) + + pending = await repo.list_pending_steering("acme", run.id) + self.assertEqual([event.sequence for event in pending], [1, 2, 3]) + + await repo.mark_steering_consumed("acme", run.id, [e1.id, e2.id]) + pending_after = await repo.list_pending_steering("acme", run.id) + self.assertEqual([event.id for event in pending_after], [e3.id]) + all_events = await repo.list_steering("acme", run.id) + self.assertEqual( + {event.id: event.status for event in all_events}, + {e1.id: "consumed", e2.id: "consumed", e3.id: "pending"}, + ) + + asyncio.run(scenario()) + + def test_terminal_run_rejects_steer(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + await repo.finish_run("acme", run.id, "succeeded", output={}) + with self.assertRaises(RunTerminalError): + await repo.submit_steering("acme", run.id, kind="user_input", payload={}) + + asyncio.run(scenario()) + + def test_tenant_isolation(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + await repo.submit_steering("acme", run.id, kind="user_input", payload={}) + other_tenant_view = await repo.list_pending_steering("other-tenant", run.id) + self.assertEqual(other_tenant_view, []) + + asyncio.run(scenario()) + + +class AtomicResumeMigrationTests(unittest.TestCase): + """Issue #16 PR #17 review point 1: resume-run creation and its steering + migration must be a single atomic operation -- a worker must never be + able to claim (let alone finish) the resumed Run before the steering + that was pending on the paused run has already been migrated onto it.""" + + def test_claim_run_never_observes_the_resumed_run_before_steering_migrated( + self, + ) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + old_run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + await repo.submit_steering("acme", old_run.id, kind="user_input", payload={"m": 1}) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + claims: list[str] = [] + stop = asyncio.Event() + + async def hammer_claim() -> None: + # Simulates a worker's poll loop racing the resume call -- + # before the fix, ``create_run`` committed the new Run as + # ``pending`` (claimable) *before* the separate + # ``transfer_pending_steering`` call ran, so this could + # observe (and even finish) the new Run with nothing + # migrated onto it yet. + while not stop.is_set(): + claimed = await repo.claim_run("worker-a", lease_seconds=30) + if claimed is not None: + claims.append(claimed.id) + if claimed.id != old_run.id: + pending = await repo.list_pending_steering("acme", claimed.id) + self.assertEqual( + len(pending), + 1, + "resumed run was claimable before its steering migrated", + ) + await asyncio.sleep(0) + + hammer = asyncio.create_task(hammer_claim()) + from lingxigraph.server.models import RunCreate + + request = RunCreate( + assistant_id=assistant.id, + resume=1, + metadata={"resumed_from_run_id": old_run.id}, + ) + new_run, transferred = await repo.resume_run_with_pending_steering( + "acme", old_run.thread_id, assistant, request, old_run.id + ) + self.assertEqual(len(transferred), 1) + for _ in range(500): + if new_run.id in claims: + break + await asyncio.sleep(0.001) + stop.set() + await hammer + self.assertIn(new_run.id, claims) + + asyncio.run(scenario()) + + def test_concurrent_resume_of_the_same_paused_run_only_succeeds_once(self) -> None: + """Issue #16 PR #17 review round 4, point 1: two concurrent + ``resume_run_with_pending_steering`` calls against the same paused + run must not both create a descendant Run -- exactly one must + succeed and ``superseded_by_run_id`` must be stable (never + overwritten by the loser).""" + + from lingxigraph.server.models import RunCreate + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + old_run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + request = RunCreate(assistant_id=assistant.id, resume=1) + results = await asyncio.gather( + repo.resume_run_with_pending_steering( + "acme", old_run.thread_id, assistant, request, old_run.id + ), + repo.resume_run_with_pending_steering( + "acme", old_run.thread_id, assistant, request, old_run.id + ), + return_exceptions=True, + ) + successes = [r for r in results if not isinstance(r, BaseException)] + failures = [r for r in results if isinstance(r, BaseException)] + self.assertEqual(len(successes), 1) + self.assertEqual(len(failures), 1) + self.assertIsInstance(failures[0], RunResumeConflictError) + + winner_run, _ = successes[0] + final_old = await repo.get_run("acme", old_run.id) + self.assertEqual(final_old.superseded_by_run_id, winner_run.id) + + # The losing attempt must not have been silently allowed to + # retry and create a second Run either. + with self.assertRaises(RunResumeConflictError): + await repo.resume_run_with_pending_steering( + "acme", old_run.thread_id, assistant, request, old_run.id + ) + + asyncio.run(scenario()) + + def test_cancel_of_a_resumed_away_paused_run_is_rejected_as_superseded(self) -> None: + """Issue #16 PR #17 review round 13 (BLOCKER): once a paused run + ``A`` has been resumed into a descendant ``B``, ``A`` keeps + ``status='paused'`` by design (the typed ``superseded_by_run_id`` + column marks it stale instead) so that ``/steer`` can point + callers at ``B``. + ``request_cancel(A)`` must honor that same signal atomically -- + raising ``RunSupersededError`` -- instead of falsely succeeding by + flipping ``A`` to ``cancelled`` while ``B`` keeps executing + untouched. + """ + + from lingxigraph.server.models import RunCreate + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + old_run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + request = RunCreate(assistant_id=assistant.id, resume=1) + new_run, _ = await repo.resume_run_with_pending_steering( + "acme", old_run.thread_id, assistant, request, old_run.id + ) + + with self.assertRaises(RunSupersededError): + await repo.request_cancel("acme", old_run.id) + + # The old run must be untouched -- still paused, still pointing + # at the same descendant, not clobbered into a terminal state. + old_after = await repo.get_run("acme", old_run.id) + self.assertEqual(old_after.status, "paused") + self.assertEqual(old_after.superseded_by_run_id, new_run.id) + + # The actual resumed descendant is unaffected and still + # cancellable normally. + new_before_cancel = await repo.get_run("acme", new_run.id) + self.assertNotEqual(new_before_cancel.status, "cancelled") + cancelled = await repo.request_cancel("acme", new_run.id) + self.assertTrue(cancelled) + new_after_cancel = await repo.get_run("acme", new_run.id) + self.assertEqual(new_after_cancel.status, "cancelled") + + asyncio.run(scenario()) + + def test_transferred_and_new_steering_sequences_never_collide(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + old_run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + await repo.submit_steering("acme", old_run.id, kind="user_input", payload={"m": 1}) + await repo.submit_steering("acme", old_run.id, kind="user_input", payload={"m": 2}) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + from lingxigraph.server.models import RunCreate + + request = RunCreate(assistant_id=assistant.id, resume=1) + new_run, transferred = await repo.resume_run_with_pending_steering( + "acme", old_run.thread_id, assistant, request, old_run.id + ) + self.assertEqual([event.sequence for event in transferred], [1, 2]) + # A concurrent-in-spirit ordinary /steer against the resumed + # run must land on the next free sequence, never colliding + # with the migrated ones. + extra, _ = await repo.submit_steering( + "acme", new_run.id, kind="user_input", payload={"m": 3} + ) + self.assertEqual(extra.sequence, 3) + all_events = await repo.list_steering("acme", new_run.id) + self.assertEqual(sorted(event.sequence for event in all_events), [1, 2, 3]) + + asyncio.run(scenario()) + + def test_forged_metadata_superseded_by_run_id_cannot_forge_control_state(self) -> None: + """Issue #16 PR #17 review round 14 (BLOCKER): ``superseded_by_run_id`` + is authoritative Runtime control state -- it must live in a typed + ``Run`` field, never in user-writable ``metadata``. A caller who + submits ``metadata.superseded_by_run_id`` at run-creation time must + not be able to forge a superseded/resume-conflict signal against a + run that was never actually resumed. + """ + + from lingxigraph.server.models import RunCreate + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + forged = RunCreate( + assistant_id=assistant.id, + input={}, + metadata={"superseded_by_run_id": "some-other-run-that-never-existed"}, + ) + run = await repo.create_run("acme", None, assistant, forged) + + # The typed field must be inert to the forged metadata -- + # this run was never actually resumed. + self.assertIsNone(run.superseded_by_run_id) + self.assertEqual( + run.metadata.get("superseded_by_run_id"), + "some-other-run-that-never-existed", + ) + + # /steer must work normally -- not spuriously superseded. + event, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + self.assertTrue(created) + self.assertEqual(event.status, "pending") + + # /cancel must work normally -- not spuriously superseded. + cancelled = await repo.request_cancel("acme", run.id) + self.assertTrue(cancelled) + after = await repo.get_run("acme", run.id) + self.assertIn(after.status, ("cancelled", "cancelling")) + + asyncio.run(scenario()) + + def test_forged_metadata_superseded_by_run_id_does_not_block_real_resume(self) -> None: + """Companion to the forging test above: a genuinely ``paused`` run + carrying the same forged ``metadata.superseded_by_run_id`` key must + still ``/resume`` normally -- the user metadata must never trigger + ``RunResumeConflictError``. + """ + + from lingxigraph.server.models import RunCreate + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + forged = RunCreate( + assistant_id=assistant.id, + input={}, + metadata={"superseded_by_run_id": "some-other-run-that-never-existed"}, + ) + run = await repo.create_run("acme", None, assistant, forged) + await repo.finish_run("acme", run.id, "paused", output={}) + + resume_request = RunCreate(assistant_id=assistant.id, resume=1) + new_run, _transferred = await repo.resume_run_with_pending_steering( + "acme", run.thread_id, assistant, resume_request, run.id + ) + self.assertIsNotNone(new_run.id) + old_after = await repo.get_run("acme", run.id) + self.assertEqual(old_after.superseded_by_run_id, new_run.id) + + asyncio.run(scenario()) + + +class DurableAckOrderingTests(unittest.TestCase): + """Issue #16 PR #17 review point 2: the local consumption log must only + be acked *after* the durable commit (steering status + lifecycle event) + has actually succeeded -- never popped destructively beforehand.""" + + def test_channel_peek_and_ack_never_lose_entries_appended_concurrently(self) -> None: + channel = SteeringChannel("run-x") + channel.submit(kind="user_input", payload={"i": 1}) + channel.drain(node="a") + first_batch = channel.peek_consumed() + self.assertEqual(len(first_batch), 1) + # Simulate a second drain happening on another task before the + # first batch is acked -- its entry must survive the ack below. + channel.submit(kind="user_input", payload={"i": 2}) + channel.drain(node="b") + channel.ack_consumed(entry.event.id for entry in first_batch) + remaining = channel.peek_consumed() + self.assertEqual(len(remaining), 1) + self.assertEqual(remaining[0].node, "b") + + def test_worker_retries_a_transient_durable_commit_failure_without_losing_it( + self, + ) -> None: + from lingxigraph.server.registry import GraphRegistry + from lingxigraph.server.worker import Worker + from lingxigraph.steering import SteeringEvent + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + accepted, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + + channel = SteeringChannel(run.id, owned_by_executor=False) + channel.ingest( + SteeringEvent( + id=accepted.id, + run_id=run.id, + sequence=accepted.sequence, + kind=accepted.kind, + payload=accepted.payload, + metadata=accepted.metadata, + created_at=accepted.created_at, + ) + ) + # Simulate the graph safe point actually draining it -- this is + # what populates the channel's local consumption log that + # ``_sync_steering_out`` must not lose. + channel.drain(node="the_node", task_id="task-0") + + class FakeGraph: + def get_steering_channel(self, run_id: str): + return channel + + worker = Worker(GraphRegistry({}), repo) + + calls = {"n": 0} + real_commit = repo.commit_steering_consumptions + + async def flaky_commit(tenant_id, run_id, worker_id, attempt, consumptions): + calls["n"] += 1 + if calls["n"] == 1: + raise ConnectionError("transient database hiccup") + return await real_commit(tenant_id, run_id, consumptions) + + repo.commit_steering_consumptions_if_owned = flaky_commit # type: ignore[method-assign] + + # First sync hits the injected transient failure: nothing may + # be lost -- the DB row must still read pending and the + # channel must still hold the unacked consumption record. + await worker._sync_steering_out(run, FakeGraph()) + still_pending = await repo.list_pending_steering("acme", run.id) + self.assertEqual(len(still_pending), 1) + self.assertEqual(len(channel.peek_consumed()), 1) + consumed_events = [ + event + for event in await repo.list_events("acme", run.id) + if event.kind == "run.steer.consumed" + ] + self.assertEqual(consumed_events, []) + + # A later safe point (next heartbeat tick) retries and this + # time the durable commit succeeds -- the consumption is fully + # recorded exactly once, not lost and not duplicated. + await worker._sync_steering_out(run, FakeGraph()) + self.assertEqual(calls["n"], 2) + pending_after = await repo.list_pending_steering("acme", run.id) + self.assertEqual(pending_after, []) + self.assertEqual(channel.peek_consumed(), ()) + all_steering = await repo.list_steering("acme", run.id) + self.assertEqual(all_steering[0].status, "consumed") + consumed_events_after = [ + event + for event in await repo.list_events("acme", run.id) + if event.kind == "run.steer.consumed" + ] + self.assertEqual(len(consumed_events_after), 1) + + asyncio.run(scenario()) + + def test_final_flush_failure_never_reports_success_with_steering_lost(self) -> None: + """Issue #16 PR #17 review round 4, point 3 (round 5, point 1/2): a + durable-commit failure on the *final* flush -- with the heartbeat + already cancelled and no further safe point within this delivery + attempt -- must not leave the run reported succeeded/paused while + the DB steering row is silently stranded ``pending`` forever, and + must not discard the run's intended outcome before the flush is + known to have succeeded. This goes through the real + ``Worker._execute()`` finalization path (not an isolated call to + ``_sync_steering_out``). + + ``_final_steering_flush`` now retries for as long as it holds the + lease rather than giving up after a fixed number of attempts (see + its docstring), so this simulates the one case it does give up in + -- the worker itself starting to drain mid-retry -- by setting + ``worker._stop`` as a side effect of the first failed commit. + """ + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0, "drained": []}) + ) + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + + worker = Worker(make_registry(), repo) + + # The heartbeat loop is irrelevant to what this test is + # checking (it only needs the steering that was already + # durably accepted *before* the run was claimed, which + # ``_execute`` syncs in up front, before the heartbeat task is + # even created) -- replace it with an inert coroutine so the + # test isn't also exercising unrelated heartbeat/event-bus + # concurrency while it deliberately holds the flush retry loop + # open. + async def inert_heartbeat(run, token, graph): + await asyncio.Event().wait() + + worker._heartbeat = inert_heartbeat # type: ignore[method-assign] + + async def always_fails(tenant_id, run_id, worker_id, attempt, consumptions): + # Simulate the worker starting to drain mid-retry so the + # flush's otherwise-unbounded retry loop bails immediately + # instead of looping (and sleeping) forever in this test. + worker._stop.set() + raise ConnectionError("transient database hiccup on final flush") + + repo.commit_steering_consumptions_if_owned = always_fails # type: ignore[method-assign] + + claimed = await worker.run_once() + self.assertTrue(claimed) + + after_failed_flush = await repo.get_run("acme", run.id) + status = ( + after_failed_flush.status.value + if hasattr(after_failed_flush.status, "value") + else after_failed_flush.status + ) + # Must never be reported succeeded/paused while the durable + # steering commit never actually happened. + self.assertEqual(status, "pending") + still_pending = await repo.list_pending_steering("acme", run.id) + self.assertEqual(len(still_pending), 1) + consumed_events = [ + event + for event in await repo.list_events("acme", run.id) + if event.kind == "run.steer.consumed" + ] + self.assertEqual(consumed_events, []) + + # Recovery: restore the durable commit, un-drain the worker, + # and let the run be redelivered. It must eventually succeed + # with exactly one ``run.steer.consumed`` event -- not lost, + # not duplicated -- confirming the still-``pending`` DB row + # (not any in-process channel state) is what a fresh delivery + # attempt's brand new graph/channel actually recovers from. + del repo.commit_steering_consumptions_if_owned # type: ignore[attr-defined] + worker._stop = asyncio.Event() + claimed_again = await worker.run_once() + self.assertTrue(claimed_again) + + finished = await repo.get_run("acme", run.id) + finished_status = ( + finished.status.value if hasattr(finished.status, "value") else finished.status + ) + self.assertEqual(finished_status, "succeeded") + self.assertIn("stop", finished.output["drained"]) + self.assertEqual(await repo.list_pending_steering("acme", run.id), []) + consumed_events_after = [ + event + for event in await repo.list_events("acme", run.id) + if event.kind == "run.steer.consumed" + ] + self.assertEqual(len(consumed_events_after), 1) + + asyncio.run(scenario()) + + def test_concurrent_observers_never_see_a_transient_terminal_status_during_final_flush( + self, + ) -> None: + """Issue #16 PR #17 review round 5, point 1 (BLOCKER): a run's + terminal/paused status must never become externally observable + (via ``GET``/``join``/SSE) before the final steering flush has + durably succeeded -- and, symmetrically, once it *does* become + observable it must never move back to non-terminal. + + This holds the final flush open with a gate the test controls, + continuously polls ``repository.get_run`` (the same read path + ``GET``/``join``/SSE all resolve to) throughout, and asserts: + (1) while the flush is blocked, the status is never + ``succeeded``/``paused``; (2) once ``succeeded`` is first + observed, every subsequent poll is also ``succeeded`` -- it never + reverts to ``pending``/``running``.""" + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0, "drained": []}) + ) + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + + worker = Worker(make_registry(), repo) + + async def inert_heartbeat(run, token, graph): + await asyncio.Event().wait() + + worker._heartbeat = inert_heartbeat # type: ignore[method-assign] + + gate = asyncio.Event() + flush_entered = asyncio.Event() + real_commit = repo.commit_steering_consumptions + + async def gated_commit(tenant_id, run_id, worker_id, attempt, consumptions): + flush_entered.set() + await gate.wait() + return await real_commit(tenant_id, run_id, consumptions) + + repo.commit_steering_consumptions_if_owned = gated_commit # type: ignore[method-assign] + + observed_statuses: list[str] = [] + stop_observing = asyncio.Event() + + async def observer() -> None: + while not stop_observing.is_set(): + current = await repo.get_run("acme", run.id) + status = ( + current.status.value + if hasattr(current.status, "value") + else current.status + ) + observed_statuses.append(status) + await asyncio.sleep(0.005) + + observer_task = asyncio.ensure_future(observer()) + execute_task = asyncio.ensure_future(worker.run_once()) + + # Let the run reach and block inside the final flush before + # releasing it -- this is the exact window the round 5 review + # identified as unsafe in the pre-fix code. + await asyncio.wait_for(flush_entered.wait(), timeout=5) + # A few more observer ticks while still gated shut. + await asyncio.sleep(0.05) + self.assertNotIn("succeeded", observed_statuses) + self.assertNotIn("paused", observed_statuses) + + gate.set() + await asyncio.wait_for(execute_task, timeout=5) + # A few more polls after completion to catch any regression + # back out of the terminal state. + await asyncio.sleep(0.05) + stop_observing.set() + await observer_task + + self.assertIn("succeeded", observed_statuses) + first_succeeded = observed_statuses.index("succeeded") + # Monotonic: nothing after the first "succeeded" observation + # is ever anything else. + self.assertTrue(all(s == "succeeded" for s in observed_statuses[first_succeeded:])) + self.assertNotIn("paused", observed_statuses) + + final = await repo.get_run("acme", run.id) + final_status = ( + final.status.value if hasattr(final.status, "value") else final.status + ) + self.assertEqual(final_status, "succeeded") + self.assertIn("stop", final.output["drained"]) + + asyncio.run(scenario()) + + def test_redelivery_after_flush_failure_uses_a_genuinely_new_graph_instance( + self, + ) -> None: + """Issue #16 PR #17 review round 5, point 2: the previous claim that + "not calling ``forget_steering()`` leaves the consumption log + available for the next delivery" does not hold, because + ``with_runtime()`` builds a *brand new* ``CompiledStateGraph`` (and + therefore a brand new, empty ``_run_steering`` channel map) on + every call -- the locally-bound ``graph``/channel object from a + failed delivery attempt is never retained across attempts. + + This test proves recovery does NOT depend on graph/channel object + identity surviving across attempts: it instruments + ``GraphRegistry.get`` to hand back a distinct, individually + identifiable ``CompiledStateGraph`` on each call, confirms the + second ``run_once()`` really did receive a different graph object + (not a hand-rolled second flush call on the first attempt's + object), and confirms the run still recovers correctly -- because + recovery is driven entirely by the still-``pending`` PostgreSQL + row, not by anything held in memory across the attempt boundary. + """ + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0, "drained": []}) + ) + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + + registry = make_registry() + # Keep every bound graph alive for the rest of the test, not + # just its id(): CPython is free to reuse a freed object's + # memory address for the next allocation, so comparing ids of + # objects that were allowed to go out of scope is not a + # reliable "is this a genuinely distinct instance" check (this + # aliased in practice on 3.13's GC timing, id()s collided). + seen_graphs: list[object] = [] + compiled = registry.get("steerable") + real_with_runtime = compiled.with_runtime + + def tracking_with_runtime(*args: Any, **kwargs: Any): + bound = real_with_runtime(*args, **kwargs) + seen_graphs.append(bound) + return bound + + compiled.with_runtime = tracking_with_runtime # type: ignore[method-assign] + + worker = Worker(registry, repo) + + async def inert_heartbeat(run, token, graph): + await asyncio.Event().wait() + + worker._heartbeat = inert_heartbeat # type: ignore[method-assign] + + real_commit = repo.commit_steering_consumptions + fail_once = {"done": False} + + async def fail_first_attempt_only( + tenant_id, run_id, worker_id, attempt, consumptions + ): + if not fail_once["done"]: + fail_once["done"] = True + worker._stop.set() + raise ConnectionError("transient database hiccup on final flush") + return await real_commit(tenant_id, run_id, consumptions) + + repo.commit_steering_consumptions_if_owned = fail_first_attempt_only # type: ignore[method-assign] + + claimed = await worker.run_once() + self.assertTrue(claimed) + after_first_attempt = await repo.get_run("acme", run.id) + status = ( + after_first_attempt.status.value + if hasattr(after_first_attempt.status, "value") + else after_first_attempt.status + ) + self.assertEqual(status, "pending") + self.assertEqual(len(seen_graphs), 1) + + worker._stop = asyncio.Event() + claimed_again = await worker.run_once() + self.assertTrue(claimed_again) + + # The critical assertion: recovery went through a *second*, + # distinct ``with_runtime()`` call -- a genuinely new graph + # instance, exactly as a real redelivery (by this worker or a + # different one entirely) would -- and it still worked, + # because the recovery source of truth is the ``pending`` DB + # row, never anything held on the first attempt's discarded + # graph/channel. + self.assertEqual(len(seen_graphs), 2) + self.assertIsNot(seen_graphs[0], seen_graphs[1]) + + finished = await repo.get_run("acme", run.id) + finished_status = ( + finished.status.value if hasattr(finished.status, "value") else finished.status + ) + self.assertEqual(finished_status, "succeeded") + self.assertIn("stop", finished.output["drained"]) + self.assertEqual(await repo.list_pending_steering("acme", run.id), []) + consumed_events = [ + event + for event in await repo.list_events("acme", run.id) + if event.kind == "run.steer.consumed" + ] + self.assertEqual(len(consumed_events), 1) + + asyncio.run(scenario()) + + +class FinalizationFencingTests(unittest.TestCase): + """Issue #16 PR #17 review round 6 (BLOCKERs 1-3): all writes a worker + makes during finalization must be fenced on its own + ``(lease_owner, attempt)``, cancellation must win over a stale + non-cancel intent inside that same fenced decision, and new steering + admission must close before the final flush starts.""" + + def test_stale_worker_cannot_revert_a_new_owners_lease_or_status(self) -> None: + """Round 6, point 1 (BLOCKER): worker A claims with a very short + lease and blocks inside the final flush; once the lease expires, + worker B claims the same run; releasing A's block must not let A's + now-stale finalization commit touch B's run at all.""" + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0, "drained": []}) + ) + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + + worker_a = Worker( + make_registry(), + repo, + worker_id="worker-a", + lease_seconds=1, + heartbeat_seconds=10, + ) + + async def inert_heartbeat(run, token, graph): + await asyncio.Event().wait() + + worker_a._heartbeat = inert_heartbeat # type: ignore[method-assign] + + gate = asyncio.Event() + flush_entered = asyncio.Event() + real_commit = repo.commit_steering_consumptions + + async def gated_commit(tenant_id, run_id, worker_id, attempt, consumptions): + flush_entered.set() + await gate.wait() + return await real_commit(tenant_id, run_id, consumptions) + + repo.commit_steering_consumptions_if_owned = gated_commit # type: ignore[method-assign] + + execute_task = asyncio.ensure_future(worker_a.run_once()) + await asyncio.wait_for(flush_entered.wait(), timeout=5) + + # Let A's 1-second lease actually expire without being renewed + # (its heartbeat_seconds=10 means the independent finalization + # renewal loop will not have ticked yet). + await asyncio.sleep(1.3) + + claimed_b = await repo.claim_run("worker-b", lease_seconds=30) + self.assertIsNotNone(claimed_b) + self.assertEqual(claimed_b.id, run.id) + self.assertEqual(claimed_b.lease_owner, "worker-b") + self.assertEqual(claimed_b.attempt, 2) + + # Now release A's blocked flush -- its commit succeeds, but its + # subsequent fenced finalize write must be refused because it + # no longer owns the lease/attempt. + gate.set() + await asyncio.wait_for(execute_task, timeout=5) + + after = await repo.get_run("acme", run.id) + self.assertEqual(after.lease_owner, "worker-b") + self.assertEqual(after.attempt, 2) + status = after.status.value if hasattr(after.status, "value") else after.status + self.assertIn(status, ("running", "cancelling")) + self.assertNotEqual(status, "pending") + self.assertNotEqual(status, "succeeded") + + asyncio.run(scenario()) + + def test_stale_worker_cannot_revert_new_owner_via_flush_failure_retry_path(self) -> None: + """Round 6, point 1 (BLOCKER), the mirror case: A's final flush + never succeeds at all (durable commit keeps failing) and A falls + back to its retry/dead-letter path -- that path's writes must also + be fenced, so A cannot reset B's actively-running attempt back to + ``pending``.""" + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0, "drained": []}) + ) + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + + worker_a = Worker( + make_registry(), + repo, + worker_id="worker-a", + lease_seconds=1, + heartbeat_seconds=10, + ) + + async def inert_heartbeat(run, token, graph): + await asyncio.Event().wait() + + worker_a._heartbeat = inert_heartbeat # type: ignore[method-assign] + + entered = asyncio.Event() + release = asyncio.Event() + + async def always_fails(tenant_id, run_id, worker_id, attempt, consumptions): + entered.set() + await release.wait() + raise ConnectionError("simulated persistent database outage") + + repo.commit_steering_consumptions_if_owned = always_fails # type: ignore[method-assign] + + execute_task = asyncio.ensure_future(worker_a.run_once()) + await asyncio.wait_for(entered.wait(), timeout=5) + await asyncio.sleep(1.3) + + claimed_b = await repo.claim_run("worker-b", lease_seconds=30) + self.assertIsNotNone(claimed_b) + self.assertEqual(claimed_b.attempt, 2) + + release.set() + await asyncio.wait_for(execute_task, timeout=5) + + after = await repo.get_run("acme", run.id) + self.assertEqual(after.lease_owner, "worker-b") + self.assertEqual(after.attempt, 2) + status = after.status.value if hasattr(after.status, "value") else after.status + self.assertIn(status, ("running", "cancelling")) + + asyncio.run(scenario()) + + def test_cancel_always_wins_over_a_stale_success_intent_during_finalization( + self, + ) -> None: + """Round 6, point 2 (BLOCKER): the graph has already computed a + SUCCEEDED intent and the heartbeat is cancelled before a client's + cancel request lands; the fenced finalize write must observe the + run is ``cancelling`` and coerce the outcome to ``cancelled``, + never letting the stale ``succeeded`` intent win.""" + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + + worker = Worker(make_registry_double(), repo, worker_id="worker-a") + + async def inert_heartbeat(run, token, graph): + await asyncio.Event().wait() + + worker._heartbeat = inert_heartbeat # type: ignore[method-assign] + + # The graph here has no steering to drain, so the final flush + # itself is a no-op -- gate the fenced finalize write instead, + # which is exactly where round 6 point 2 requires the + # cancelling re-check to happen. + gate = asyncio.Event() + commit_entered = asyncio.Event() + # Issue #16 PR #17 review round 9, point 2 (BLOCKER): a + # terminal ``finish`` intent now finalizes via the merged + # ``finalize_run_with_steering_disposition_if_owned`` -- gate + # that instead of the now-bypassed ``finish_run_if_owned``. + real_finalize = repo.finalize_run_with_steering_disposition_if_owned + + async def gated_finalize(*args: Any, **kwargs: Any): + commit_entered.set() + await gate.wait() + return await real_finalize(*args, **kwargs) + + repo.finalize_run_with_steering_disposition_if_owned = gated_finalize # type: ignore[method-assign] + + execute_task = asyncio.ensure_future(worker.run_once()) + await asyncio.wait_for(commit_entered.wait(), timeout=5) + + # Cancellation arrives while the run still reads + # running/cancelling (finalization has not committed yet). + cancelled = await repo.request_cancel("acme", run.id) + self.assertTrue(cancelled) + mid_status = await repo.get_run("acme", run.id) + self.assertEqual( + mid_status.status.value + if hasattr(mid_status.status, "value") + else mid_status.status, + "cancelling", + ) + + gate.set() + await asyncio.wait_for(execute_task, timeout=5) + + final = await repo.get_run("acme", run.id) + final_status = ( + final.status.value if hasattr(final.status, "value") else final.status + ) + self.assertEqual(final_status, "cancelled") + self.assertNotEqual(final_status, "succeeded") + + asyncio.run(scenario()) + + def test_stale_worker_cannot_commit_steering_consumptions_after_lease_takeover( + self, + ) -> None: + """Issue #16 PR #17 review round 7, point 2 (BLOCKER). + + Worker A drains steering event E during attempt N and blocks + inside the durable consumption commit. Its lease expires; worker B + claims the same run (attempt N+1). Releasing A's blocked commit + must be rejected outright -- A's commit must never touch E's + durable status or emit ``run.steer.consumed`` once it no longer + owns the lease/attempt, exactly like the already-fenced + ``finish_run_if_owned``/``retry_run_if_owned`` writes. + """ + + from lingxigraph.steering import SteeringConsumption, SteeringEvent + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + accepted, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + + claimed_a = await repo.claim_run("worker-a", lease_seconds=1) + assert claimed_a is not None + self.assertEqual(claimed_a.attempt, 1) + + steering_event = SteeringEvent( + id=accepted.id, + run_id=run.id, + sequence=accepted.sequence, + kind=accepted.kind, + payload=accepted.payload, + metadata=accepted.metadata, + created_at=accepted.created_at, + ) + consumption = SteeringConsumption( + event=steering_event, + consumed_at=steering_event.created_at, + node="n", + namespace=(), + task_id="t-0", + ) + + # A's lease expires without renewal; B claims the same run at + # a new attempt while A is (conceptually) still "blocked" -- + # this test drives the two calls sequentially, which exercises + # the exact same fencing check the concurrent version would. + await asyncio.sleep(1.2) + claimed_b = await repo.claim_run("worker-b", lease_seconds=30) + assert claimed_b is not None + self.assertEqual(claimed_b.attempt, 2) + + # A's stale, now-recovering commit must be rejected entirely -- + # nothing written, nothing acked. + result = await repo.commit_steering_consumptions_if_owned( + "acme", run.id, "worker-a", claimed_a.attempt, [consumption] + ) + self.assertIsNone(result) + + # E's durable status must be untouched -- still pending, no + # ``run.steer.consumed`` lifecycle event. + still_pending = await repo.list_pending_steering("acme", run.id) + self.assertEqual([event.id for event in still_pending], [accepted.id]) + consumed_events = [ + event + for event in await repo.list_events("acme", run.id) + if event.kind == "run.steer.consumed" + ] + self.assertEqual(consumed_events, []) + + # B, the actual current owner, can commit it normally. + stored = await repo.commit_steering_consumptions_if_owned( + "acme", run.id, "worker-b", claimed_b.attempt, [consumption] + ) + self.assertEqual(len(stored), 1) + self.assertEqual(await repo.list_pending_steering("acme", run.id), []) + + asyncio.run(scenario()) + + +class SteeringClosedGateTests(unittest.TestCase): + """Issue #16 PR #17 review round 6, point 3 (BLOCKER): once graph + execution ends there is no safe point left for a newly accepted + steering event to ever be consumed, even though the Run row may still + read ``running``/``cancelling`` while the final flush is in flight.""" + + def test_steer_during_finalization_window_gets_a_stable_error_not_a_stuck_pending_row( + self, + ) -> None: + from lingxigraph.errors import RunFinalizingError + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + + worker = Worker(make_registry_double(), repo, worker_id="worker-a") + + async def inert_heartbeat(run, token, graph): + await asyncio.Event().wait() + + worker._heartbeat = inert_heartbeat # type: ignore[method-assign] + + gate = asyncio.Event() + commit_entered = asyncio.Event() + # Issue #16 PR #17 review round 9, point 2 (BLOCKER): a + # terminal ``finish`` intent now finalizes via the merged + # ``finalize_run_with_steering_disposition_if_owned`` -- gate + # that instead of the now-bypassed ``finish_run_if_owned``. + real_finalize = repo.finalize_run_with_steering_disposition_if_owned + + async def gated_finalize(*args: Any, **kwargs: Any): + commit_entered.set() + await gate.wait() + return await real_finalize(*args, **kwargs) + + repo.finalize_run_with_steering_disposition_if_owned = gated_finalize # type: ignore[method-assign] + + execute_task = asyncio.ensure_future(worker.run_once()) + await asyncio.wait_for(commit_entered.wait(), timeout=5) + + # The graph has already reached END (no safe point left) but + # the run row still reads running/cancelling -- this is + # exactly the window round 6 point 3 is about. + still_active = await repo.get_run("acme", run.id) + status = ( + still_active.status.value + if hasattr(still_active.status, "value") + else still_active.status + ) + self.assertIn(status, ("running", "cancelling")) + self.assertTrue(still_active.steering_closed) + + with self.assertRaises(RunFinalizingError): + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "too late"} + ) + + gate.set() + await asyncio.wait_for(execute_task, timeout=5) + + # Never a terminal run with a pending steering inbox nobody + # will ever consume. + self.assertEqual(await repo.list_pending_steering("acme", run.id), []) + final = await repo.get_run("acme", run.id) + final_status = ( + final.status.value if hasattr(final.status, "value") else final.status + ) + self.assertEqual(final_status, "succeeded") + + asyncio.run(scenario()) + + def test_steering_closed_gate_resets_on_a_fresh_delivery_attempt(self) -> None: + """A gate closed by an earlier, now-abandoned attempt must not + leak into the next delivery attempt's fresh claim -- otherwise a + perfectly ordinary retried run would spuriously reject all + ``/steer`` calls forever.""" + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + claimed = await repo.claim_run("worker-a", lease_seconds=1) + assert claimed is not None + closed = await repo.close_steering("acme", run.id, "worker-a", claimed.attempt) + self.assertTrue(closed) + + await asyncio.sleep(1.2) + reclaimed = await repo.claim_run("worker-b", lease_seconds=30) + assert reclaimed is not None + self.assertEqual(reclaimed.attempt, 2) + self.assertFalse(reclaimed.steering_closed) + + # Steering must be accepted normally under the new attempt. + accepted, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "hello"} + ) + self.assertTrue(created) + self.assertEqual(accepted.status, "pending") + + asyncio.run(scenario()) + + def test_steer_that_wins_the_lock_race_before_gate_close_is_superseded_not_rerun( + self, + ) -> None: + """Issue #16 PR #17 review round 8, point 1 (BLOCKER) -- CHOSEN FIX: + "seal admission at the safe boundary + supersede late events". + + A ``/steer`` call can win the run row's lock *before* + ``close_steering()`` does, even though the graph has already + finished and the heartbeat has already stopped -- "landed before + the gate closed" is not the same as "landed before the graph's + last safe point". This artificially blocks ``close_steering()``, + completes a ``submit_steering()`` while it is blocked (simulating + the race winner), then releases the block. Round 7's fix forced an + indefinite retry loop onto this run -- but the ``double`` graph + below never calls ``drain_steering()`` at all, so forcing a rerun + penalized a graph that is correctly, deliberately ignoring + steering. The run must instead finalize normally on this very + attempt, and the late-landing steering event must end up with a + durable ``superseded`` disposition and matching + ``run.steer.superseded`` lifecycle event -- never silently + dropped, never forcing a rerun. + """ + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + + worker = Worker(make_registry_double(), repo, worker_id="worker-a") + + async def inert_heartbeat(run, token, graph): + await asyncio.Event().wait() + + worker._heartbeat = inert_heartbeat # type: ignore[method-assign] + + gate = asyncio.Event() + entered = asyncio.Event() + real_close = repo.close_steering + + async def blocked_close(tenant_id, run_id, worker_id, attempt): + entered.set() + await gate.wait() + return await real_close(tenant_id, run_id, worker_id, attempt) + + repo.close_steering = blocked_close # type: ignore[method-assign] + + execute_task = asyncio.ensure_future(worker.run_once()) + await asyncio.wait_for(entered.wait(), timeout=5) + + # The graph has already reached END and the heartbeat is + # already cancelled -- there is no safe point left -- but the + # gate has not closed yet. A concurrent ``/steer`` wins the + # run lock first and durably commits. + accepted, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "too late"} + ) + self.assertTrue(created) + + gate.set() + await asyncio.wait_for(execute_task, timeout=5) + + final = await repo.get_run("acme", run.id) + final_status = ( + final.status.value if hasattr(final.status, "value") else final.status + ) + # The run finalizes normally on this attempt -- never forced + # into a retry loop just because a steering event existed that + # the graph never looked at. + self.assertEqual(final_status, "succeeded") + + # The late-landing steering event is durably superseded, not + # left pending, not silently dropped. + still_pending = await repo.list_pending_steering("acme", run.id) + self.assertEqual(still_pending, []) + all_steering = await repo.list_steering("acme", run.id) + self.assertEqual(len(all_steering), 1) + self.assertEqual(all_steering[0].id, accepted.id) + self.assertEqual(all_steering[0].status, "superseded") + + # A durable, observable run.steer.superseded lifecycle event + # was recorded for it -- not the resume-transfer reason, since + # this is the late-boundary-race disposition. + events = await repo.list_events("acme", run.id) + superseded_events = [e for e in events if e.kind == "run.steer.superseded"] + self.assertEqual(len(superseded_events), 1) + self.assertEqual(superseded_events[0].data["steering_event_id"], accepted.id) + self.assertEqual( + superseded_events[0].data["reason"], "unconsumed_at_final_boundary" + ) + self.assertIsNone(superseded_events[0].data["superseded_by_run_id"]) + + asyncio.run(scenario()) + + def test_graph_that_never_drains_steering_still_finalizes_on_first_attempt( + self, + ) -> None: + """Issue #16 PR #17 review round 8, point 1 (BLOCKER) required + regression coverage (a): a graph that never calls + ``drain_steering()`` at all, receiving one ordinary steering + event, must still finalize successfully on its first attempt -- + no forced rerun just because steering happened to exist. This is + the deliberate-ignore case explicitly allowed by issue #16 ("a + node may call drain_steering() once, in a loop, or not at all").""" + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + accepted, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "ignored"} + ) + self.assertTrue(created) + + worker = Worker(make_registry_double(), repo, worker_id="worker-a") + claimed = await worker.run_once() + self.assertTrue(claimed) + + final = await repo.get_run("acme", run.id) + final_status = ( + final.status.value if hasattr(final.status, "value") else final.status + ) + self.assertEqual(final_status, "succeeded") + self.assertEqual(final.attempt, 1) + + all_steering = await repo.list_steering("acme", run.id) + self.assertEqual(len(all_steering), 1) + self.assertEqual(all_steering[0].status, "superseded") + events = await repo.list_events("acme", run.id) + superseded_events = [e for e in events if e.kind == "run.steer.superseded"] + self.assertEqual(len(superseded_events), 1) + self.assertEqual(superseded_events[0].data["steering_event_id"], accepted.id) + + asyncio.run(scenario()) + + +class CancellingLeaseExpiryRecoveryTests(unittest.TestCase): + """Issue #16 PR #17 review round 11 (BLOCKER). + + Both repositories only reclaimed an expired lease when the run was + exactly ``running``. A run that was moved to ``cancelling`` (via + ``request_cancel``) keeps its lease -- if the owning worker then + crashes before it can finalize the run to a terminal status, the + expired lease was never reclaimed because ``cancelling`` didn't match + either the expired-``running`` recovery query or the ordinary + ``pending`` claim selector. The run was stuck in ``cancelling`` + forever, wedging its thread and leaving ``steering_closed`` (if it had + been set) permanently closed with no terminal status ever reached. + + ``claim_run`` must now resolve an expired ``cancelling`` lease + straight to the terminal ``cancelled`` status (never back to + ``pending``, which would silently erase the cancellation signal and + could let business execution resume after the caller explicitly asked + to stop). + """ + + def test_claim_run_resolves_expired_cancelling_lease_to_cancelled(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + + claimed = await repo.claim_run("worker-a", lease_seconds=1) + assert claimed is not None + self.assertEqual(claimed.status, "running") + + # Reproduce the exact finalization-window scenario: cancel is + # requested while the worker still holds the lease, and the + # worker gets as far as closing steering admission before it + # crashes -- never reaching the fenced terminal commit. + cancelled_requested = await repo.request_cancel("acme", run.id) + self.assertTrue(cancelled_requested) + mid_flight = await repo.get_run("acme", run.id) + self.assertEqual(mid_flight.status, "cancelling") + + # There is durable steering history pending when the crash + # happens. + accepted, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "hi"} + ) + self.assertTrue(created) + self.assertEqual(accepted.status, "pending") + + closed = await repo.close_steering("acme", run.id, "worker-a", claimed.attempt) + self.assertTrue(closed) + + # Worker A never comes back; let the lease expire, then + # simulate worker B's next claim attempt. + await asyncio.sleep(1.2) + reclaimed = await repo.claim_run("worker-b", lease_seconds=30) + + # The stranded run must never be handed out as claimable work + # -- claim_run must return None (no other pending run exists). + self.assertIsNone(reclaimed) + + final = await repo.get_run("acme", run.id) + self.assertEqual(final.status, "cancelled") + self.assertIsNone(final.lease_owner) + self.assertIsNone(final.lease_expires_at) + self.assertIsNotNone(final.finished_at) + assert final.error is not None + self.assertEqual(final.error["code"], "run_cancelled") + + # A subsequent claim attempt must also never return this run. + self.assertIsNone(await repo.claim_run("worker-c", lease_seconds=30)) + + # Ordinary cancel already leaves pending steering as durable + # history (it is never implicitly consumed/replayed) -- the + # lease-recovery path must not behave differently. + pending_steering = await repo.list_pending_steering("acme", run.id) + self.assertEqual(len(pending_steering), 1) + self.assertEqual(pending_steering[0].id, accepted.id) + + asyncio.run(scenario()) + + def test_expired_running_lease_still_recovers_to_pending(self) -> None: + """Confirm the pre-existing expired-``running`` recovery path is + unchanged by the new ``cancelling`` branch.""" + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + claimed = await repo.claim_run("worker-a", lease_seconds=1) + assert claimed is not None + + await asyncio.sleep(1.2) + reclaimed = await repo.claim_run("worker-b", lease_seconds=30) + assert reclaimed is not None + self.assertEqual(reclaimed.id, run.id) + self.assertEqual(reclaimed.status, "running") + self.assertEqual(reclaimed.lease_owner, "worker-b") + + asyncio.run(scenario()) + + +class RetryCommitCancelWinsEventTests(unittest.TestCase): + """Issue #16 PR #17 review round 8, point 2 (BLOCKER). + + ``retry_run_if_owned`` already correctly coerces the transition to + ``cancelled`` when the run is ``cancelling`` at commit time -- it must + never be sent back to ``pending``. But the Worker used to only check + ``updated is not None`` before unconditionally appending a + ``worker_retrying`` event, so a durable "retrying" event could be + appended even though the actual write coerced the run to + ``cancelled``. These tests prove the Worker now branches on the + *actual* resulting status for both retry call sites. + """ + + def test_retry_or_dead_letter_does_not_emit_worker_retrying_when_cancel_wins(self) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + self.assertTrue(await repo.request_cancel("acme", run.id)) + cancelling = await repo.get_run("acme", run.id) + self.assertEqual(cancelling.status, "cancelling") + + worker = Worker(make_registry_double(), repo, worker_id="worker-a") + await worker._retry_or_dead_letter( + claimed, {"code": "delivery_retry", "message": "transient"} + ) + + final = await repo.get_run("acme", run.id) + self.assertEqual(final.status, "cancelled") + events = await repo.list_events("acme", run.id) + self.assertEqual([e for e in events if e.kind == "worker_retrying"], []) + + asyncio.run(scenario()) + + def test_commit_intent_retry_does_not_emit_worker_retrying_when_cancel_wins(self) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + self.assertTrue(await repo.request_cancel("acme", run.id)) + + worker = Worker(make_registry_double(), repo, worker_id="worker-a") + intent = Worker._Intent( + kind="retry", + status=RunStatus.PENDING, + error={"code": "delivery_retry", "message": "transient"}, + ) + committed = await worker._commit_intent(claimed, intent) + self.assertTrue(committed) + + final = await repo.get_run("acme", run.id) + self.assertEqual(final.status, "cancelled") + events = await repo.list_events("acme", run.id) + self.assertEqual([e for e in events if e.kind == "worker_retrying"], []) + + asyncio.run(scenario()) + + def test_ordinary_retry_still_emits_worker_retrying_when_pending(self) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + worker = Worker(make_registry_double(), repo, worker_id="worker-a") + await worker._retry_or_dead_letter( + claimed, {"code": "delivery_retry", "message": "transient"} + ) + + final = await repo.get_run("acme", run.id) + self.assertEqual(final.status, "pending") + events = await repo.list_events("acme", run.id) + self.assertEqual(len([e for e in events if e.kind == "worker_retrying"]), 1) + + asyncio.run(scenario()) + + +class RetryWithEventAtomicityTests(unittest.TestCase): + """Issue #16 PR #17 review round 10, point 1 (BLOCKER). + + ``retry_run_if_owned()`` and the durable ``worker_retrying`` event used + to be two separate transactions, reopening a TOCTOU window between + "the run is truly pending" and "the event says it's retrying". These + tests prove the merged ``retry_run_with_event_if_owned`` closes both + races described in the review. + """ + + def test_race_a_cancel_in_the_gap_leaves_no_late_worker_retrying_event(self) -> None: + """A cancel that would have landed *between* the old two writes + must never let a stale worker append ``worker_retrying`` for a run + that is durably ``cancelled`` -- because there is no longer a gap + for it to land in: the status write and the event are the same + atomic operation.""" + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + # Cancel arrives before the merged retry transaction runs -- + # the run is already ``cancelling`` when the fenced write + # executes, so cancel-wins coercion applies inside the same + # critical section. + self.assertTrue(await repo.request_cancel("acme", run.id)) + + result = await repo.retry_run_with_event_if_owned( + "acme", + run.id, + "worker-a", + claimed.attempt, + error={"code": "delivery_retry", "message": "transient"}, + max_attempts=5, + ) + assert result is not None + updated, event = result + self.assertEqual(updated.status, "cancelled") + self.assertIsNone(event) + + final = await repo.get_run("acme", run.id) + self.assertEqual(final.status, "cancelled") + events = await repo.list_events("acme", run.id) + self.assertEqual([e for e in events if e.kind == "worker_retrying"], []) + + asyncio.run(scenario()) + + def test_race_b_worker_retrying_event_precedes_next_attempts_events(self) -> None: + """Worker A's ``worker_retrying(attempt N)`` event must always be + durable *before* the run becomes externally visible as + ``pending`` -- so a worker B that then claims it (attempt N+1) and + appends its own execution events can never produce an event with a + lower sequence than ``worker_retrying``. Because the merged method + makes both writes atomic, this is true by construction; this test + proves the observable sequencing.""" + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + result = await repo.retry_run_with_event_if_owned( + "acme", + run.id, + "worker-a", + claimed.attempt, + error={"code": "delivery_retry", "message": "transient"}, + max_attempts=5, + ) + assert result is not None + updated, event = result + self.assertEqual(updated.status, "pending") + assert event is not None + self.assertEqual(event.kind, "worker_retrying") + + # Worker B now claims the retried run (attempt N+1) and logs + # its own execution event. + claimed_b = await repo.claim_run("worker-b", lease_seconds=30) + assert claimed_b is not None + self.assertGreater(claimed_b.attempt, claimed.attempt) + next_event = await repo.append_event( + "acme", run.id, "node_started", {"attempt": claimed_b.attempt} + ) + + self.assertLess(event.sequence, next_event.sequence) + + asyncio.run(scenario()) + + +class AtomicSteerAcceptedEventTests(unittest.TestCase): + """Issue #16 PR #17 review round 4, point 2: accepting a steering row + and recording its ``run.steer.accepted`` lifecycle event must be a + single atomic, idempotent unit -- a retried request (same + Idempotency-Key) that finds the steering row already durably created + must still repair a missing ``run.steer.accepted`` event rather than + permanently skip it.""" + + def test_idempotency_key_retry_repairs_a_missing_accepted_event(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + + event, created = await repo.submit_steering( + "acme", + run.id, + kind="user_input", + payload={"m": 1}, + idempotency_key="key-1", + ) + self.assertTrue(created) + accepted = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.accepted" + ] + self.assertEqual(len(accepted), 1) + + # Simulate the pre-fix failure window: the steering row + # committed, but the ``run.steer.accepted`` append never + # happened (e.g. the process crashed / the DB call transiently + # failed right after the row committed). + repo._events[("acme", run.id)] = [ + e for e in repo._events[("acme", run.id)] if e.kind != "run.steer.accepted" + ] + self.assertEqual( + [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.accepted" + ], + [], + ) + + # The client retries with the same Idempotency-Key: the + # steering row already exists (``created`` is False) but the + # gap must still be repaired, not permanently skipped. + retried_event, retried_created = await repo.submit_steering( + "acme", + run.id, + kind="user_input", + payload={"m": 1}, + idempotency_key="key-1", + ) + self.assertFalse(retried_created) + self.assertEqual(retried_event.id, event.id) + accepted_after = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.accepted" + ] + self.assertEqual(len(accepted_after), 1) + self.assertEqual(accepted_after[0].data["steering_event_id"], event.id) + + # A further retry must not duplicate it either. + await repo.submit_steering( + "acme", + run.id, + kind="user_input", + payload={"m": 1}, + idempotency_key="key-1", + ) + accepted_final = [ + e + for e in await repo.list_events("acme", run.id) + if e.kind == "run.steer.accepted" + ] + self.assertEqual(len(accepted_final), 1) + + asyncio.run(scenario()) + + def test_transfer_records_accepted_event_for_the_new_run_atomically(self) -> None: + """The paused-transfer path's ``run.steer.accepted`` must land on + the new run inside the same migration, correlated back to the + original event via ``source_event_id``/``transferred_from_run_id``.""" + + from lingxigraph.server.models import RunCreate + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + old_run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + original, _ = await repo.submit_steering( + "acme", old_run.id, kind="user_input", payload={"m": 1} + ) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + new_run, transferred = await repo.resume_run_with_pending_steering( + "acme", + old_run.thread_id, + assistant, + RunCreate(assistant_id=assistant.id), + old_run.id, + ) + self.assertEqual(len(transferred), 1) + new_events = await repo.list_events("acme", new_run.id) + accepted = [e for e in new_events if e.kind == "run.steer.accepted"] + self.assertEqual(len(accepted), 1) + self.assertEqual(accepted[0].data["steering_event_id"], transferred[0].id) + self.assertEqual(accepted[0].data["transferred_from_run_id"], old_run.id) + self.assertEqual(accepted[0].data["source_event_id"], original.id) + + asyncio.run(scenario()) + + +class IdempotencyKeyReplaySafeAcrossAdmissionGatesTests(unittest.TestCase): + """Issue #16 PR #17 review round 7, point 3. + + A same-Idempotency-Key replay must return the already-existing event + (id/sequence unchanged), never a fresh 409, regardless of whether the + run has since gone finalizing/terminal/superseded -- that is the + entire point of an idempotency key: safely retrying exactly this kind + of uncertain-outcome window. Only a genuinely *new* key against a + finalizing/terminal run should still get 409. + """ + + def test_replay_after_finalizing_returns_same_event_not_409(self) -> None: + from lingxigraph.errors import RunFinalizingError + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + original, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1}, idempotency_key="key-1" + ) + self.assertTrue(created) + + gate_closed = await repo.close_steering("acme", run.id, "worker-a", claimed.attempt) + self.assertTrue(gate_closed) + + # Same key, replayed after the run started finalizing: must + # return the same event, not a 409. + replayed, replayed_created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1}, idempotency_key="key-1" + ) + self.assertFalse(replayed_created) + self.assertEqual(replayed.id, original.id) + self.assertEqual(replayed.sequence, original.sequence) + + # A genuinely new key against the still-finalizing run must + # still be rejected -- this is a real new-admission decision. + with self.assertRaises(RunFinalizingError): + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 2}, idempotency_key="key-2" + ) + + asyncio.run(scenario()) + + def test_replay_after_terminal_returns_same_event_not_409(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + + original, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1}, idempotency_key="key-1" + ) + self.assertTrue(created) + + await repo.finish_run("acme", run.id, "succeeded", output={}) + + replayed, replayed_created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1}, idempotency_key="key-1" + ) + self.assertFalse(replayed_created) + self.assertEqual(replayed.id, original.id) + self.assertEqual(replayed.sequence, original.sequence) + + with self.assertRaises(RunTerminalError): + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 2}, idempotency_key="key-2" + ) + + asyncio.run(scenario()) + + +class InMemoryRepositorySteeringEdgeCaseTests(unittest.TestCase): + """Direct unit coverage of ``InMemoryRepository`` steering branches that + the HTTP/worker end-to-end tests above don't happen to exercise: unknown + runs, empty batches, mixed-status transfers, and idempotency-keyed + transfers.""" + + def test_submit_steering_on_unknown_run_raises_key_error(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + with self.assertRaises(KeyError): + await repo.submit_steering("acme", "no-such-run", kind="user_input", payload={}) + + asyncio.run(scenario()) + + def test_mark_steering_consumed_with_empty_ids_is_a_no_op(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + await repo.submit_steering("acme", run.id, kind="user_input", payload={}) + # Should return without touching anything. + await repo.mark_steering_consumed("acme", run.id, []) + pending = await repo.list_pending_steering("acme", run.id) + self.assertEqual(len(pending), 1) + + asyncio.run(scenario()) + + def test_commit_steering_consumptions_with_empty_list_returns_empty(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + result = await repo.commit_steering_consumptions("acme", run.id, []) + self.assertEqual(result, []) + + asyncio.run(scenario()) + + def test_commit_steering_consumptions_skips_non_matching_ids(self) -> None: + """The per-event update loop must keep scanning past events that + don't match any id in the batch, not just check the first one.""" + + from lingxigraph.steering import SteeringEvent + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + e1, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"i": 1} + ) + e2, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"i": 2} + ) + + consumption = SteeringConsumption( + event=SteeringEvent( + id=e2.id, + run_id=run.id, + sequence=e2.sequence, + kind=e2.kind, + payload=e2.payload, + metadata=e2.metadata, + created_at=e2.created_at, + ), + consumed_at=e2.created_at, + node="the_node", + ) + stored = await repo.commit_steering_consumptions("acme", run.id, [consumption]) + self.assertEqual(len(stored), 1) + self.assertEqual(stored[0].data["steering_event_id"], e2.id) + + statuses = { + event.id: event.status for event in await repo.list_steering("acme", run.id) + } + self.assertEqual(statuses[e1.id], "pending") + self.assertEqual(statuses[e2.id], "consumed") + + asyncio.run(scenario()) + + def test_resume_rejects_a_missing_old_run(self) -> None: + """Issue #16 PR #17 review round 4, point 1: the locked + revalidation inside ``resume_run_with_pending_steering`` must + reject (rather than silently proceed for) an old run that is no + longer present -- it can no longer prove the run was ``paused``.""" + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + # Simulate the old run row being gone by the time the resume + # migration runs. + del repo._runs[("acme", run.id)] + with self.assertRaises(RunResumeConflictError): + await repo.resume_run_with_pending_steering( + "acme", None, assistant, _run_create(assistant.id), run.id + ) + + asyncio.run(scenario()) + + def test_transfer_preserves_idempotency_key_and_skips_already_consumed( + self, + ) -> None: + """Only ``pending``/``delivered`` events transfer; an already + ``consumed`` event is left behind untouched, and a transferred + event's idempotency key must still dedupe under the new run id.""" + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + old_run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + consumed_event, _ = await repo.submit_steering( + "acme", old_run.id, kind="user_input", payload={"i": "done"} + ) + await repo.mark_steering_consumed("acme", old_run.id, [consumed_event.id]) + pending_event, _ = await repo.submit_steering( + "acme", + old_run.id, + kind="user_input", + payload={"i": "pending"}, + idempotency_key="key-1", + ) + await repo.finish_run("acme", old_run.id, "paused", output={}) + + new_run, transferred = await repo.resume_run_with_pending_steering( + "acme", None, assistant, _run_create(assistant.id), old_run.id + ) + + self.assertEqual(len(transferred), 1) + self.assertEqual(transferred[0].source_event_id, pending_event.id) + self.assertEqual(transferred[0].idempotency_key, "key-1") + + old_events = { + event.id: event.status for event in await repo.list_steering("acme", old_run.id) + } + self.assertEqual(old_events[consumed_event.id], "consumed") + self.assertEqual(old_events[pending_event.id], "superseded") + + # Resubmitting with the same idempotency key under the *new* + # run id must dedupe against the transferred row, proving the + # transfer registered it in ``_steering_keys`` for the new id. + dup_event, created = await repo.submit_steering( + "acme", + new_run.id, + kind="user_input", + payload={"different": True}, + idempotency_key="key-1", + ) + self.assertFalse(created) + self.assertEqual(dup_event.id, transferred[0].id) + + asyncio.run(scenario()) + + +class InMemoryRepositoryRunLifecycleEdgeCaseTests(unittest.TestCase): + """Direct unit coverage of ``InMemoryRepository`` run-lifecycle branches + unrelated to steering payloads but exercised alongside them in the same + module (queue quotas, redrive, and claim blocking).""" + + def test_redrive_run_returns_none_for_non_redrivable_status(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + # A freshly created run is "pending", not dead_letter/failed. + result = await repo.redrive_run("acme", run.id) + self.assertIsNone(result) + + asyncio.run(scenario()) + + def test_create_run_respects_run_timeout_and_budget_config(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run( + "acme", + None, + assistant, + _run_create(assistant.id).model_copy( + update={"run_timeout": 42, "max_tool_calls": 3} + ), + ) + self.assertEqual(run.config["run_timeout"], 42) + self.assertEqual(run.config["max_tool_calls"], 3) + + asyncio.run(scenario()) + + def test_create_run_queued_quota_exceeded_raises(self) -> None: + async def scenario() -> None: + from lingxigraph.errors import ConcurrentRunError + from lingxigraph.server.repository import RepositoryLimits + + repo = InMemoryRepository( + limits=RepositoryLimits(max_active_runs=10, max_queued_runs=1) + ) + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + # First run stays "pending" (queued) since nothing claims it. + await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + with self.assertRaises(ConcurrentRunError): + await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + + asyncio.run(scenario()) + + def test_claim_run_skips_thread_blocked_pending_run(self) -> None: + """A pending run whose thread already has an active run must be + skipped by ``claim_run`` (blocked branch), leaving it unclaimed.""" + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + thread = await repo.create_thread("acme", ThreadCreate()) + first = await repo.create_run( + "acme", thread.id, assistant, _run_create(assistant.id) + ) + claimed_first = await repo.claim_run("worker-1") + self.assertEqual(claimed_first.id, first.id) + + await repo.create_run("acme", thread.id, assistant, _run_create(assistant.id)) + # `second` is pending on the same thread as the still-running + # `first`, so claim_run must skip it and return None. + claimed_second = await repo.claim_run("worker-2") + self.assertIsNone(claimed_second) + + asyncio.run(scenario()) + + +def _assistant_create(): + from lingxigraph.server.models import AssistantCreate + + return AssistantCreate(graph_id="double") + + +def _run_create(assistant_id: str, input: dict | None = None): + from lingxigraph.server.models import RunCreate + + return RunCreate( + assistant_id=assistant_id, input=input if input is not None else {"value": 1} + ) + + +MAX_STEERABLE_TICKS = 15 + + +def make_registry() -> GraphRegistry: + """A graph that loops draining steering until told to stop. + + Each tick sleeps briefly so a concurrently-running test has a real + wall-clock window to submit steering input over HTTP before the graph + reaches its tick cap -- mirrors a real "keep working until steered" + agent loop. + """ + + builder = StateGraph(State) + + async def wait_for_stop(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + await asyncio.sleep(0.05) + drained = [event.payload.get("message", "") for event in runtime.drain_steering()] + merged = list(state.get("drained", [])) + drained + return {"ticks": state.get("ticks", 0) + 1, "drained": merged} + + def should_continue(state: State) -> str: + if state.get("drained") and "stop" in state["drained"]: + return END + if state.get("ticks", 0) >= MAX_STEERABLE_TICKS: + return END + return "wait_for_stop" + + builder.add_node("wait_for_stop", wait_for_stop) + builder.add_edge(START, "wait_for_stop") + builder.add_conditional_edges("wait_for_stop", should_continue) + return GraphRegistry({"steerable": builder.compile()}) + + +def make_registry_double() -> GraphRegistry: + """A trivial, near-instant single-node graph registered as ``double``. + + Used by the round 6 finalization-fencing/gate tests, which need the + graph to reach its terminal outcome (and the worker to enter its + finalization window) almost immediately, without any steering to + drain along the way. + """ + + builder = StateGraph(State) + + async def double(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + return {"ticks": state.get("ticks", 0) + 1} + + builder.add_node("double", double) + builder.add_edge(START, "double") + builder.add_edge("double", END) + return GraphRegistry({"double": builder.compile()}) + + +class ServerSteeringTests(unittest.TestCase): + """End-to-end: HTTP accept -> durable inbox -> worker -> graph safe point.""" + + def test_running_run_receives_steer_and_consumes_it(self) -> None: + app = create_app( + registry=make_registry(), + authenticator=Authenticator.insecure_dev(), + embedded_worker=True, + ) + # Fast heartbeat so mid-run steering syncs promptly for the test; + # production defaults to a longer interval. + app.state.worker.heartbeat_seconds = 0.02 + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "steerable"} + ).json() + run = client.post( + "/v1/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0, "drained": []}}, + ).json() + run_id = run["id"] + + # Wait until the worker has actually claimed and started the run. + wait_for_status(client, run_id, headers, {"running"}, attempts=200) + + accepted = client.post( + f"/v1/runs/{run_id}/steer", + headers=headers, + json={"kind": "user_input", "payload": {"message": "keep-going"}}, + ) + self.assertEqual(accepted.status_code, 202, accepted.text) + self.assertEqual(accepted.json()["sequence"], 1) + self.assertIn(accepted.json()["status"], {"pending", "delivered"}) + + stop = client.post( + f"/v1/runs/{run_id}/steer", + headers=headers, + json={"kind": "user_input", "payload": {"message": "stop"}}, + ) + self.assertEqual(stop.status_code, 202) + + result = wait_for_status(client, run_id, headers, {"succeeded", "failed"}) + self.assertEqual(result.json()["status"], "succeeded", result.text) + self.assertIn("keep-going", result.json()["output"]["drained"]) + self.assertIn("stop", result.json()["output"]["drained"]) + + events = asyncio.run(app.state.repository.list_events("acme", run_id)) + kinds = [event.kind for event in events] + self.assertIn("run.steer.accepted", kinds) + self.assertIn("run.steer.consumed", kinds) + # accepted must precede consumed for the lifecycle to make sense. + self.assertLess( + kinds.index("run.steer.accepted"), kinds.index("run.steer.consumed") + ) + + def test_duplicate_idempotency_key_does_not_duplicate(self) -> None: + app = create_app(registry=make_registry(), authenticator=Authenticator.insecure_dev()) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "steerable"} + ).json() + run = client.post( + "/v1/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0, "drained": []}}, + ).json() + run_id = run["id"] + first = client.post( + f"/v1/runs/{run_id}/steer", + headers={**headers, "Idempotency-Key": "msg-123"}, + json={"kind": "user_input", "payload": {"message": "hi"}}, + ).json() + second = client.post( + f"/v1/runs/{run_id}/steer", + headers={**headers, "Idempotency-Key": "msg-123"}, + json={"kind": "user_input", "payload": {"message": "hi-again"}}, + ).json() + self.assertEqual(first["id"], second["id"]) + self.assertEqual(first["sequence"], second["sequence"]) + pending = asyncio.run(app.state.repository.list_pending_steering("acme", run_id)) + self.assertEqual(len(pending), 1) + + def test_terminal_run_returns_conflict_not_silent_event(self) -> None: + app = create_app(registry=make_registry(), authenticator=Authenticator.insecure_dev()) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "steerable"} + ).json() + run = client.post( + "/v1/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0, "drained": []}}, + ).json() + run_id = run["id"] + cancelled = client.post(f"/v1/runs/{run_id}/cancel", headers=headers) + self.assertEqual(cancelled.json()["status"], "cancelled") + rejected = client.post( + f"/v1/runs/{run_id}/steer", + headers=headers, + json={"kind": "user_input", "payload": {}}, + ) + self.assertEqual(rejected.status_code, 409) + self.assertEqual(rejected.json()["code"], "run_terminal") + + def test_missing_run_returns_404(self) -> None: + app = create_app(registry=make_registry(), authenticator=Authenticator.insecure_dev()) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + response = client.post( + "/v1/runs/does-not-exist/steer", + headers=headers, + json={"kind": "user_input", "payload": {}}, + ) + self.assertEqual(response.status_code, 404) + + def test_payload_too_large_rejected(self) -> None: + app = create_app(registry=make_registry(), authenticator=Authenticator.insecure_dev()) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "steerable"} + ).json() + run = client.post( + "/v1/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0, "drained": []}}, + ).json() + response = client.post( + f"/v1/runs/{run['id']}/steer", + headers=headers, + json={"kind": "user_input", "payload": {"message": "x" * 500_000}}, + ) + self.assertEqual(response.status_code, 413) + + def test_payload_between_32kb_and_generic_event_cap_still_rejected(self) -> None: + """Issue #16 PR #17 review round 5, point 3 (CONTRACT): the public + docs and ``steering.MAX_STEERING_PAYLOAD_BYTES`` both promise a + ~32KB cap, but the HTTP endpoint used to pass + ``repository.limits.max_event_bytes`` (262,144 bytes) as the + payload ceiling instead, silently accepting payloads up to 8x the + documented limit. A ~500KB payload (comfortably over *both* + numbers) couldn't have caught this ~40KB-sized contract mismatch; + this uses a payload sized specifically between the two limits.""" + + from lingxigraph.steering import MAX_STEERING_PAYLOAD_BYTES + + app = create_app(registry=make_registry(), authenticator=Authenticator.insecure_dev()) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "steerable"} + ).json() + run = client.post( + "/v1/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0, "drained": []}}, + ).json() + oversized_message = "x" * (MAX_STEERING_PAYLOAD_BYTES + 8_000) + self.assertLess(len(oversized_message), 262_144) + response = client.post( + f"/v1/runs/{run['id']}/steer", + headers=headers, + json={"kind": "user_input", "payload": {"message": oversized_message}}, + ) + self.assertEqual(response.status_code, 413) + + def test_queued_run_can_receive_steer_before_worker_claims_it(self) -> None: + app = create_app(registry=make_registry(), authenticator=Authenticator.insecure_dev()) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "steerable"} + ).json() + run = client.post( + "/v1/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0, "drained": []}}, + ).json() + self.assertEqual( + client.get(f"/v1/runs/{run['id']}", headers=headers).json()["status"], "pending" + ) + accepted = client.post( + f"/v1/runs/{run['id']}/steer", + headers=headers, + json={"kind": "user_input", "payload": {"message": "stop"}}, + ) + self.assertEqual(accepted.status_code, 202) + + worker = app.state.worker + asyncio.run(worker.run_once()) + asyncio.run(worker.run_once()) + result = wait_for_status(client, run["id"], headers, {"succeeded", "failed"}) + self.assertEqual(result.json()["status"], "succeeded", result.text) + self.assertIn("stop", result.json()["output"]["drained"]) + + def test_worker_restart_recovers_pending_steering_from_postgres_shaped_repo(self) -> None: + """Simulates a worker crash: a fresh Worker resumes from the repository.""" + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + registry = make_registry() + repository = InMemoryRepository() + assistant = await repository.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repository.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0, "drained": []}) + ) + # Durable accept happens *before* any worker exists yet. + await repository.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + + crashed_worker = Worker(registry, repository, heartbeat_seconds=0.05) + claimed = await crashed_worker.run_once() + self.assertTrue(claimed) + finished = await repository.get_run("acme", run.id) + self.assertEqual( + finished.status.value if hasattr(finished.status, "value") else finished.status, + "succeeded", + ) + self.assertIn("stop", finished.output["drained"]) + + asyncio.run(scenario()) + + def test_redis_notify_loss_does_not_lose_steering(self) -> None: + """The worker heartbeat re-polls PostgreSQL regardless of eventbus.""" + + from lingxigraph.server.eventbus import EventBus + from lingxigraph.server.worker import Worker + + class BlackHoleEventBus(EventBus): + async def publish(self, tenant_id: str, run_id: str, sequence: int) -> None: + return None + + async def wait(self, tenant_id: str, run_id: str, *, timeout: float = 15.0) -> None: + return None + + async def scenario() -> None: + registry = make_registry() + repository = InMemoryRepository() + assistant = await repository.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repository.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0, "drained": []}) + ) + worker = Worker( + registry, repository, heartbeat_seconds=0.05, event_bus=BlackHoleEventBus() + ) + task = asyncio.create_task(worker.run_once()) + await asyncio.sleep(0.05) + await repository.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + await task + finished = await repository.get_run("acme", run.id) + status = ( + finished.status.value if hasattr(finished.status, "value") else finished.status + ) + self.assertEqual(status, "succeeded") + self.assertIn("stop", finished.output["drained"]) + + asyncio.run(scenario()) + + def test_cancel_takes_priority_over_steer(self) -> None: + """Steering must never undo or delay a cancel request. + + Exercised on a still-queued run (no worker claims it): the durable + steer is accepted, and the subsequent cancel still takes effect + immediately and unconditionally -- a prior steer never blocks it. + """ + + app = create_app(registry=make_registry(), authenticator=Authenticator.insecure_dev()) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "steerable"} + ).json() + run = client.post( + "/v1/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0, "drained": []}}, + ).json() + run_id = run["id"] + accepted = client.post( + f"/v1/runs/{run_id}/steer", + headers=headers, + json={"kind": "user_input", "payload": {"message": "keep-going"}}, + ) + self.assertEqual(accepted.status_code, 202) + cancel = client.post(f"/v1/runs/{run_id}/cancel", headers=headers) + self.assertEqual(cancel.status_code, 200) + self.assertEqual(cancel.json()["status"], "cancelled") + # The steer stays durably recorded (never rewound by cancel) but + # will simply never be delivered since the run never executes. + pending = asyncio.run(app.state.repository.list_pending_steering("acme", run_id)) + self.assertEqual(len(pending), 1) + + def test_paused_run_steer_is_transferred_and_consumed_after_resume(self) -> None: + """Documented choice (option B): steer during pause is durably + accepted under the *paused* run_id, but ``/resume`` creates a brand + new Run row -- so at resume time any still-pending steering is + atomically transferred onto the new run_id and delivered/consumed + by the graph there. This is the end-to-end regression for the + blocking review point: the old test only asserted resume succeeded, + never that the paused steer was actually drained/consumed.""" + + from lingxigraph import interrupt + + paused_builder = StateGraph(State) + + def approval(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + drained_before = [event.payload for event in runtime.peek_steering()] + value = interrupt({"question": "approve?", "seen_before_resume": drained_before}) + return {"ticks": int(value)} + + def after_approval(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + # Proves the transferred event is actually delivered to *this* + # (new) run's safe point, not merely accepted somewhere. + drained = [event.payload["message"] for event in runtime.drain_steering()] + return {"drained": drained} + + paused_builder.add_node("approval", approval) + paused_builder.add_node("after_approval", after_approval) + paused_builder.add_edge(START, "approval").add_edge("approval", "after_approval") + paused_builder.add_edge("after_approval", END) + registry = GraphRegistry({"approval": paused_builder.compile()}) + + app = create_app( + registry=registry, authenticator=Authenticator.insecure_dev(), embedded_worker=True + ) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "approval"} + ).json() + thread = client.post("/v1/threads", headers=headers, json={}).json() + run = client.post( + f"/v1/threads/{thread['id']}/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0}}, + ).json() + run_id = run["id"] + paused = wait_for_status(client, run_id, headers, {"paused"}) + self.assertEqual(paused.json()["status"], "paused") + + accepted = client.post( + f"/v1/runs/{run_id}/steer", + headers=headers, + json={"kind": "user_input", "payload": {"message": "while-paused"}}, + ) + self.assertEqual(accepted.status_code, 202) + pending = asyncio.run(app.state.repository.list_pending_steering("acme", run_id)) + self.assertEqual(len(pending), 1) + + resumed = client.post( + f"/v1/runs/{run_id}/resume", headers=headers, json={"resume": 9} + ).json() + new_run_id = resumed["id"] + self.assertNotEqual(new_run_id, run_id) + result = wait_for_status(client, new_run_id, headers, {"succeeded", "failed"}) + self.assertEqual(result.json()["status"], "succeeded", result.text) + + # The graph actually saw and drained the while-paused event + # under the *new* run. + self.assertEqual(result.json()["output"]["drained"], ["while-paused"]) + + # The old run's steering row is terminal ("superseded"), not + # stuck "pending" forever -- and the new run's inbox shows it + # went pending -> consumed there. + old_events = asyncio.run(app.state.repository.list_steering("acme", run_id)) + self.assertEqual([event.status for event in old_events], ["superseded"]) + new_events = asyncio.run(app.state.repository.list_steering("acme", new_run_id)) + self.assertEqual(len(new_events), 1) + self.assertEqual(new_events[0].status, "consumed") + self.assertEqual(new_events[0].payload, {"message": "while-paused"}) + + new_run_events = asyncio.run(app.state.repository.list_events("acme", new_run_id)) + kinds = [event.kind for event in new_run_events] + self.assertIn("run.steer.accepted", kinds) + self.assertIn("run.steer.consumed", kinds) + consumed_event = next( + event for event in new_run_events if event.kind == "run.steer.consumed" + ) + self.assertEqual(consumed_event.data["node"], "after_approval") + self.assertIn("queue_latency_seconds", consumed_event.data) + self.assertGreaterEqual(consumed_event.data["queue_latency_seconds"], 0) + + # Further steer attempts against the now-superseded old run_id + # are rejected loudly instead of silently pending again. + stale = client.post( + f"/v1/runs/{run_id}/steer", + headers=headers, + json={"kind": "user_input", "payload": {"message": "too-late"}}, + ) + self.assertEqual(stale.status_code, 409) + self.assertEqual(stale.json()["code"], "run_superseded") + + def test_resume_with_no_pending_steering_still_marks_old_run_superseded(self) -> None: + """Even with nothing to transfer, resume closes the old run_id off + from future steering so it cannot silently pend forever a second + time (see RunSupersededError).""" + + from lingxigraph import interrupt + + builder = StateGraph(State) + + def approval(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + value = interrupt({"question": "approve?"}) + return {"ticks": int(value)} + + builder.add_node("approval", approval) + builder.add_edge(START, "approval").add_edge("approval", END) + registry = GraphRegistry({"approval": builder.compile()}) + + app = create_app( + registry=registry, authenticator=Authenticator.insecure_dev(), embedded_worker=True + ) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "approval"} + ).json() + thread = client.post("/v1/threads", headers=headers, json={}).json() + run = client.post( + f"/v1/threads/{thread['id']}/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0}}, + ).json() + run_id = run["id"] + wait_for_status(client, run_id, headers, {"paused"}) + + resumed = client.post( + f"/v1/runs/{run_id}/resume", headers=headers, json={"resume": 1} + ).json() + wait_for_status(client, resumed["id"], headers, {"succeeded", "failed"}) + + stale = client.post( + f"/v1/runs/{run_id}/steer", + headers=headers, + json={"kind": "user_input", "payload": {}}, + ) + self.assertEqual(stale.status_code, 409) + self.assertEqual(stale.json()["code"], "run_superseded") + + def test_paused_steer_identity_and_latency_survive_resume_transfer(self) -> None: + """Issue #16 PR #17 review point 3: a paused-run transfer must + preserve the *original* event's identity and acceptance time. + + Proves both halves of the fix end to end: + * The id a client's paused ``/steer`` call got back + (``accepted_id``) is traceable, via ``source_event_id``, all the + way through the resumed run's ``run.steer.accepted`` and + ``run.steer.consumed`` events -- never silently replaced by an + unrelated new id. + * ``queue_latency_seconds`` on the eventual ``consumed`` event is + computed from the *original* acceptance time, so it includes the + time the event spent waiting while the run was paused, not just + the time since the resume/transfer moment. + """ + + from lingxigraph import interrupt + + paused_builder = StateGraph(State) + + def approval(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + value = interrupt({"question": "approve?"}) + return {"ticks": int(value)} + + def after_approval(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + drained = [event.payload["message"] for event in runtime.drain_steering()] + return {"drained": drained} + + paused_builder.add_node("approval", approval) + paused_builder.add_node("after_approval", after_approval) + paused_builder.add_edge(START, "approval").add_edge("approval", "after_approval") + paused_builder.add_edge("after_approval", END) + registry = GraphRegistry({"approval": paused_builder.compile()}) + + app = create_app( + registry=registry, authenticator=Authenticator.insecure_dev(), embedded_worker=True + ) + headers = {"x-tenant-id": "acme"} + with TestClient(app) as client: + assistant = client.post( + "/v1/assistants", headers=headers, json={"graph_id": "approval"} + ).json() + thread = client.post("/v1/threads", headers=headers, json={}).json() + run = client.post( + f"/v1/threads/{thread['id']}/runs", + headers=headers, + json={"assistant_id": assistant["id"], "input": {"ticks": 0}}, + ).json() + run_id = run["id"] + wait_for_status(client, run_id, headers, {"paused"}) + + accepted = client.post( + f"/v1/runs/{run_id}/steer", + headers=headers, + json={"kind": "user_input", "payload": {"message": "while-paused"}}, + ).json() + accepted_id = accepted["id"] + + # A real pause-wait window: the queue latency computed after + # resume must include (at least) this much time. + pause_wait_seconds = 0.3 + time.sleep(pause_wait_seconds) + + resumed = client.post( + f"/v1/runs/{run_id}/resume", headers=headers, json={"resume": 9} + ).json() + new_run_id = resumed["id"] + result = wait_for_status(client, new_run_id, headers, {"succeeded", "failed"}) + self.assertEqual(result.json()["status"], "succeeded", result.text) + + new_events = asyncio.run(app.state.repository.list_events("acme", new_run_id)) + accepted_transfer = next( + event for event in new_events if event.kind == "run.steer.accepted" + ) + # The transferred event's id changed (it is a new durable row), + # but its source_event_id must point back at the exact id the + # client received from its paused-run /steer call. + self.assertNotEqual(accepted_transfer.data["steering_event_id"], accepted_id) + self.assertEqual(accepted_transfer.data["source_event_id"], accepted_id) + + consumed = next(event for event in new_events if event.kind == "run.steer.consumed") + self.assertEqual(consumed.data["source_event_id"], accepted_id) + self.assertGreaterEqual( + consumed.data["queue_latency_seconds"], pause_wait_seconds * 0.9 + ) + + steering_rows = asyncio.run(app.state.repository.list_steering("acme", new_run_id)) + self.assertEqual(steering_rows[0].source_event_id, accepted_id) + + def test_double_pause_resume_preserves_root_source_event_id(self) -> None: + """Issue #16 PR #17 review round 3, point 2: a *second* pause and + resume (A -> B -> C) must not overwrite the root ``source_event_id`` + with the intermediate hop's id -- C's migrated event must still + correlate back to A, not B.""" + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + from lingxigraph.server.models import RunCreate + + run_a = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + event_a, _ = await repo.submit_steering( + "acme", run_a.id, kind="user_input", payload={"m": 1} + ) + self.assertIsNone(event_a.source_event_id) + await repo.finish_run("acme", run_a.id, "paused", output={}) + + run_b, transferred_b = await repo.resume_run_with_pending_steering( + "acme", + run_a.thread_id, + assistant, + RunCreate(assistant_id=assistant.id, resume=1), + run_a.id, + ) + self.assertEqual(len(transferred_b), 1) + event_b = transferred_b[0] + self.assertEqual(event_b.source_event_id, event_a.id) + self.assertEqual(event_b.created_at, event_a.created_at) + await repo.finish_run("acme", run_b.id, "paused", output={}) + + run_c, transferred_c = await repo.resume_run_with_pending_steering( + "acme", + run_b.thread_id, + assistant, + RunCreate(assistant_id=assistant.id, resume=1), + run_b.id, + ) + self.assertEqual(len(transferred_c), 1) + event_c = transferred_c[0] + # Root identity A must survive, never be replaced by B's id. + self.assertEqual(event_c.source_event_id, event_a.id) + self.assertEqual(event_c.created_at, event_a.created_at) + + from lingxigraph.steering import SteeringConsumption, SteeringEvent + + steering_event = SteeringEvent( + id=event_c.id, + run_id=run_c.id, + sequence=event_c.sequence, + kind=event_c.kind, + payload=event_c.payload, + metadata=event_c.metadata, + created_at=event_c.created_at, + source_event_id=event_c.source_event_id, + ) + consumption = SteeringConsumption( + event=steering_event, + consumed_at=steering_event.created_at, + node="n", + namespace=(), + task_id="t", + ) + stored = await repo.commit_steering_consumptions("acme", run_c.id, [consumption]) + self.assertEqual(len(stored), 1) + self.assertEqual(stored[0].data["source_event_id"], event_a.id) + + asyncio.run(scenario()) + + def test_commit_steering_consumptions_idempotent_on_retry(self) -> None: + """Issue #16 PR #17 review round 3, point 3: calling + ``commit_steering_consumptions`` twice with the same batch (a + worker retrying after an ack it never observed) must produce only + one ``run.steer.consumed`` lifecycle event, not two.""" + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + event, _ = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"m": 1} + ) + + from lingxigraph.steering import SteeringConsumption, SteeringEvent + + steering_event = SteeringEvent( + id=event.id, + run_id=run.id, + sequence=event.sequence, + kind=event.kind, + payload=event.payload, + metadata=event.metadata, + created_at=event.created_at, + ) + consumption = SteeringConsumption( + event=steering_event, + consumed_at=steering_event.created_at, + node="n", + namespace=(), + task_id="t", + ) + first = await repo.commit_steering_consumptions("acme", run.id, [consumption]) + self.assertEqual(len(first), 1) + second = await repo.commit_steering_consumptions("acme", run.id, [consumption]) + self.assertEqual(second, []) + + all_events = await repo.list_events("acme", run.id) + consumed = [e for e in all_events if e.kind == "run.steer.consumed"] + self.assertEqual(len(consumed), 1) + + asyncio.run(scenario()) + + +class WorkerFencedWriteLeaseLostBranchTests(unittest.TestCase): + """Coverage for the "lease no longer owned" branches of every fenced + write the Worker performs during finalization -- these can only be + reached by simulating a lease loss between the Worker deciding what to + do and the repository actually committing it, so they're exercised + here by monkeypatching the fenced repository methods to return + ``None`` (exactly what a losing CAS/fence looks like from the + caller's perspective), rather than by orchestrating a real multi- + worker race (already covered elsewhere against real Postgres).""" + + def test_retry_or_dead_letter_abandons_when_retry_lease_lost(self) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + worker = Worker(make_registry_double(), repo, worker_id="worker-a") + + async def lost_lease(*args: Any, **kwargs: Any) -> None: + return None + + repo.retry_run_with_event_if_owned = lost_lease # type: ignore[method-assign] + await worker._retry_or_dead_letter( + claimed, {"code": "delivery_retry", "message": "transient"} + ) + # No exception, no crash, and (since the fenced write never + # touched anything) the run's real status is whatever it was + # before -- nothing durable to assert beyond "this returned". + + asyncio.run(scenario()) + + def test_retry_or_dead_letter_abandons_when_dead_letter_lease_lost(self) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + claimed.attempt = 999 # force the max-attempts (dead-letter) branch + + worker = Worker( + make_registry_double(), repo, worker_id="worker-a", max_delivery_attempts=1 + ) + + async def lost_lease(*args: Any, **kwargs: Any) -> None: + return None + + repo.finish_run_if_owned = lost_lease # type: ignore[method-assign] + await worker._retry_or_dead_letter( + claimed, {"code": "delivery_retry", "message": "transient"} + ) + + asyncio.run(scenario()) + + def test_commit_intent_finalize_abandons_when_lease_lost(self) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("double"), graph_version="1" + ) + await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0}) + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + worker = Worker(make_registry_double(), repo, worker_id="worker-a") + + async def lost_lease(*args: Any, **kwargs: Any) -> None: + return None + + repo.finish_run_if_owned = lost_lease # type: ignore[method-assign] + intent = Worker._Intent(kind="finish", status=RunStatus.SUCCEEDED, output={}) + committed = await worker._commit_intent(claimed, intent) + self.assertFalse(committed) + + asyncio.run(scenario()) + + def test_final_steering_flush_returns_false_when_heartbeat_confirms_lease_lost( + self, + ) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + registry = make_registry() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repo.create_run( + "acme", + None, + assistant, + _run_create(assistant.id, {"ticks": 0, "drained": []}), + ) + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + graph = registry.get("steerable").with_runtime() + channel = graph.get_steering_channel(run.id) + worker = Worker(make_registry(), repo, worker_id="worker-a") + await worker._sync_steering_in(claimed, graph) + channel.drain() + + async def always_fails(*args: Any, **kwargs: Any) -> None: + raise ConnectionError("transient") + + async def lease_gone(*args: Any, **kwargs: Any) -> bool: + return False + + repo.commit_steering_consumptions_if_owned = always_fails # type: ignore[method-assign] + repo.heartbeat = lease_gone # type: ignore[method-assign] + result = await worker._final_steering_flush( + claimed, graph, base_delay=0.001, max_delay=0.001 + ) + self.assertFalse(result) + + asyncio.run(scenario()) + + def test_final_steering_flush_returns_false_when_commit_reports_lease_lost(self) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + registry = make_registry() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repo.create_run( + "acme", + None, + assistant, + _run_create(assistant.id, {"ticks": 0, "drained": []}), + ) + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + graph = registry.get("steerable").with_runtime() + channel = graph.get_steering_channel(run.id) + worker = Worker(make_registry(), repo, worker_id="worker-a") + await worker._sync_steering_in(claimed, graph) + channel.drain() + + async def lost_lease(*args: Any, **kwargs: Any) -> None: + return None + + repo.commit_steering_consumptions_if_owned = lost_lease # type: ignore[method-assign] + result = await worker._final_steering_flush(claimed, graph) + self.assertFalse(result) + + asyncio.run(scenario()) + + def test_sync_steering_out_skips_ack_when_commit_fails_transiently(self) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + registry = make_registry() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repo.create_run( + "acme", + None, + assistant, + _run_create(assistant.id, {"ticks": 0, "drained": []}), + ) + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + graph = registry.get("steerable").with_runtime() + channel = graph.get_steering_channel(run.id) + worker = Worker(make_registry(), repo, worker_id="worker-a") + await worker._sync_steering_in(claimed, graph) + channel.drain() + + async def always_fails(*args: Any, **kwargs: Any) -> None: + raise ConnectionError("transient") + + repo.commit_steering_consumptions_if_owned = always_fails # type: ignore[method-assign] + await worker._sync_steering_out(claimed, graph) + self.assertEqual(len(channel.peek_consumed()), 1) + + asyncio.run(scenario()) + + def test_sync_steering_out_skips_ack_when_lease_lost(self) -> None: + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + registry = make_registry() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("steerable"), graph_version="1" + ) + run = await repo.create_run( + "acme", + None, + assistant, + _run_create(assistant.id, {"ticks": 0, "drained": []}), + ) + await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "stop"} + ) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + + graph = registry.get("steerable").with_runtime() + channel = graph.get_steering_channel(run.id) + worker = Worker(make_registry(), repo, worker_id="worker-a") + await worker._sync_steering_in(claimed, graph) + channel.drain() + + async def lost_lease(*args: Any, **kwargs: Any) -> None: + return None + + repo.commit_steering_consumptions_if_owned = lost_lease # type: ignore[method-assign] + await worker._sync_steering_out(claimed, graph) + self.assertEqual(len(channel.peek_consumed()), 1) + + asyncio.run(scenario()) + + def test_delivery_retry_recovers_steering_not_yet_drained(self) -> None: + """Issue #16 PR #17 review round 9, point 1 (BLOCKER). + + A REAL Worker-level delivery-retry regression -- not a graph-level + ``RetryPolicy`` test (a node-level retry never leaves the Worker's + ``_execute`` at all, so it can never exercise the + ``closing_steering``/``intent.kind == "retry"`` scoping bug this + covers). Steering E is durably pending; attempt 1's graph node + raises a retryable exception *before* calling ``drain_steering()`` + at all, so the exception unwinds all the way out of + ``graph.astream`` and into ``Worker._execute``'s ``except + Exception`` branch, producing a ``kind="retry"`` intent. Before the + round 9 fix, ``closing_steering`` was true for *any* non-paused + intent (including retry), so this sequence would close steering + admission and supersede E -- permanently losing it -- the instant + before the Run was sent back to ``pending``. Assert E survives: it + is still ``pending``/``delivered`` (never ``superseded``) after + attempt 1, and attempt 2 (a fresh ``claim_run()``, mirroring a real + redelivery) re-ingests and drains it successfully. + """ + + from lingxigraph.server.worker import Worker + + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create_named("flaky_before_drain"), graph_version="1" + ) + run = await repo.create_run( + "acme", None, assistant, _run_create(assistant.id, {"ticks": 0, "drained": []}) + ) + accepted, created = await repo.submit_steering( + "acme", run.id, kind="user_input", payload={"message": "hello"} + ) + self.assertTrue(created) + + registry, attempts, drained_log = make_registry_flaky_before_drain() + worker = Worker(registry, repo, worker_id="worker-a", max_delivery_attempts=5) + + # Attempt 1: the node raises a retryable exception before ever + # calling drain_steering() -- the run must go back to pending, + # and E must remain fully recoverable, never superseded. + claimed1 = await worker.run_once() + self.assertTrue(claimed1) + self.assertEqual(len(attempts), 1) + + after_attempt_1 = await repo.get_run("acme", run.id) + assert after_attempt_1 is not None + status_1 = ( + after_attempt_1.status.value + if hasattr(after_attempt_1.status, "value") + else after_attempt_1.status + ) + self.assertEqual(status_1, "pending") + + still_pending = await repo.list_pending_steering("acme", run.id) + self.assertEqual([event.id for event in still_pending], [accepted.id]) + all_steering = await repo.list_steering("acme", run.id) + self.assertEqual(len(all_steering), 1) + self.assertIn(all_steering[0].status, ("pending", "delivered")) + + events_after_1 = await repo.list_events("acme", run.id) + self.assertFalse( + any(event.kind == "run.steer.superseded" for event in events_after_1) + ) + + # Attempt 2: a fresh delivery attempt re-ingests E via the + # ordinary claim -> _sync_steering_in path and drains it. + claimed2 = await worker.run_once() + self.assertTrue(claimed2) + self.assertEqual(len(attempts), 2) + self.assertEqual(drained_log, [["hello"]]) + + final = await repo.get_run("acme", run.id) + assert final is not None + final_status = ( + final.status.value if hasattr(final.status, "value") else final.status + ) + self.assertEqual(final_status, "succeeded") + + final_steering = await repo.list_steering("acme", run.id) + self.assertEqual(len(final_steering), 1) + self.assertEqual(final_steering[0].status, "consumed") + + asyncio.run(scenario()) + + +def make_registry_flaky_before_drain() -> tuple[GraphRegistry, list[int], list[list[str]]]: + """A single-node graph whose node raises a retryable ``ConnectionError`` + on its first *delivery* attempt, before ever calling + ``drain_steering()``, and succeeds (draining whatever is pending) on + the second. No node-level ``RetryPolicy`` is attached -- the exception + is meant to propagate all the way out of ``graph.astream`` into the + Worker's own delivery-retry handling, not be swallowed by a + node-internal retry. + """ + + attempts: list[int] = [] + drained_log: list[list[str]] = [] + + builder = StateGraph(State) + + async def flaky(state: State, runtime: Runtime[Any]) -> dict[str, Any]: + attempts.append(1) + if len(attempts) < 2: + raise ConnectionError("transient, before draining anything") + drained = [event.payload["message"] for event in runtime.drain_steering()] + drained_log.append(drained) + return {"ticks": state.get("ticks", 0) + 1} + + builder.add_node("flaky", flaky) + builder.add_edge(START, "flaky") + builder.add_edge("flaky", END) + return GraphRegistry({"flaky_before_drain": builder.compile()}), attempts, drained_log + + +class InMemoryRepositoryRound9BranchCoverageTests(unittest.TestCase): + """Direct unit coverage of the InMemory-reachable branches added/kept + across issue #16 PR #17 review round 9: the restored unfenced + ``retry_run()``, ``close_steering()``'s already-closed no-op, the + standalone ``supersede_pending_steering_if_owned()`` (both its + nothing-to-supersede and something-to-supersede paths), and + ``finalize_run_with_steering_disposition_if_owned(supersede_pending=False)``.""" + + def test_retry_run_unfenced_admin_path(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + await repo.claim_run("worker-a", lease_seconds=30) + updated = await repo.retry_run("acme", run.id, error={"code": "x"}) + self.assertEqual(updated.status, "pending") + self.assertIsNone(updated.lease_owner) + + asyncio.run(scenario()) + + def test_close_steering_is_a_no_op_when_already_closed(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + first = await repo.close_steering("acme", run.id, "worker-a", claimed.attempt) + self.assertTrue(first) + second = await repo.close_steering("acme", run.id, "worker-a", claimed.attempt) + self.assertTrue(second) + + asyncio.run(scenario()) + + def test_supersede_pending_steering_if_owned_standalone_no_pending(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + result = await repo.supersede_pending_steering_if_owned( + "acme", run.id, "worker-a", claimed.attempt + ) + self.assertEqual(result, []) + + asyncio.run(scenario()) + + def test_supersede_pending_steering_if_owned_standalone_supersedes(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + await repo.submit_steering("acme", run.id, kind="user_input", payload={"m": "x"}) + result = await repo.supersede_pending_steering_if_owned( + "acme", run.id, "worker-a", claimed.attempt + ) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].kind, "run.steer.superseded") + steering = await repo.list_steering("acme", run.id) + self.assertEqual(steering[0].status, "superseded") + + asyncio.run(scenario()) + + def test_finalize_run_with_steering_disposition_can_skip_supersede(self) -> None: + async def scenario() -> None: + repo = InMemoryRepository() + assistant = await repo.create_assistant( + "acme", _assistant_create(), graph_version="1" + ) + run = await repo.create_run("acme", None, assistant, _run_create(assistant.id)) + claimed = await repo.claim_run("worker-a", lease_seconds=30) + assert claimed is not None + await repo.submit_steering("acme", run.id, kind="user_input", payload={"m": "x"}) + result = await repo.finalize_run_with_steering_disposition_if_owned( + "acme", + run.id, + "worker-a", + claimed.attempt, + RunStatus.PAUSED, + output=None, + error=None, + supersede_pending=False, + supersede_reason="n/a", + ) + self.assertIsNotNone(result) + updated, superseded_events = result + self.assertEqual(updated.status, "paused") + self.assertEqual(superseded_events, []) + steering = await repo.list_steering("acme", run.id) + self.assertEqual(steering[0].status, "pending") + + asyncio.run(scenario()) + + +def _assistant_create_named(graph_id: str): + from lingxigraph.server.models import AssistantCreate + + return AssistantCreate(graph_id=graph_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_steering_module_edge_cases.py b/tests/test_steering_module_edge_cases.py new file mode 100644 index 0000000..3e3a0fc --- /dev/null +++ b/tests/test_steering_module_edge_cases.py @@ -0,0 +1,51 @@ +"""Direct unit tests for lingxigraph.steering module-level edge cases.""" + +import unittest + +from lingxigraph.steering import ( + SteeringChannel, + SteeringPayloadTooLarge, + validate_steering_payload, +) + + +class ValidateSteeringPayloadTests(unittest.TestCase): + def test_rejects_unserializable_payload(self) -> None: + class Unserializable: + pass + + with self.assertRaises(SteeringPayloadTooLarge): + validate_steering_payload({"bad": Unserializable()}) + + +class SteeringChannelDrainCallbackTests(unittest.TestCase): + def test_on_drain_exception_is_swallowed(self) -> None: + channel = SteeringChannel(run_id="run-1") + + def boom(_drained: object) -> None: + raise RuntimeError("observability sink is down") + + channel.on_drain = boom + channel.submit(kind="note", payload={"a": 1}) + # Must not raise even though on_drain blows up. + drained = channel.drain() + self.assertEqual(len(drained), 1) + + def test_ack_consumed_with_empty_ids_is_a_no_op(self) -> None: + channel = SteeringChannel(run_id="run-1") + channel.submit(kind="note", payload={"a": 1}) + channel.drain() + before = channel.peek_consumed() + channel.ack_consumed([]) + after = channel.peek_consumed() + self.assertEqual(before, after) + + def test_ack_consumed_is_a_no_op_when_log_already_empty(self) -> None: + channel = SteeringChannel(run_id="run-1") + # Nothing submitted/drained -- consumed log starts empty. + channel.ack_consumed(["nonexistent-id"]) # must not raise + self.assertEqual(channel.peek_consumed(), ()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tools_edge_cases.py b/tests/test_tools_edge_cases.py new file mode 100644 index 0000000..154f21b --- /dev/null +++ b/tests/test_tools_edge_cases.py @@ -0,0 +1,102 @@ +"""Direct unit tests for lingxigraph.tools pure-function edge branches.""" + +import asyncio +import unittest + +from lingxigraph import AIMessage, Command, InvalidUpdateError, ToolCall, tool +from lingxigraph.tools import ToolNode, _make_spec, validate_json_schema + + +def add(a: int, b: int = 1) -> int: + """Add two numbers.""" + return a + b + + +def var_args_tool(a: int, *args: int, **kwargs: int) -> int: + """A tool with *args/**kwargs, which must be skipped from the schema.""" + return a + + +class ToolDecoratorEdgeCaseTests(unittest.TestCase): + def test_tool_decorator_with_explicit_string_name_positional(self) -> None: + spec = tool("custom_name")(add) + self.assertEqual(spec.name, "custom_name") + + def test_make_spec_skips_var_positional_and_var_keyword_parameters(self) -> None: + spec = _make_spec( + var_args_tool, name=None, return_direct=False + ) + self.assertEqual(set(spec.parameters["properties"]), {"a"}) + + +class ValidateJsonSchemaEdgeCaseTests(unittest.TestCase): + def test_enum_violation_raises(self) -> None: + with self.assertRaises(ValueError): + validate_json_schema("z", {"enum": ["a", "b"]}) + + def test_array_items_are_validated_recursively(self) -> None: + schema = {"type": "array", "items": {"type": "integer"}} + with self.assertRaises(ValueError): + validate_json_schema([1, "not-an-int"], schema) + validate_json_schema([1, 2, 3], schema) # must not raise + + def test_object_missing_required_argument_raises(self) -> None: + schema = {"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]} + with self.assertRaises(ValueError): + validate_json_schema({}, schema) + + def test_object_rejects_unknown_argument_when_additional_properties_false(self) -> None: + schema = { + "type": "object", + "properties": {"x": {"type": "integer"}}, + "additionalProperties": False, + } + with self.assertRaises(ValueError): + validate_json_schema({"x": 1, "y": 2}, schema) + + +class ToolNodeConstructionEdgeCaseTests(unittest.TestCase): + def test_duplicate_tool_names_are_rejected(self) -> None: + spec_a = tool("dup")(add) + spec_b = tool("dup")(add) + with self.assertRaises(ValueError): + ToolNode([spec_a, spec_b]) + + def test_non_positive_read_only_settings_are_rejected(self) -> None: + spec = tool(add) + with self.assertRaises(ValueError): + ToolNode([spec], read_only_concurrency=0) + with self.assertRaises(ValueError): + ToolNode([spec], read_only_batch_size=0) + + def test_call_without_a_trailing_ai_message_raises(self) -> None: + node = ToolNode([tool(add)]) + + async def scenario() -> None: + with self.assertRaises(InvalidUpdateError): + await node({"messages": []}) + + asyncio.run(scenario()) + + def test_command_returning_tool_must_be_the_only_call(self) -> None: + def make_command() -> Command: + return Command(goto="next") + + node = ToolNode([tool(add), tool(make_command)]) + + async def scenario() -> None: + message = AIMessage( + content="", + tool_calls=[ + ToolCall(id="1", name="add", args={"a": 1, "b": 2}), + ToolCall(id="2", name="make_command", args={}), + ], + ) + with self.assertRaises(InvalidUpdateError): + await node({"messages": [message]}) + + asyncio.run(scenario()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_worker_edge_cases.py b/tests/test_worker_edge_cases.py new file mode 100644 index 0000000..e1ecc33 --- /dev/null +++ b/tests/test_worker_edge_cases.py @@ -0,0 +1,94 @@ +"""Direct unit tests for Worker lifecycle properties and small edge branches.""" + +import asyncio +import unittest + +from lingxigraph.server.registry import GraphRegistry +from lingxigraph.server.repository import InMemoryRepository +from lingxigraph.server.worker import Worker + + +class WorkerLifecyclePropertyTests(unittest.TestCase): + def test_draining_ready_and_live_properties(self) -> None: + registry = GraphRegistry({}) + repository = InMemoryRepository() + worker = Worker(registry, repository) + + self.assertFalse(worker.draining) + self.assertTrue(worker.ready) + self.assertTrue(worker.live) + + worker.stop() + + self.assertTrue(worker.draining) + self.assertFalse(worker.ready) + + def test_run_once_returns_false_immediately_while_draining(self) -> None: + async def scenario() -> None: + registry = GraphRegistry({}) + repository = InMemoryRepository() + worker = Worker(registry, repository) + worker.stop() + claimed = await worker.run_once() + self.assertFalse(claimed) + + asyncio.run(scenario()) + + def test_drain_returns_true_when_already_idle(self) -> None: + async def scenario() -> None: + registry = GraphRegistry({}) + repository = InMemoryRepository() + worker = Worker(registry, repository) + # No active _execute in flight -- _idle is already set. + finished = await worker.drain(timeout=1.0) + self.assertTrue(finished) + self.assertTrue(worker.draining) + + asyncio.run(scenario()) + + +class IsRetryableClassificationTests(unittest.TestCase): + """Direct unit coverage of ``Worker._is_retryable``'s branches: it + decides whether a node exception should route into the ordinary + delivery-retry path or fail the run outright.""" + + def test_validation_errors_are_never_retryable(self) -> None: + from lingxigraph.errors import GraphValidationError, InvalidUpdateError + + self.assertFalse(Worker._is_retryable(GraphValidationError("bad graph"))) + self.assertFalse(Worker._is_retryable(InvalidUpdateError("bad update"))) + self.assertFalse(Worker._is_retryable(KeyError("missing"))) + self.assertFalse(Worker._is_retryable(ValueError("bad value"))) + + def test_connection_and_persistence_errors_are_retryable(self) -> None: + from lingxigraph.errors import PersistenceError + + self.assertTrue(Worker._is_retryable(ConnectionError("dropped"))) + self.assertTrue(Worker._is_retryable(TimeoutError("timed out"))) + self.assertTrue(Worker._is_retryable(PersistenceError("db hiccup"))) + + def test_plain_runtime_error_is_retryable(self) -> None: + self.assertTrue(Worker._is_retryable(RuntimeError("transient"))) + + def test_httpx_module_exceptions_are_retryable(self) -> None: + class FakeHttpxError(Exception): + pass + + FakeHttpxError.__module__ = "httpx._exceptions" + self.assertTrue(Worker._is_retryable(FakeHttpxError("network blip"))) + + def test_name_based_transient_markers_are_retryable(self) -> None: + class UpstreamTemporaryFailure(Exception): + pass + + self.assertTrue(Worker._is_retryable(UpstreamTemporaryFailure("try again"))) + + def test_unrelated_exception_is_not_retryable(self) -> None: + class SomeDomainError(Exception): + pass + + self.assertFalse(Worker._is_retryable(SomeDomainError("permanent"))) + + +if __name__ == "__main__": + unittest.main()