diff --git a/.agents/docs/rpc-loro-streams-rpc.md b/.agents/docs/rpc-loro-streams-rpc.md index 54bafb53e..9eeca24bb 100644 --- a/.agents/docs/rpc-loro-streams-rpc.md +++ b/.agents/docs/rpc-loro-streams-rpc.md @@ -20,6 +20,28 @@ been removed. handlers. - `README.md` — package smoke-test notes. +`session/queue-steer` is an identity-based control operation, not queue reordering. Its +request names both the expected active turn and exact queued item, and callers require +`queueItemSteer` v2. Its paired `session/queue-mutate` domain RPC serializes revision-checked +edit/remove/reorder against reservation; conflicts fail visibly, without renderer direct-write +fallback. Native submission waits for durable history and queue removal. +The CLI accepts the item only when its +editing lease is inactive, then preserves native ACP Steer when acknowledged or creates an +ordinary follow-up before cancelling the expected turn. The ordinary path retains the queue +row until history and its activation pointer are durable. Missing, editing, and stale targets +do neither. +Before either remote queue control is written, the shared +[source authorizer](../../packages/components/src/providers/session-control-authorization.ts) +matches session metadata to the target machine and requires current machine access plus +matching project access for local-project sessions. Unlike UI visibility, control has no +session-owner fallback: authorship cannot override revoked access. Incomplete snapshots fail +closed. Local routing +with an unavailable sender fails locally without creating a Streams client. +The retained request contains no requester identity: +the target daemon cannot authenticate such a claim and must not grant its owner fast path from it. +Native queue Steer instead inherits the frozen requester from the authenticated active invocation; +it fails before consumption when that identity is unavailable and never trusts the queue row. + ## Remote lifecycle acknowledgements For accepted restart/upgrade responses, `settleMachineLifecycleResponse` attempts diff --git a/.agents/docs/sessions-live-status.md b/.agents/docs/sessions-live-status.md index 0b00bcbd9..bd7389c5f 100644 --- a/.agents/docs/sessions-live-status.md +++ b/.agents/docs/sessions-live-status.md @@ -43,8 +43,12 @@ this page is the full text of the rules summarised there. must queue in that state (even when the preference is guide; steering requires positive live prompt activity), because queue promotion is safe for both a live turn and a stale transcript while direct dispatch can create a second accepted - turn. This barrier affects routing only; it must not relight Working UI or enable - Stop. That pre-start label is additionally suppressed whenever the + turn. An explicit `queueBehavior: "inverse"` submission swaps queue and guide for + that submission only, then applies the same activity and ordering gates; ordinary + Enter supplies no override and neither path changes the stored preference. The + inverse command owns its composer-focus/content/readiness predicate so rebinding its + shortcut cannot bypass those constraints. This barrier affects routing only; it must not relight Working + UI or enable Stop. That pre-start label is additionally suppressed whenever the status chip has an active connection/machine problem (`statusStripState != null`: browser offline, machine removed or offline) — the chip owns that story, and "Starting…" next to "machine offline" is a contradiction. `isSessionWorking` diff --git a/.agents/notes/implemented/architecture/2026-09-14-queue-steer-effect-boundary.md b/.agents/notes/implemented/architecture/2026-09-14-queue-steer-effect-boundary.md new file mode 100644 index 000000000..7e022ed0c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-queue-steer-effect-boundary.md @@ -0,0 +1,147 @@ +# Effect ownership for queue Steer + +Status: implemented +Translation: current + +[中文](2026-09-14-queue-steer-effect-boundary.zh.md) + +## Abstract + +QueueSteerService owns exact selection, validation, recovery evidence, fallback and receipts; +ActiveTurnSteerPort retains live-turn and provider ownership. Effect 3.18.4 expresses local +resource lifetimes and typed failures without treating provider submission as reversible. +Native reservation makes frozen history and queue removal durable before submission; +conflicting stale edits fail visibly and retain their drafts. Queue Steer requires +queueItemSteer v2, without old-daemon compatibility. Indeterminate delivery never replays; +real process-crash and installed-app verification remain outside the completed checks. + +## Source evidence + +The [workspace catalog](../../../../pnpm-workspace.yaml) pins `effect: 3.18.4`, +and [CLI dependencies](../../../../apps/cli/package.json) consume `catalog:`. +[SessionExecutionService](../../../../apps/cli/src/session/session-execution-service.ts) +already uses `Effect.gen` and `Effect.acquireRelease` for turn ownership and +finalization. The implementation extends that existing approach. + +The installed package has source but no `AGENTS.md`. The official v3 checkout has +[agent instructions](https://github.com/Effect-TS/effect/blob/1af4232fea7bc613e1dc68db9bec7b1f596d9e68/AGENTS.md) +and a [documentation entry](https://github.com/Effect-TS/effect/blob/1af4232fea7bc613e1dc68db9bec7b1f596d9e68/docs/index.md). +Upstream v4 examples using `Context.Service` or `Effect.catch` must not be copied +into this v3 application. CLI instructions reference `context/cli-effect-ts.md`, +which is absent from this checkout. + +The installed implementations of `acquireUseRelease` in `src/internal/core.ts`, +`acquireRelease` in `src/internal/fiberRuntime.ts`, `tryPromise` in +`src/internal/core-effect.ts`, and the `ManagedRuntime` API were inspected. +The corresponding [3.18.4 source](https://github.com/Effect-TS/effect/tree/ede2ea11c2abe7038bac3c83fb7b5eef101858d2/packages/effect/src) +is the API authority. Upstream v3 +[resource tests](https://github.com/Effect-TS/effect/blob/1af4232fea7bc613e1dc68db9bec7b1f596d9e68/packages/effect/test/Effect/acquire-release.test.ts) +and [interruption tests](https://github.com/Effect-TS/effect/blob/1af4232fea7bc613e1dc68db9bec7b1f596d9e68/packages/effect/test/Effect/interruption.test.ts) +were read as supplementary evidence, not run or assumed identical to the pinned release. + +## Boundaries and implementation + +| Owner | Responsibility | +| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| UI | Pass session, exact queue item and expected turn; no native/fallback policy. | +| [QueueSteerService](../../../../apps/cli/src/session/queue-steer-service.ts) | Exact selection, validation, durable marker, recovery, fallback and bounded receipts. | +| [ActiveTurnSteerPort](../../../../apps/cli/src/session/active-turn-steer-port.ts) | Live ownership, native submission, prompt handoff, exact Stop and local guards. | +| SessionExecutionService | Implement the port; compose Context.Tag/Layer and execute domain operations on its per-session queue. | +| SessionDocument | Compare row revisions and mutate synchronously; write history only through shared HistoryWriter. | + +The queue service never reads runtime maps, agentClient, promptInFlight, invocation or successor, +and receives no onSubmitting/onAcknowledged/onApplied/onUndelivered callbacks. +It defines its own narrow dependency contract, without importing SessionExecutionServiceDeps. +Provider requester identity comes from the active invocation, not the shared queue author. +Ordinary turn and composer routing remain unchanged. + +The source facade shares [session authorization](../../../../packages/components/src/providers/session-control-authorization.ts) +between queue Steer and mutation, using complete authenticated machine/project snapshots. +Control requires machine access plus matching project access for local-project sessions. +Reusing UI visibility was incorrect: its intentional session-owner fallback permits display +after machine access is revoked, but must not grant control. The control contract therefore +contains no currentUserId or session-owner input and does not call the visibility predicate. +RuntimeProvider supplies a workspace-fenced snapshot; missing metadata or authorization +fails closed. Routing plane is decided independently of sender availability: local failure cannot +fall through to Streams. The target daemon cannot authenticate a caller identity from this RPC. + +## Resources and failures + +Rewrite/ownership guards and the adapter's local ACK gate belong to Scope through +Effect.acquireRelease. Interruption is masked between submission and ACK cleanup registration +so local cleanup ownership cannot be abandoned. Stop still cancels ACP, not its owner fiber. +Scope neither reverses external submission nor runs after process death. + +| Category | Behavior | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SteerPreparationFailure, ProviderRejected, pre-submission StaleTurn | Only proven non-delivery and eligible preconditions allow ordinary dispatch recovery. Missing, editing or stale selection before reservation remains a no-op. | +| ProviderDeliveryUnknown | Return the error without fallback or replay, including ownership loss or handoff failure after submission. | +| PersistenceFailure | Journal/history persistence failure; retain durable evidence, never infer non-delivery from the error tag alone. | + +`prepareSteer` completes prompt building, configuration application and ownership validation +before the queue service writes `submitting`. Its failures are handled only at the preparation +call, not through a blanket PersistenceFailure fallback. `submitSteer` consumes an opaque, +single-use handle; the execution service privately retains the lazy submission effect in a +WeakMap, exposing neither runtime objects nor callbacks. The port rechecks ownership after +journal persistence without asynchronous preparation before the provider call. Generic throws +from that call are ProviderDeliveryUnknown, whether synchronous or asynchronous; explicit +AgentSteerNotDeliveredError remains proven rejection. An invalid/reused handle cannot replay. +Crashes between write-ahead evidence and the provider call remain indeterminate. No new phase +or whole-operation retry is introduced. + +## Authoritative mutation and recovery + +Early removal alone cannot prevent another client saving during the history persistence await. +queueItemSteer therefore advances to v2: edit/remove/reorder use the narrow +`session/queue-mutate` domain RPC, sharing reservation's serialization and rewrite conflict guard. +Update/remove carry canonical row revisions; reorder carries the original ID snapshot. +Conflicts and missing rows fail explicitly; failed RPC never falls back to direct writes. +Enqueue, ordinary sends and other UI data remain renderer-authored, without a generic +write-intent mirror. Old daemons do not expose Queue Steer. + +Native order is reservation marker → pending_apply history durable → queue removal durable +→ prepareSteer → submitting marker → submitSteer → durable receipt. Every pre-submit barrier gates provider calls. +Recovery uses marker and frozen history, without requiring a surviving row. Existing markers +remain readable. A legacy row without a revision may be removed only when its frozen content +is provably unchanged and it is not being edited; otherwise preserve it for reconciliation. + +Reserved without history clears its marker and leaves the queue untouched, with no terminal +receipt. Same C/T retries revalidate and reserve anew, either after startup recovery or within +the current request; a failed clear prevents proceeding. A cached error here would incorrectly +make a proven-unsubmitted operation permanently unsteerable for the remaining active turn. +Reserved with history/fallback recover the same ordinary turn; submitting/acknowledged never replay; +applied recovers an accepted receipt. Ordinary cancel-and-dispatch retains its history/activation +publication order and in-memory receipts. The [Spec](../../../../specs/message-queue-interactions.md) +owns the full contract and remains draft. This supersedes the +[original decision](../feature/2026-09-13-queue-steer-controls.md)'s editable-row retention and +old-daemon compatibility policies. + +The editor stays mounted after the last row disappears, shows the conflict, and retains its +unsaved draft until explicitly dismissed. Direct CRDT writes from old software are not v2 +authority acceptance; this is not compatibility with arbitrary old renderers. + +## Verification and limits + +Existing suites cover exact C preserving A/B, provider rejection and uncertainty, Stop/late ACK, +consecutive handoffs, receipts/restart, and reservation versus edit/remove/reorder on a real LoroDoc. +Explicit promise gates prove durable removal before submission, zero submissions after +reservation/history/removal/submission-marker persistence failure, marker-plus-history recovery, +and local guard release. Components verify displaced-draft retention; shared negotiation rejects v1. +No race assertion depends on sleeps or real network scheduling. +Prompt-build and mode/model failures leave C dispatchable with a fallback marker and no provider +call. Synchronous provider throws and rejected ACKs retain submission evidence without fallback +or replay. Stop during submission-marker persistence is checked before any provider call. + +The execution suite connects the real source facade, Streams client/server, LoroDoc and execution +service over an in-memory transport. A visible machine with a denied private project rejects +both controls with zero appends, no marker, and unchanged queue/history/active turn. The same +trace covers session creators with revoked machine or denied project access, even while UI +visibility remains true; a retained project grant cannot override machine revocation. Local routing +with no sender also rejects without constructing the remote client. A positive project-access +control reaches the daemon and applies C. Restart and in-request pre-history recovery both allow +the same C/T to proceed. These are deterministic synthetic traces, not production-user traces. + +Direct CLI/components typechecks and targeted tests were run. Root pnpm check / pnpm format +cannot start because corepack is absent; installed pnpm runs targeted checks and Prettier instead. +No real-provider, full desktop end-to-end, process kill/restart, or arbitrary mixed-old-client +verification was performed. diff --git a/.agents/notes/implemented/architecture/2026-09-14-queue-steer-effect-boundary.zh.md b/.agents/notes/implemented/architecture/2026-09-14-queue-steer-effect-boundary.zh.md new file mode 100644 index 000000000..36e505dea --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-queue-steer-effect-boundary.zh.md @@ -0,0 +1,129 @@ +# 队列 Steer 的 Effect 归属边界 + +Status: implemented +Translation: current + +[English](2026-09-14-queue-steer-effect-boundary.md) + +## 摘要 + +队列 Steer 由 `QueueSteerService` 负责精确选择、验证、恢复证据、fallback 与回执, +`ActiveTurnSteerPort` 保留 live turn 和 provider ownership。Effect 3.18.4 +表达本地资源生命周期与类型化失败,不把 provider 提交包装成可撤销资源。 +Native reservation 后先持久化冻结 history 和队列删除,再提交 provider;旧客户端草稿 +冲突会明确失败并保留。队列 Steer 仅支持 queueItemSteer v2,没有旧 daemon 兼容路径。 +不确定交付禁止重放;真实多进程崩溃和已安装应用的人工验证仍未覆盖。 + +## 源码证据 + +[workspace catalog](../../../../pnpm-workspace.yaml) 固定 `effect: 3.18.4`, +[CLI 依赖](../../../../apps/cli/package.json) 通过 `catalog:` 使用它。 +[SessionExecutionService](../../../../apps/cli/src/session/session-execution-service.ts) +已经用 `Effect.gen`、`Effect.acquireRelease` 表达 turn ownership 与 finalization, +本实现延续现有方式。 + +安装包有源码但没有 `AGENTS.md`。官方 v3 checkout 包含 +[agent 规则](https://github.com/Effect-TS/effect/blob/1af4232fea7bc613e1dc68db9bec7b1f596d9e68/AGENTS.md) +与[文档入口](https://github.com/Effect-TS/effect/blob/1af4232fea7bc613e1dc68db9bec7b1f596d9e68/docs/index.md)。 +不能把使用 `Context.Service` 或 `Effect.catch` 的上游 v4 示例复制到本项目的 v3。 +CLI 规则引用的 `context/cli-effect-ts.md` 在当前 checkout 缺失。 + +已检查安装包 `src/internal/core.ts` 的 `acquireUseRelease`、 +`src/internal/fiberRuntime.ts` 的 `acquireRelease`、 +`src/internal/core-effect.ts` 的 `tryPromise`,以及 `ManagedRuntime` API。 +对应的 [3.18.4 源码](https://github.com/Effect-TS/effect/tree/ede2ea11c2abe7038bac3c83fb7b5eef101858d2/packages/effect/src) +是 API 依据。另已阅读上游 v3 的 +[资源测试](https://github.com/Effect-TS/effect/blob/1af4232fea7bc613e1dc68db9bec7b1f596d9e68/packages/effect/test/Effect/acquire-release.test.ts) +与[中断测试](https://github.com/Effect-TS/effect/blob/1af4232fea7bc613e1dc68db9bec7b1f596d9e68/packages/effect/test/Effect/interruption.test.ts) +作为补充证据;未运行它们,也未假定它们与固定版本完全相同。 + +## 边界与实现 + +| 归属 | 责任 | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| UI | 每行仅传 session、queue item 和 expected turn;不选择 native/fallback。 | +| [QueueSteerService](../../../../apps/cli/src/session/queue-steer-service.ts) | 精确选择、验证、持久 marker、恢复、fallback 和有界回执。 | +| [ActiveTurnSteerPort](../../../../apps/cli/src/session/active-turn-steer-port.ts) | live ownership、native submission、prompt handoff、精确 Stop 与本地 guard。 | +| SessionExecutionService | 实现 port;组合 Context.Tag/Layer,并在 per-session queue 执行领域 operation。 | +| SessionDocument | 同步比较 row revision 后修改;history 只走共享 HistoryWriter。 | + +Queue 服务不读取 runtime map、agentClient、promptInFlight、invocation 或 successor, +也不使用 onSubmitting/onAcknowledged/onApplied/onUndelivered 回调。Provider requester +来自活动 invocation,不相信队列作者。服务定义自己的窄依赖契约,不导入 +SessionExecutionServiceDeps。既有普通 turn 与 composer 路由不变。 + +Source facade 为队列 Steer 和 mutation 共用 [session 授权](../../../../packages/components/src/providers/session-control-authorization.ts), +使用完整的已认证 machine/project 快照。控制需要 machine 权限,local-project session 还需 +对应项目权限。复用 UI visibility 是错误的:它刻意允许 session 创建者在 machine 权限撤销后 +仍看到 session,但不能据此授予控制权。因此控制契约不包含 currentUserId 或 session owner, +也不调用 visibility predicate。RuntimeProvider 提供按 workspace 隔离的快照;metadata 或授权缺失时 +fail closed。Routing plane 独立于 sender 可用性,local 失败不能落入 Streams。 +目标 daemon 无法从此 RPC 认证调用方身份。 + +## 资源和失败 + +rewrite/ownership guard 和 adapter 本地 ACK gate 用 Effect.acquireRelease 归属 Scope。 +提交到 ACK cleanup 注册之间屏蔽 fiber interruption,避免释放本地 handle 的责任丢失; +Stop 仍取消 ACP,而非其 owner fiber。Scope 既不能撤销外部提交,也不能跨进程死亡执行。 + +| 类别 | 行为 | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| SteerPreparationFailure、ProviderRejected、提交前 StaleTurn | 只有确定未交付且前提允许才恢复普通 dispatch;reservation 前缺失、编辑中或过期仍无副作用。 | +| ProviderDeliveryUnknown | 向上返回错误,不进入 fallback、不重放。包括提交后失去 ownership 或 handoff 失败。 | +| PersistenceFailure | Journal/history 持久化失败;保留 durable evidence,不能仅凭错误 tag 推导未交付。 | + +`prepareSteer` 完成 prompt 构建、配置应用和 ownership 验证后,queue service 才写 +`submitting`。只在 preparation 调用处处理其失败,不 blanket catch PersistenceFailure +来 fallback。`submitSteer` 消费不透明的单次 handle;execution service 用私有 WeakMap +保留惰性 submission effect,不暴露 runtime 对象或 callback。Journal 持久化后,port 再次 +检查 ownership,provider call 前不再等待异步准备。该调用的普通同步/异步异常均为 +ProviderDeliveryUnknown;明确的 AgentSteerNotDeliveredError 仍证明拒绝交付。无效或已用 +handle 不能重放。写前证据与 provider call 之间的 crash 仍不确定;不新增 phase 或重试整个 operation。 + +## 权威写入与恢复 + +仅提前删除 row 无法防止 history 持久化等待期间另一个客户端保存编辑。因此 +queueItemSteer 升为 v2:renderer 对支持者的 edit/remove/reorder 通过窄领域 RPC +`session/queue-mutate` 到 daemon,与 reservation 共用串行入口和 rewrite conflict guard。 +更新/删除带 canonical row revision,重排带原始 ID 快照;冲突和缺失明确失败。 +RPC 失败绝不退回直接写入。enqueue、普通发送和其他 UI 数据仍由 renderer 写入, +没有恢复通用 write-intent mirror。旧 daemon 不开放队列 Steer。 + +Native 顺序是 reservation marker → pending_apply history durable → queue removal durable +→ prepareSteer → submitting marker → submitSteer → durable receipt。任一提交前 barrier 失败禁止 provider 调用。 +恢复使用 marker 和冻结 history;不要求原 row 存在。旧 marker 仍可读;若残留 row 无 revision, +只有可证明与冻结内容一致且不在编辑中才删除,否则保留并等待对账。不能通过恢复丢掉已接受编辑。 + +没有 history 的 `reserved` 清除 marker、保留 queue,不生成终态 receipt。相同 C/T 的重试 +重新验证并建立 reservation,既支持启动恢复后重试,也支持当前请求继续;clear 失败则不能继续。 +此处缓存错误会让确定未提交的操作在当前 active turn 剩余期间永久无法再次 Steer。 +有 history 的 `reserved`/`fallback` 可恢复同一个普通 turn;`submitting`/`acknowledged` 不重放; +`applied` 恢复 accepted 回执。普通 cancel-and-dispatch 保留既有 history/activation +发布顺序与内存回执。完整契约由 [Spec](../../../../specs/message-queue-interactions.zh.md) 拥有; +它仍是 draft。本决策替代[原记录](../feature/2026-09-13-queue-steer-controls.zh.md)的 +共享 row 保留到交付完成及旧 daemon 兼容策略。 + +编辑器在最后一行消失后仍保持挂载,显示冲突提示并保留未保存草稿,直到用户明确关闭。 +旧软件直接写 CRDT 不等于 v2 authority 接受保存;不能声称兼容任意旧 renderer。 + +## 验证与限制 + +已有 suites 覆盖精确 C 保留 A/B、provider 拒绝与不确定失败、Stop/late ACK、连续 handoff、 +receipt/restart,以及真实 LoroDoc 上的 reservation 与 edit/remove/reorder 冲突。 +显式 promise gate 验证删除持久化先于 provider,以及 reservation/history/removal/submission-marker +持久化失败时零提交、marker 加 history 的恢复和本地 guard 释放。组件覆盖 row 消失后的草稿保留, +共享协议拒绝 v1。测试不使用睡眠或真实网络来决定竞态。 +Prompt 构建或 mode/model 失败时,C 可普通 dispatch、marker 为 fallback,provider 零调用。 +Provider 同步抛错或 ACK 拒绝保留 submission 证据,不 fallback、不重放。 +Submission marker 持久化期间的 Stop 也会在 provider call 前被检查。 + +Execution suite 用内存传输连接真实 source facade、Streams client/server、LoroDoc 和 +execution service。Machine 可见但私有项目不可见时,两种 control 均拒绝:零 append、无 marker, +queue/history/active turn 不变。同一 trace 覆盖 session 创建者被撤销 machine 或拒绝项目权限, +即使 UI visibility 仍为 true;残留项目权限不能绕过 machine 撤权。Local 缺 sender 同样拒绝且不创建远程 client。开放项目权限的 +正向对照可到达 daemon 并应用 C。重启恢复和请求内 pre-history 恢复都允许相同 C/T 继续。 +这些是确定性合成数据 trace,不是生产用户 trace。 + +直接 CLI、components 类型检查与目标测试已运行;根级 pnpm check / pnpm format +因缺少 corepack 无法启动,改用已安装 pnpm 运行目标检查和 Prettier。 +没有真实 provider、完整桌面端到端、进程 kill/restart 或任意旧客户端混跑的验证。 diff --git a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md new file mode 100644 index 000000000..861dcd04d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.md @@ -0,0 +1,99 @@ +# Queue inversion and direct manipulation + +Status: implemented +Translation: current + +[中文](2026-09-13-queue-steer-controls.zh.md) + +## Abstract + +Queue and Steer previously required changing a persistent preference, later queued +items hid Steer, and reordering started only from the small leading handle. The adopted +interaction adds a one-shot inverse submission command, exposes Steer on every row when the +daemon can identify that row safely, and uses the row's message content as its drag target. +Queue order and immediate Steer remain independent. Exact-item steering is version-negotiated, +preserves native ACP steering, respects editing leases, and never stops the active turn when +the selected identity is missing. + +## Decision + +- Register `session.sendWithInverseQueueBehavior` in the command system with + `Mod+Shift+Enter` as its default. The composer calls + `sendMessage({ queueBehavior: "inverse" })`; ordinary submission passes no option. The + command-level predicate owns composer focus, content, and send readiness so user binding + overrides cannot remove those rules. The routing resolver reverses only this submission. +- Advertise `queueItemSteer` in `MachineMeta.protocolCapabilities`. The renderer calls + `session/queue-steer` only when that version is present; missing means unsupported. The + request names the queue `$cid` and expected active turn, and the renderer waits for the + result without changing queue or history locally. +- Authorize a remote request before appending it, using the renderer's authenticated, + Convex-authoritative visible-machine snapshot. Fail closed while that snapshot is unavailable + or excludes the target. The RPC carries no requester identity because a target daemon cannot + authenticate an identity claimed by a workspace stream writer. Same-host local IPC remains the + trusted local control path. +- Let the daemon choose the execution mechanism after validating both identities and the + target's editing lease. With acknowledged native ACP Steer, it records a durable phase marker, + retains the selected row while history is `pending_apply`, and enters the existing `steerPrompt` + handoff. Native Steer derives its + requester from the authenticated active invocation, never the shared queue row, and fails + before consumption if that frozen identity is absent. Without native Steer, it + writes the row as an ordinary pending turn, publishes its activation pointer, removes the + queue row only after both writes succeed, then cancels only the expected active turn. A + partial publication retains the row as a retry marker and reuses the existing history ID. +- Retain mixed-version behavior without reintroducing reorder-then-cancel. An older daemon + with an authoritative acknowledged-Steer capability uses the legacy native path. Other + older daemons retain only the established queue-head interrupt; later-row Steer controls + are disabled with an upgrade explanation. +- Keep a bounded in-memory daemon receipt for each completed exact operation key, and retain the + latest native result in its durable saga marker. A response-loss retry, including an immediate + retry after daemon restart for native Steer, returns the same result. A cancellation failure + after consumption leaves one durable follow-up and is also returned idempotently. Native + rejection receipts are written only after fallback publication and row cleanup succeed. +- Recover native handoff as a saga with `reserved`, write-ahead `submitting`, provider + `acknowledged`, locally committed `applied`, and `fallback` phases in a machine-local, + daemon-owned marker. Shared Session metadata is not trusted as a recovery authority. + `reserved` work that reached history and `fallback` are safe to convert to exact ordinary + dispatch; a pre-history reservation leaves the row queued. `submitting` is indeterminate and + `acknowledged` may have side effects, so both fail visibly without replay after restart. + `applied` proves the local handoff and retains an accepted receipt. The row stays present until + the durable result is complete. +- Make the leading number and message body a single pointer and keyboard drag activator. + Keep action buttons outside it, and disable it while the row editor owns interaction. + +## Alternatives and trade-offs + +Keeping Steer on the first row would require users to perform an unrelated reorder first. +Reorder-then-cancel was rejected because reorder can resolve after a concurrent peer deleted +the selected row, causing Stop to target the current turn without any message to promote. +Renderer-side history materialization remains only for old-daemon native compatibility; it +cannot provide exact-item atomicity. Removing native steering was rejected because +`steerPrompt` injects into the current prompt, while cancel-and-dispatch starts a new turn. +Treating the provider call and CRDT writes as atomic was rejected because ACP exposes neither +an idempotent caller-owned submission key nor a delivery query; replaying an indeterminate call +could execute tools twice. +Making the complete row draggable was also rejected because Steer, edit, and remove would +become accidental drag starters. + +## Verification and limits + +- Routing tests cover Queue → Steer and Steer → Queue inversion while a prompt is live. +- Command tests cover the default binding, explicit submission option, and command-level + composer-focus rule. +- Queue component tests cover later-row Steer, old-daemon head-only disabling, authoritative + legacy native selection, and the drag activator boundary. +- CLI service and Session Doc tests cover exact C consumption, native `steerPrompt`, exact + cancellation, forged and missing native identity, missing and active-edit rejection, + activation-publication failure, cancellation failure, and response-loss retries. +- Crash/recovery tests cover retry after failed fallback publication, exact recovery from + `reserved`, non-replay of `submitting`/`acknowledged` provider calls, `applied` cleanup, and + durable receipt replay after restart. +- Machine RPC and protocol-capability tests cover the queue/turn identities, rejection of a + requester identity claim, source authorization, and mixed-version negotiation. +- Component tests use synthetic pointer state. Physical touch dragging and a full + provider-backed steer run were not exercised. + +## References + +- [Message queue interaction Spec](../../../../specs/message-queue-interactions.md) +- [Queue scope](../../../../packages/components/src/components/sessions/message-queue/AGENTS.md) +- [Submission routing](../../../docs/sessions-live-status.md) diff --git a/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md new file mode 100644 index 000000000..81273f949 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-13-queue-steer-controls.zh.md @@ -0,0 +1,78 @@ +# 队列反转与直接操作 + +Status: implemented +Translation: current + +[English](2026-09-13-queue-steer-controls.md) + +## 摘要 + +此前若要在“排队”和“引导”间切换,必须修改持久设置;后续队列项不显示“引导”,重排也只能 +从狭小的左侧把手开始。本次采用一次性反转提交命令、在 daemon 能安全识别目标行时让每行 +显示“引导”,并把消息内容区作为拖动区域。队列顺序与立即“引导”保持独立。精确队列项引导 +必须版本协商、保留 native ACP Steer、遵守 editing lease,并在目标 ID 缺失时保持当前 turn 不变。 + +## 决策 + +- 在命令系统注册 `session.sendWithInverseQueueBehavior`,默认仅在输入框聚焦时由 + `Mod+Shift+Enter` 触发。输入框直接调用 `sendMessage({ queueBehavior: "inverse" })`,普通 + 提交不传选项。输入框聚焦、有内容且可以发送是命令级条件,因此用户覆盖 binding 后也不会 + 丢失;路由器只反转本次有效偏好。 +- 在 `MachineMeta.protocolCapabilities` 声明 `queueItemSteer`。只有该版本存在时,Renderer + 才调用 `session/queue-steer`;缺失即表示不支持。请求携带队列 `$cid` 与预期活动 turn, + Renderer 等待结果,不在本地修改队列或历史。 +- 远程请求写入前,Renderer 必须使用已认证且以 Convex 为权威来源的可见 machine 快照做 + 授权;快照尚不可用或不包含目标时应 fail closed。RPC 不携带请求者身份,因为目标 daemon + 无法认证 workspace stream 写入者声称的身份。同机 local IPC 仍是可信的本地控制路径。 +- Daemon 确认两个 ID 及目标 editing lease 后决定执行机制。支持 acknowledged native ACP + Steer 时,先记录持久阶段标记,在 history 为 `pending_apply` 期间保留目标 row,再进入既有 + `steerPrompt` handoff。Native Steer + 的 requester 必须来自已认证的活动 invocation,绝不信任共享 queue row;若缺少该冻结身份, + 应在消费前失败。否则消费为 + 普通 pending turn,发布 activation pointer,并仅在两项写入都成功后删除 queue row,再只 + 取消预期活动 turn。部分发布失败时保留 row 作为重试标记,并复用已有 history ID。 +- 混合版本兼容不得恢复“先重排后取消”。旧 daemon 若 authoritative capability 声明支持 + acknowledged Steer,则使用旧 native 路径;其他旧 daemon 只保留既有队首 interrupt,后续行 + “引导”禁用并提示升级。 +- Daemon 为已完成的精确操作 key 保留有界内存 receipt,并在 native saga marker 中保留最近一次 + 持久结果。响应丢失后的重试(包括 native Steer 后 daemon 重启)返回相同结果,不再次消费或 + 取消。消费后取消失败也只留下一个持久 follow-up。Native 拒绝只有在 fallback activation 和 + row 清理持久成功后才完成 receipt。 +- Native handoff 以 machine-local、由 daemon 持有的 marker 中的 `reserved`、write-ahead + `submitting`、provider `acknowledged`、本地已提交 `applied`、`fallback` 阶段组成 saga;共享 + Session metadata 不作为恢复权威。已进入 history 的 + `reserved` 与 `fallback` 可安全恢复为精确普通 dispatch;写 history 前中断则保留 row。 + `submitting` 结果不确定,`acknowledged` 可能已有 side effect,重启后两者均落成可见失败且不 + 重放。`applied` 证明本地 handoff 已完成并恢复 accepted 回执。持久结果完成前始终保留目标 row。 +- 左侧序号和消息正文合并为一个支持鼠标及键盘的拖动区域。操作按钮保持在区域外;编辑器 + 接管交互时禁用该行拖动。 + +## 备选方案与取舍 + +只允许队首“引导”会迫使用户先做一次无关重排。没有采用“重排后取消”:并发客户端删除所选 +行时,重排仍可能 resolve,进而在没有可提升消息的情况下错误 Stop 当前 turn。也没有让 +Renderer 写历史仅保留给旧 daemon 的 native 兼容路径;它无法提供精确队列项原子性。没有删除 +native steer,因为 `steerPrompt` 注入当前 prompt,而 cancel-and-dispatch 会开启新 turn,两者 +语义不同。也不能把 provider 调用和 CRDT 写入宣称为原子操作:ACP 既没有 caller-owned 幂等 +提交键,也没有交付查询,重放结果不确定的请求可能重复执行工具。没有让整行都可拖动,因为 +那会使“引导”、“编辑”和“移除”成为意外拖动起点。 + +## 验证与边界 + +- 路由测试覆盖 prompt 活动时“排队 → 引导”和“引导 → 排队”的反转。 +- 命令测试覆盖默认 binding、显式提交选项和命令级输入框聚焦规则。 +- 队列组件测试覆盖后续行引导、旧 daemon 仅启用队首、authoritative legacy native 选择,以及 + 拖动区域边界。 +- CLI service 与 Session Doc 测试覆盖精确消费 C、native `steerPrompt`、精确取消、目标缺失、 + 伪造或缺失的 native identity、editing lease、activation 发布失败、取消失败及响应丢失重试。 +- 崩溃恢复测试覆盖 fallback 发布失败后的重试、`reserved` 精确恢复、`submitting`/ + `acknowledged` provider 调用不重放、`applied` 清理,以及重启后的持久回执重放。 +- Machine RPC 与 protocol capability 测试覆盖队列/turn 两个 ID、拒绝请求者身份声明、来源 + 授权以及混合版本协商。 +- 组件测试使用合成指针状态;未验证物理触摸拖动和完整的 Provider-backed steer 流程。 + +## 参考 + +- [消息队列交互 Spec](../../../../specs/message-queue-interactions.zh.md) +- [队列作用域](../../../../packages/components/src/components/sessions/message-queue/AGENTS.md) +- [提交路由](../../../docs/sessions-live-status.md) diff --git a/apps/cli/src/lib/AGENTS.md b/apps/cli/src/lib/AGENTS.md index c722c2151..7a41f9466 100644 --- a/apps/cli/src/lib/AGENTS.md +++ b/apps/cli/src/lib/AGENTS.md @@ -33,13 +33,11 @@ end-to-end map. The WS/DO control-plane path is DEPRECATED; do not add to it. deliberately short, so a queued detach/revoke stays prompt. Backfill enable/disable flips its authorization generation inside the queued body (S5: a revoked workspace must never keep backfill enabled). -- **Dual-author (no write intents)**: the renderer direct-authors user/UI durable - writes against its own repo over its own Streams connection; the CLI authors only - agent-produced data. The v4-v6 write-intent envelope (`WorkspaceWriteIntentAuthor`, - `intent`/`intent-ack` frames, CLI preview-comment mirror) is REMOVED; never - reintroduce a proxy-authoring path (invariants in `specs/local-first-two-plane.md`). - Local dispatch triggers off the renderer-authored `latestUserMsgId` doc-meta write - plus the local Machine RPC fast path. +- Renderer user/UI writes use its own repo; never restore generic v4-v6 write intents + or CLI preview-comment mirrors. The narrow exception is `session/queue-mutate` + on queueItemSteer v2: edit/remove/reorder require daemon ownership and revision checks + shared with reservation/promotion. Queue enqueue and ordinary sends remain renderer-authored. + Local dispatch watches `latestUserMsgId` plus the Machine RPC fast path. ## Local Loro data plane diff --git a/apps/cli/src/lib/loro/AGENTS.md b/apps/cli/src/lib/loro/AGENTS.md index 46947501b..f4fddf05a 100644 --- a/apps/cli/src/lib/loro/AGENTS.md +++ b/apps/cli/src/lib/loro/AGENTS.md @@ -49,7 +49,8 @@ Rules: fork targets). The dispatch watcher's contract, "session metadata is the activation index", is -documented in `../../session/AGENTS.md` and applies to any module enumerating rooms. +documented in `../../session/AGENTS.md`. Queue promotion retains its row until history +and that metadata activation are durable; the rule also applies to room enumeration. ## Shared ACP runtime config contains no secrets diff --git a/apps/cli/src/lib/loro/doc-user-turn.test.ts b/apps/cli/src/lib/loro/doc-user-turn.test.ts index fefe46b75..83f383b3c 100644 --- a/apps/cli/src/lib/loro/doc-user-turn.test.ts +++ b/apps/cli/src/lib/loro/doc-user-turn.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import type { SessionHistoryInput, SessionId } from '@lody/shared'; +import { + getServerNow, + queueItemRevision, + type MessageQueueItem, + type SessionHistoryInput, + type SessionId, +} from '@lody/shared'; import { LoroDoc, LoroList, LoroMap } from 'loro-crdt'; import type { LoroRepo } from 'loro-repo'; @@ -21,17 +27,32 @@ const createLogger = (): Logger => * storage entry. `loro.toJSON().history` is the stored state and * `doc.readHistorySnapshot()` reads it back through the session-data seam. */ -const createSessionDocument = (repo: Partial, loroDoc?: LoroDoc) => { +const createSessionDocument = ( + repo: Partial, + options: { + loroDoc?: LoroDoc; + } = {} +) => { const doc = new SessionDocument( repo as LoroRepo, 'session-append-1' as SessionId, async () => {}, createLogger() ); - const loro = composeTestSessionDoc(doc, loroDoc ? { doc: loroDoc } : undefined); + const loro = composeTestSessionDoc(doc, { + ...(options.loroDoc ? { doc: options.loroDoc } : {}), + }); return { doc, loro }; }; +const seedMessageQueue = async ( + doc: SessionDocument, + items: Array> +): Promise => { + for (const item of items) await doc.pushMessageQueue(item); + return await doc.getMessageQueue(); +}; + const createUserTurn = (id: string): SessionHistoryInput => ({ id, role: 'user', @@ -42,6 +63,159 @@ const createUserTurn = (id: string): SessionHistoryInput => ({ userId: 'user-1', }); +describe('SessionDocument.consumeMessageQueueItemAsUserTurn', () => { + it('rejects stale edits, missing rows and stale reorder snapshots without losing accepted content', async () => { + const { doc } = createSessionDocument({ upsertDocMeta: async () => {} }); + const rows = await seedMessageQueue( + doc, + ['A', 'B', 'C'].map((task) => ({ + task, + userId: 'user-1', + timestamp: '2026-09-14T00:00:00.000Z', + })) + ); + const selected = rows[2]!; + const update = { + kind: 'update' as const, + queueItemId: selected.$cid, + expectedRevision: queueItemRevision(selected), + patch: { task: 'Roll back instead' }, + }; + await doc.mutateMessageQueue(update); + await expect( + doc.mutateMessageQueue({ ...update, patch: { task: 'Stale edit' } }) + ).rejects.toThrow('changed'); + expect((await doc.getMessageQueue())[2]?.task).toBe('Roll back instead'); + await doc.removeMessageQueueItem(selected.$cid); + await expect(doc.mutateMessageQueue(update)).rejects.toThrow('no longer'); + await expect( + doc.mutateMessageQueue({ + kind: 'reorder', + expectedItemIds: rows.map((row) => row.$cid), + orderedItemIds: rows.map((row) => row.$cid).reverse(), + }) + ).rejects.toThrow('changed'); + expect((await doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B']); + }); + it('consumes the named later row after activation without reordering the survivors', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const { doc } = createSessionDocument({ upsertDocMeta }); + const queue = await seedMessageQueue( + doc, + ['A', 'B', 'C'].map((label) => ({ + task: `task ${label}`, + timestamp: '2026-09-13T00:00:00.000Z', + })) + ); + const target = queue[2]!; + + const result = await doc.consumeMessageQueueItemAsUserTurn(target.$cid, () => + createUserTurn('user:C') + ); + + expect(result).toMatchObject({ type: 'consumed', entry: { id: 'user:C' } }); + expect((await doc.sessionData.history.readAll()).map((entry) => entry.id)).toEqual(['user:C']); + expect((await doc.getMessageQueue()).map((item) => item.task)).toEqual(['task A', 'task B']); + expect(upsertDocMeta).toHaveBeenCalledWith(doc.roomId, { latestUserMsgId: 'user:C' }); + }); + + it('retains the queue row and resumes publication without duplicating history', async () => { + const upsertDocMeta = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('metadata unavailable')) + .mockResolvedValue(undefined); + const { doc } = createSessionDocument({ upsertDocMeta }); + const [target] = await seedMessageQueue(doc, [ + { + task: 'task C', + timestamp: '2026-09-13T00:00:00.000Z', + }, + ]); + + await expect( + doc.consumeMessageQueueItemAsUserTurn(target!.$cid, () => createUserTurn('user:C')) + ).rejects.toThrow('metadata unavailable'); + expect((await doc.sessionData.history.readAll()).map((entry) => entry.id)).toEqual(['user:C']); + expect((await doc.getMessageQueue()).map((item) => item.task)).toEqual(['task C']); + + await expect( + doc.consumeMessageQueueItemAsUserTurn(target!.$cid, () => createUserTurn('user:C')) + ).resolves.toMatchObject({ type: 'consumed', entry: { id: 'user:C' } }); + expect((await doc.sessionData.history.readAll()).map((entry) => entry.id)).toEqual(['user:C']); + expect(await doc.getMessageQueue()).toEqual([]); + expect(upsertDocMeta).toHaveBeenCalledTimes(2); + }); + + it('does not write history or a dispatch pointer when the identity is absent', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const { doc } = createSessionDocument({ upsertDocMeta }); + await seedMessageQueue(doc, [ + { + task: 'task A', + timestamp: '2026-09-13T00:00:00.000Z', + }, + ]); + + await expect( + doc.consumeMessageQueueItemAsUserTurn('missing', () => createUserTurn('user:C')) + ).resolves.toEqual({ type: 'missing' }); + expect(await doc.sessionData.history.readAll()).toEqual([]); + expect((await doc.getMessageQueue()).map((item) => item.task)).toEqual(['task A']); + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); + + it('does not consume an item while another client holds its editing lease', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const { doc } = createSessionDocument({ upsertDocMeta }); + const queue = await seedMessageQueue(doc, [ + { + task: 'task C', + timestamp: '2026-09-13T00:00:00.000Z', + isEditing: true, + editingStartedAt: getServerNow(), + }, + ]); + const target = queue[0]!; + + await expect( + doc.consumeMessageQueueItemAsUserTurn(target.$cid, () => createUserTurn('user:C')) + ).resolves.toEqual({ type: 'editing' }); + expect(await doc.sessionData.history.readAll()).toEqual([]); + expect((await doc.getMessageQueue()).map((item) => item.task)).toEqual(['task C']); + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); + + it('can reserve an exact item for native steer without publishing ordinary dispatch', async () => { + const upsertDocMeta = vi.fn(async () => {}); + const { doc } = createSessionDocument({ upsertDocMeta }); + const queue = await seedMessageQueue(doc, [ + { + task: 'task C', + timestamp: '2026-09-13T00:00:00.000Z', + }, + ]); + const target = queue[0]!; + + await expect( + doc.consumeMessageQueueItemAsUserTurn( + target.$cid, + () => ({ ...createUserTurn('user:C'), status: 'pending_apply' }), + { publishDispatch: false } + ) + ).resolves.toMatchObject({ type: 'consumed', entry: { id: 'user:C' } }); + await expect( + doc.consumeMessageQueueItemAsUserTurn( + target.$cid, + () => ({ ...createUserTurn('user:C'), status: 'pending_apply' }), + { publishDispatch: false } + ) + ).resolves.toMatchObject({ type: 'consumed', entry: { id: 'user:C' } }); + expect((await doc.sessionData.history.readAll()).map((entry) => entry.id)).toEqual(['user:C']); + expect((await doc.getMessageQueue()).map((item) => item.task)).toEqual(['task C']); + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); +}); + describe('SessionDocument.appendUserTurn', () => { it('opens old malformed notices without sanitizing stored history', () => { const loro = new LoroDoc(); @@ -57,7 +231,7 @@ describe('SessionDocument.appendUserTurn', () => { const version = loro.version().toJSON(); const history = loro.getList('history').toJSON(); // Exercise the actual production composition over pre-existing storage. - const { doc } = createSessionDocument({}, loro); + const { doc } = createSessionDocument({}, { loroDoc: loro }); expect(loro.version().toJSON()).toEqual(version); expect(loro.getList('history').toJSON()).toEqual(history); doc.mirror?.dispose(); diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 759f807ef..968ac49cf 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -1,3 +1,4 @@ +import { queueItemRevision, ACPSessionConfigSchema, type SessionQueueMutation } from '@lody/shared'; import { createSessionAgentWrites, type SessionAgentWrites } from './session-agent-writes'; import { readSessionHistory } from '@lody/shared/session-data'; import { readLatestTurn } from '@lody/shared/session-data'; @@ -20,6 +21,7 @@ import { AgentConfigId, MachineId, ManagedBuiltinAgentType, + type SessionHistory, SessionHistoryInput, isCodeCollabFileIndexFlockDocId, isCodeCollabFileIndexSignalFlockDocId, @@ -326,6 +328,7 @@ export type LoroRepoPersistReason = | 'session-fork-prepare' | 'session-fork-commit' | 'session-fork-rollback' + | 'queue-steer-commit' | 'session-edit-and-resend-commit' | 'session-edit-and-resend-rollback'; @@ -1742,6 +1745,12 @@ const acpRuntimeConfigEqual = ( */ const EDITING_LEASE_MS = 5 * 60 * 1000; +export const hasActiveMessageQueueEditingLease = (item: MessageQueueItem): boolean => { + if (!item.isEditing) return false; + const startedAt = item.editingStartedAt ?? 0; + return getServerNow() - startedAt < EDITING_LEASE_MS; +}; + /** * Subscribe to any session change for a composed session document: control fields * from the control-plane Mirror plus history from the session-data observation. A @@ -2740,9 +2749,7 @@ export class SessionDocument implements LoroDocument { - if (!this.mirror) { - throw new Error('SessionDocument not initialized'); - } - + /** Compare and mutate synchronously; callers hold the daemon's queue ownership guard. */ + async mutateMessageQueue(mutation: SessionQueueMutation['mutation']): Promise { + if (!this.mirror) throw new Error('SessionDocument not initialized'); this.mirror.setState((prev) => { - const mq = (prev.mq ?? []) as MessageQueueItem[]; - // @ts-ignore - mq is read-only in type but writable at runtime - prev.mq = mq.filter((item: MessageQueueItem) => item.$cid !== cid); + const queue = (prev.mq ?? []) as MessageQueueItem[]; + let next: MessageQueueItem[]; + if (mutation.kind === 'reorder') { + const ids = queue.map((item) => item.$cid); + if ( + queueItemRevision(ids) !== queueItemRevision(mutation.expectedItemIds) || + new Set(mutation.orderedItemIds).size !== ids.length || + mutation.orderedItemIds.some((id) => !ids.includes(id)) + ) { + throw new Error('The queue changed. Refresh before reordering.'); + } + if (queue.some(hasActiveMessageQueueEditingLease)) + throw new Error('A queue item is being edited.'); + next = mutation.orderedItemIds.map((id) => queue.find((item) => item.$cid === id)!); + } else { + const row = queue.find((item) => item.$cid === mutation.queueItemId); + if (!row) + throw new Error( + 'This message is no longer in the editable queue. Your draft was not saved.' + ); + if (queueItemRevision(row) !== mutation.expectedRevision) { + throw new Error('This queued message changed. Your draft was not saved.'); + } + if (mutation.kind === 'remove') { + next = queue.filter((item) => item !== row); + } else { + const patch = mutation.patch; + const mutable = new Set(['task', 'isEditing', 'editingStartedAt', 'acpSessionConfig']); + for (const [key, value] of Object.entries(patch)) { + if ( + !mutable.has(key) && + queueItemRevision(value) !== + queueItemRevision((row as unknown as Record)[key]) + ) { + throw new Error('Queue message identity cannot be changed.'); + } + } + if (patch.task !== undefined && typeof patch.task !== 'string') + throw new Error('Invalid queued message text.'); + if (patch.isEditing !== undefined && typeof patch.isEditing !== 'boolean') + throw new Error('Invalid editing state.'); + if ( + patch.editingStartedAt !== undefined && + (typeof patch.editingStartedAt !== 'number' || !Number.isFinite(patch.editingStartedAt)) + ) { + throw new Error('Invalid editing lease.'); + } + if (patch.acpSessionConfig !== undefined) + ACPSessionConfigSchema.partial().parse(patch.acpSessionConfig); + next = queue.map((item) => + item === row ? ({ ...row, ...patch, $cid: row.$cid } as MessageQueueItem) : item + ); + } + } + // @ts-ignore - the mirror exposes a readonly snapshot but owns this mutation + prev.mq = next; return prev; }); + await this.repo.upsertDocMeta(this.roomId, { messageQueueUpdatedAt: getServerNow() }); } - /** - * Replace the fields of the message-queue item identified by `$cid`. The - * caller supplies the fully-resolved next field set (the renderer resolves its - * updater function to a concrete value before sending the intent), so this is a - * full replacement of the item's non-`$cid` fields — matching the single-author - * write-intent contract (`session-mq-update`). - */ - async updateMessageQueueItem(cid: string, patch: Partial): Promise { + async removeMessageQueueItem(cid: string): Promise { if (!this.mirror) { throw new Error('SessionDocument not initialized'); } + this.mirror.setState((prev) => { const mq = (prev.mq ?? []) as MessageQueueItem[]; // @ts-ignore - mq is read-only in type but writable at runtime - prev.mq = mq.map((item: MessageQueueItem) => - item.$cid === cid ? ({ ...item, ...patch, $cid: item.$cid } as MessageQueueItem) : item - ); + prev.mq = mq.filter((item: MessageQueueItem) => item.$cid !== cid); return prev; }); } /** - * Reorder the message queue to the given `$cid` order. `orderedCids` is the full - * resulting order from the renderer (`session-mq-reorder`); items whose `$cid` - * is absent from the list are dropped to the end in their existing relative - * order (defensive — the renderer always sends the complete set). + * Consume one exact queue identity without losing its dispatch retry marker. + * + * The callback and editing-lease check run against the current queue row, with + * no await gap before the shared HistoryWriter accepts the turn. Ordinary + * dispatch retains the row until its metadata activation pointer is durable; + * native Steer leaves removal to QueueSteerService, before provider submission. */ - async reorderMessageQueue(orderedCids: readonly string[]): Promise { + async consumeMessageQueueItemAsUserTurn( + cid: string, + buildEntry: (item: MessageQueueItem) => SessionHistoryInput | null, + options: { publishDispatch?: boolean } = {} + ): Promise< + | { type: 'consumed'; entry: SessionHistoryInput } + | { type: 'missing' } + | { type: 'editing' } + | { type: 'invalid' } + > { if (!this.mirror) { throw new Error('SessionDocument not initialized'); } + + const outcome: { + value: + | { type: 'consumed'; entry: SessionHistoryInput } + | { type: 'missing' } + | { type: 'editing' } + | { type: 'invalid' }; + } = { value: { type: 'missing' } }; + const publishDispatch = options.publishDispatch !== false; this.mirror.setState((prev) => { - const mq = (prev.mq ?? []) as MessageQueueItem[]; - const byCid = new Map(mq.map((item) => [item.$cid, item] as const)); - const ordered: MessageQueueItem[] = []; - for (const cid of orderedCids) { - const item = byCid.get(cid); - if (item) { - ordered.push(item); - byCid.delete(cid); - } + const queue = (prev.mq ?? []) as MessageQueueItem[]; + const item = queue.find((candidate) => candidate.$cid === cid); + if (!item) return prev; + if (hasActiveMessageQueueEditingLease(item)) { + outcome.value = { type: 'editing' }; + return prev; + } + + const entry = buildEntry(item); + if (!entry) { + outcome.value = { type: 'invalid' }; + return prev; } - for (const item of mq) { - if (item.$cid !== undefined && byCid.has(item.$cid)) { - ordered.push(item); + if (entry.role !== 'user') { + throw new Error( + `consumeMessageQueueItemAsUserTurn requires a user entry, received role "${entry.role}" for ${entry.id}` + ); + } + const existing = this.sessionData.writer.read(entry.id); + if (existing) { + const status = resolveSessionHistoryStatus(existing); + if ( + existing.role !== 'user' || + (publishDispatch ? status !== 'pending' && status !== 'seen' : status !== 'pending_apply') + ) { + outcome.value = { type: 'invalid' }; + return prev; } + // A previous ordinary consume may have committed history before metadata + // publication failed. Reuse that durable turn instead of appending it twice. + outcome.value = { type: 'consumed', entry: existing as SessionHistoryInput }; + } else { + this.sessionData.writer.append(entry as SessionHistory); + outcome.value = { type: 'consumed', entry }; } - // @ts-ignore - mq is read-only in type but writable at runtime - prev.mq = ordered; return prev; }); + + if (outcome.value.type === 'consumed' && publishDispatch) { + await this.repo.upsertDocMeta(this.roomId, { + latestUserMsgId: outcome.value.entry.id, + } satisfies Partial); + await this.removeMessageQueueItem(cid); + } + return outcome.value; } async destroy(options: { preserveStatus?: boolean } = {}) { diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index fdae8ea4f..00c33a931 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -287,6 +287,7 @@ import { SessionDispatchWatcher } from '@/session/session-dispatch-watcher'; import { SessionUserResolver } from '@/session/session-user-resolver'; import { SessionForkService } from '@/session/session-fork-service'; import { createFileSessionForkOperationStore } from '@/session/session-fork-operation-store'; +import { createFileQueueSteerOperationStore } from '@/session/session-queue-steer-operation-store'; import { SessionEditAndResendService, type SessionEditAndResendInput, @@ -2961,6 +2962,10 @@ export class MessageHandler { machineId: this.machineId, userId: this.userId, workspaceId: this.workspaceId, + queueSteerOperationStore: createFileQueueSteerOperationStore({ + workspaceId: this.workspaceId, + machineId: this.machineId, + }), preferredBaseBranch: this.preferredBaseBranch, touchSession: (sessionId) => this.touchSession(sessionId), startSessionActivePresence: (sessionId, phase) => @@ -3280,6 +3285,8 @@ export class MessageHandler { }; }, steerSession: async (args) => await this.steerSessionWithAccessCheck(args), + mutateQueuedMessage: async (args) => await this.executionService.mutateQueuedMessage(args), + steerQueuedMessage: async (args) => await this.executionService.steerQueuedMessage(args), controlSessionGoal: async (args) => await this.controlSessionGoalWithAccessCheck(args), terminateSession: async ({ sessionId }) => await this.terminateAcpSession(sessionId), forkSession: async (args) => await this.forkSessionWithAccessCheck(args), @@ -3410,6 +3417,11 @@ export class MessageHandler { }, onFatalAuthFailure: (error) => this.onFatalAuthFailure?.(error), }); + void this.executionService.recoverPendingQueueSteers().catch((error: unknown) => { + this.logger.debug( + `[queue-steer] Failed to recover pending operations: ${formatErrorMessage(error)}` + ); + }); this.sessionForkService = new SessionForkService({ workspaceDocument: this.workspaceDocument, sessionManager: this.sessionManager, @@ -6315,6 +6327,14 @@ export class MessageHandler { sessionId: request.params.sessionId as SessionId, }); } + case 'session/queue-mutate': + return await this.executionService.mutateQueuedMessage(request.params); + case 'session/queue-steer': { + return await this.executionService.steerQueuedMessage({ + ...request.params, + sessionId: request.params.sessionId as SessionId, + }); + } case 'session/goal': { return await this.controlSessionGoalWithAccessCheck({ ...request.params, diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index d06019006..4f47b6f54 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -25,7 +25,8 @@ Contract: specs/session-orchestration.md. ## Dispatch -- Queue promotion preserves frozen fields; remove its row only after history and activation succeed. +- QueueSteerService selects/recovers; ActiveTurnSteerPort prepares/submits. + Prepare before submitting evidence. No runtime handles/phase callbacks; stale/missing never stop. - Absent session meta is "unknown", not foreign: hold the TTL-bounded RPC stash until meta lands; drop it only on a definitive verdict. - Subscribe to RPC offers BEFORE awaiting Doc Room join/sync and never dispatch from the RPC @@ -55,10 +56,9 @@ Contract: specs/session-orchestration.md. tombstone; CLI dispatch producers keep their own marker policy. - Ordinary turn execution writes only `processingUserMsgId` and `lastHandledUserMsgId`; no start or terminal path may read-await-rewrite the other slots. -- Never submit steer after Stop. A late accepted ACK cancels that exact steer entry without - transferring ownership, changing dispatch pointers or requeueing it. - Requeue unaccepted steer via its pointer, not entry status, only before submission or on - `AgentSteerNotDeliveredError`; skip active or handled entries. +- No Steer after Stop; late ACK never replays. Exclude queue mutations/promotion; persist + marker/history/removal before submission. Scope owns guards only. Requeue only proven + non-delivery. Reserved without history clears marker, not row: retryable, no receipt. - Resume must REOPEN the in-progress assistant entry, clearing `finished`/`endedAt`/`permissionWaitMs` there only; never write `finished=false` from teardown. - Keep JSON-RPC/transport matching in `acp-error-classification.ts`: disposed/stale `-32603` is diff --git a/apps/cli/src/session/README.md b/apps/cli/src/session/README.md index dd818051b..9e982560f 100644 --- a/apps/cli/src/session/README.md +++ b/apps/cli/src/session/README.md @@ -11,6 +11,13 @@ CLI/MCP orchestration contract is specs/session-orchestration.md. ## Files +| Boundary | Entry | Ownership | +| --------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- | +| Dispatch | [SessionDispatchWatcher](session-dispatch-watcher.ts) | Observe durable activation and claim ordinary turns. | +| Queue operation | [QueueSteerService](queue-steer-service.ts) | Exact selection, reservation, delivery policy and recovery evidence. | +| Live execution | [ActiveTurnSteerPort](active-turn-steer-port.ts) | Native submission and handoff within SessionExecutionService ownership. | +| Storage | [SessionDocument](../lib/loro/doc.ts) | Revision-checked queue mutations and shared history writes. | + - `session-dispatch-watcher.ts` — the current dispatch entry: watches `repo.watch('doc-metadata')` plus a per-session mirror subscribe and dispatches when `latestUserMsgId` differs from `lastHandledUserMsgId`. Also accepts `session/dispatch-turn` @@ -19,10 +26,14 @@ CLI/MCP orchestration contract is specs/session-orchestration.md. authorized or executed. Its extensive header comment is the authoritative doc for edge cases (stale pointers, history/meta sync races). - `session-dispatch-logic.ts` — pure decision functions for the watcher (testable). +- `queued-message-turn.ts` — the shared queue-item-to-User-turn conversion used by + both ordinary queue dispatch and exact-item Steer consumption. - `turn-history-gate.ts` — ordering barrier for RPC fast-path turns. Created in message-handler's `beginConversationTurn`, stored/disposed via `SessionTransientStore` turn state; it creates the assistant entry when it opens. -- `session-execution-service.ts` — runs one turn end-to-end: ACP prompt, turn ids, +- `session-execution-service.ts` — runs one turn end-to-end, implements ActiveTurnSteerPort + and serializes queue operations without owning their delivery policy or journal. + It owns ACP prompt, turn ids, lifecycle/error handling, GitHub/local project setup, and post-turn diffStats. - `acp-error-classification.ts` — JSON-RPC/transport error string matching for the above. - `session-manager.ts` / `session.ts` / `session-sandbox.ts` / `terminal-manager.ts` — diff --git a/apps/cli/src/session/active-turn-steer-port.ts b/apps/cli/src/session/active-turn-steer-port.ts new file mode 100644 index 000000000..a27189d58 --- /dev/null +++ b/apps/cli/src/session/active-turn-steer-port.ts @@ -0,0 +1,65 @@ +import { Context, Data, Effect, type Scope } from 'effect'; +import type { SessionId, SessionTurnInputConfig, SessionSteerResponse } from '@lody/shared'; + +export type SteerTurn = { + id: string; + userId: string; + timestamp: string; + inputConfig: SessionTurnInputConfig; +}; + +export class StaleTurn extends Data.TaggedError('StaleTurn')<{ + disposition: 'no-active-turn' | 'stale-turn' | 'busy'; + message: string; +}> {} + +/** Only a proven refusal before injection permits ordinary dispatch. */ +export class ProviderRejected extends Data.TaggedError('ProviderRejected')<{ + disposition: Exclude; + message: string; +}> {} + +export class ProviderDeliveryUnknown extends Data.TaggedError('ProviderDeliveryUnknown')<{ + message: string; + cause?: unknown; +}> {} + +export class PersistenceFailure extends Data.TaggedError('PersistenceFailure')<{ + message: string; + cause: unknown; +}> {} + +/** Preparation failed before steer submission; the frozen turn may become ordinary dispatch. */ +export class SteerPreparationFailure extends Data.TaggedError('SteerPreparationFailure')<{ + message: string; + cause: unknown; +}> {} + +/** Opaque, single-use handle; execution state stays with the live-turn owner. */ +export class PreparedSteer extends Data.TaggedClass('PreparedSteer')<{}> {} + +export type NativeSteerFailure = StaleTurn | ProviderRejected | ProviderDeliveryUnknown; +export type NativeSteerInput = { + sessionId: SessionId; + expectedTurnId: string; + turn: SteerTurn; +}; + +/** Operations run inside the execution owner's per-session serialization boundary. */ +export class ActiveTurnSteerPort extends Context.Tag('lody/ActiveTurnSteerPort')< + ActiveTurnSteerPort, + { + guard(sessionId: SessionId): Effect.Effect; + inspect( + sessionId: SessionId, + expectedTurnId: string + ): Effect.Effect<{ native: boolean; requesterUserId: string }, StaleTurn | ProviderRejected>; + prepareSteer( + input: NativeSteerInput + ): Effect.Effect; + submitSteer(prepared: PreparedSteer): Effect.Effect<{ userTurnId: string }, NativeSteerFailure>; + cancel(sessionId: SessionId, expectedTurnId: string): Effect.Effect; + ownsPrompt(sessionId: SessionId, expectedTurnId: string): boolean; + activeUserTurnId(sessionId: SessionId): string | undefined; + } +>() {} diff --git a/apps/cli/src/session/queue-steer-service.ts b/apps/cli/src/session/queue-steer-service.ts new file mode 100644 index 000000000..6c586c5be --- /dev/null +++ b/apps/cli/src/session/queue-steer-service.ts @@ -0,0 +1,524 @@ +import { Context, Effect, Layer } from 'effect'; +import { + getServerNow, + normalizeSessionTurnInputConfig, + queueItemRevision, + resolveSessionHistoryStatus, + type SessionId, + type WorkspaceId, + type MachineId, + type ChatFailedReason, + type ChatFailedCode, + type SessionQueueSteerResponse, + type SessionQueueMutation, + type SessionQueueMutationResponse, +} from '@lody/shared'; +import { readSessionHistory } from '@lody/shared/session-data'; +import { + hasActiveMessageQueueEditingLease, + type SessionDocument, + type LoroDocumentManager, +} from '@/lib/loro/doc'; +import type { Logger } from '@/utils/logger'; +import { formatErrorMessage } from '@/utils/format-error'; +import { ActiveTurnSteerPort, PersistenceFailure } from './active-turn-steer-port'; +import { buildQueuedMessageUserTurn } from './queued-message-turn'; +import { + isQueueSteerMarkerOwnedBy, + type QueueSteerOperationMarker, + type QueueSteerOperationStore, +} from './session-queue-steer-operation-store'; + +type Request = { sessionId: SessionId; expectedTurnId: string; queueItemId: string }; +type Marker = QueueSteerOperationMarker; +type Recovery = SessionQueueSteerResponse | 'deferred' | 'retryable' | null; +export type QueueSteerServiceDeps = { + workspaceId: WorkspaceId; + machineId: MachineId; + workspaceDocument: Pick; + queueSteerOperationStore: QueueSteerOperationStore; + logger: Logger; + recordChatFailure( + sessionDoc: SessionDocument, + reason: ChatFailedReason, + message?: string, + code?: ChatFailedCode + ): Promise; + requeue( + sessionId: SessionId, + userTurnId: string + ): Promise<'requeued' | 'not-requeueable' | 'requeue-failed'>; + failTurn(sessionId: SessionId, doc: SessionDocument, userTurnId: string): Promise; +}; + +const persist = (run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (cause) => new PersistenceFailure({ cause, message: formatErrorMessage(cause) }), + }); + +const respond = ( + request: Pick, + disposition: SessionQueueSteerResponse['disposition'], + details: { userTurnId?: string; error?: string } = {} +): SessionQueueSteerResponse => ({ + type: 'session/queue-steer_response', + sessionId: request.sessionId, + queueItemId: request.queueItemId, + accepted: disposition === 'accepted', + disposition, + ...details, +}); + +/** Callers serialize these effects with the execution owner's session mutation queue. */ +export class QueueSteerService extends Context.Tag('lody/QueueSteerService')< + QueueSteerService, + { + steerQueueItem(request: Request): Effect.Effect; + recover( + sessionId: SessionId, + doc: SessionDocument + ): Effect.Effect; + mutate(request: SessionQueueMutation): Effect.Effect; + } +>() { + static layer(deps: QueueSteerServiceDeps) { + return Layer.effect(QueueSteerService, QueueSteerService.make(deps)); + } + + static make(deps: QueueSteerServiceDeps) { + return Effect.gen(function* () { + const active = yield* ActiveTurnSteerPort; + const receipts = new Map(); + const remember = (key: string, response: SessionQueueSteerResponse) => { + receipts.delete(key); + receipts.set(key, response); + if (receipts.size > 512) { + const oldest = receipts.keys().next().value; + if (oldest !== undefined) receipts.delete(oldest); + } + return response; + }; + const owned = (marker: Marker) => + isQueueSteerMarkerOwnedBy(marker, deps.workspaceId, deps.machineId); + const flush = () => + persist(() => deps.workspaceDocument.persistPendingChanges('queue-steer-commit')); + const write = ( + sessionId: SessionId, + marker: Omit + ) => { + const durable: Marker = { + ...marker, + sessionId, + workspaceId: deps.workspaceId, + machineId: deps.machineId, + updatedAt: getServerNow(), + }; + return persist(() => deps.queueSteerOperationStore.record(durable)).pipe( + Effect.as(durable) + ); + }; + const receipt = (sessionId: SessionId, marker: Marker) => + marker.completedAt && marker.response + ? respond({ sessionId, queueItemId: marker.queueItemId }, marker.response.disposition, { + userTurnId: marker.response.userTurnId, + error: marker.response.error, + }) + : null; + const complete = Effect.fn('QueueSteer.complete')(function* ( + sessionId: SessionId, + marker: Marker, + response: SessionQueueSteerResponse + ) { + yield* write(sessionId, { + ...marker, + completedAt: getServerNow(), + response: { + disposition: response.disposition, + userTurnId: response.userTurnId, + error: response.error, + }, + }); + return remember(marker.operationKey, response); + }); + + // A surviving legacy row may contain an edit accepted after its history was frozen. + // Preserve it and defer rather than silently deleting somebody else's work. + const removeReservedRow = Effect.fn('QueueSteer.removeReservedRow')(function* ( + doc: SessionDocument, + marker: Marker + ) { + const row = (yield* persist(() => doc.getMessageQueue())).find( + (item) => item.$cid === marker.queueItemId + ); + if (row) { + let same = marker.queueRevision === queueItemRevision(row); + if (!marker.queueRevision && !hasActiveMessageQueueEditingLease(row)) { + const meta = yield* persist(() => doc.getMetaState()); + const frozen = readSessionHistory(doc.sessionData.history).find( + (entry) => entry.id === marker.userTurnId + ); + const candidate = meta ? buildQueuedMessageUserTurn(row, meta) : null; + same = + !!candidate && + !!frozen && + candidate.id === frozen.id && + candidate.timestamp === frozen.timestamp && + queueItemRevision(candidate.items) === queueItemRevision(frozen.items) && + queueItemRevision(normalizeSessionTurnInputConfig(candidate.inputConfig)) === + queueItemRevision(normalizeSessionTurnInputConfig(frozen.inputConfig)); + } + if (!same || hasActiveMessageQueueEditingLease(row)) { + return yield* new PersistenceFailure({ + cause: undefined, + message: + 'The reserved queue row changed. Its edited content was preserved; delivery requires reconciliation.', + }); + } + yield* persist(() => doc.removeMessageQueueItem(marker.queueItemId)); + } + yield* flush(); + return undefined; + }); + + const fallback = Effect.fn('QueueSteer.fallback')(function* ( + sessionId: SessionId, + doc: SessionDocument, + marker: Marker, + response: SessionQueueSteerResponse + ) { + const durable = yield* write(sessionId, { + ...marker, + phase: 'fallback', + completedAt: undefined, + response: { + disposition: response.disposition, + userTurnId: response.userTurnId, + error: response.error, + }, + }); + yield* removeReservedRow(doc, durable); + const result = yield* persist(() => deps.requeue(sessionId, marker.userTurnId)); + if (result === 'requeue-failed') { + return yield* new PersistenceFailure({ + cause: undefined, + message: 'Failed to recover the reserved turn for dispatch.', + }); + } + yield* flush(); + return yield* complete(sessionId, durable, response); + }); + + const recoverMarker = Effect.fn('QueueSteer.recoverMarker')(function* ( + sessionId: SessionId, + doc: SessionDocument, + marker: Marker + ) { + const previous = receipt(sessionId, marker); + if (previous) return remember(marker.operationKey, previous); + if (!owned(marker)) return 'deferred'; + if ( + (marker.phase === 'submitting' || marker.phase === 'acknowledged') && + active.ownsPrompt(sessionId, marker.expectedTurnId) + ) + return 'deferred'; + yield* persist(async () => { + await doc.waitUntilSynced?.(); + }); + const entry = readSessionHistory(doc.sessionData.history).find( + (item) => item.role === 'user' && item.id === marker.userTurnId + ); + const request = { sessionId, queueItemId: marker.queueItemId }; + if (!entry) { + if (marker.phase !== 'reserved') return 'deferred'; + yield* persist(() => deps.queueSteerOperationStore.clear(sessionId)); + return 'retryable'; + } + if (marker.phase === 'reserved' || marker.phase === 'fallback') { + return yield* fallback( + sessionId, + doc, + marker, + marker.response + ? respond(request, marker.response.disposition, marker.response) + : respond(request, 'error', { + userTurnId: marker.userTurnId, + error: 'The daemon restarted before submission; the reserved turn was queued.', + }) + ); + } + const applied = + marker.phase === 'applied' || + active.activeUserTurnId(sessionId) === marker.userTurnId || + resolveSessionHistoryStatus(entry) === 'handled'; + const status = resolveSessionHistoryStatus(entry); + if ( + active.activeUserTurnId(sessionId) !== marker.userTurnId && + status !== 'handled' && + status !== 'failed' && + status !== 'canceled' + ) { + yield* persist(() => deps.failTurn(sessionId, doc, marker.userTurnId)); + yield* persist(() => + deps.recordChatFailure( + doc, + 'agent_disconnected', + applied + ? 'The daemon restarted after Steer handoff. The provider call was not replayed.' + : 'Steer delivery could not be confirmed after restart. The provider call was not replayed.' + ) + ); + } + yield* removeReservedRow(doc, marker); + return yield* complete( + sessionId, + applied ? { ...marker, phase: 'applied' } : marker, + respond(request, applied ? 'accepted' : 'error', { + userTurnId: marker.userTurnId, + ...(applied + ? {} + : { error: 'Native Steer delivery is indeterminate; it was not replayed.' }), + }) + ); + }); + + const recover = (sessionId: SessionId, doc: SessionDocument) => + Effect.scoped( + Effect.gen(function* () { + const guarded = yield* Effect.either(active.guard(sessionId)); + if (guarded._tag === 'Left') return 'deferred' as const; + const marker = yield* persist(() => deps.queueSteerOperationStore.read(sessionId)); + return marker ? yield* recoverMarker(sessionId, doc, marker) : null; + }) + ); + + const steerQueueItem = (request: Request) => + Effect.scoped( + Effect.gen(function* () { + const { sessionId, expectedTurnId, queueItemId } = request; + const operationKey = JSON.stringify([sessionId, expectedTurnId, queueItemId]); + const cached = receipts.get(operationKey); + if (cached) return cached; + yield* active.guard(sessionId); + const doc = yield* persist(() => + deps.workspaceDocument.getOrCreateSessionDoc(sessionId) + ); + const meta = yield* persist(() => doc.getMetaState()); + if (!meta) + return respond(request, 'error', { error: 'Session metadata is unavailable.' }); + const previous = yield* persist(() => deps.queueSteerOperationStore.read(sessionId)); + if (previous) { + if (!owned(previous)) return respond(request, 'busy'); + const previousReceipt = receipt(sessionId, previous); + if (previous.operationKey === operationKey) { + if (previousReceipt) return remember(operationKey, previousReceipt); + const recovered = yield* recoverMarker(sessionId, doc, previous); + if (recovered !== 'retryable') + return recovered && recovered !== 'deferred' + ? recovered + : respond(request, 'error', { + error: 'The previous delivery is still indeterminate.', + }); + } else if (!previousReceipt) + return respond(request, 'busy', { + error: 'Another queue operation is being recovered.', + }); + } + const target = yield* active.inspect(sessionId, expectedTurnId); + const row = (yield* persist(() => doc.getMessageQueue())).find( + (item) => item.$cid === queueItemId + ); + if (!row) return respond(request, 'queue-item-missing'); + if (hasActiveMessageQueueEditingLease(row)) + return respond(request, 'queue-item-editing'); + const entry = buildQueuedMessageUserTurn(row, meta, { + status: target.native ? 'pending_apply' : 'pending', + }); + if (!entry) return respond(request, 'invalid-queue-item'); + if (!target.native) { + const consumed = yield* persist(() => + doc.consumeMessageQueueItemAsUserTurn(queueItemId, () => entry) + ); + if (consumed.type !== 'consumed') + return respond( + request, + consumed.type === 'editing' + ? 'queue-item-editing' + : consumed.type === 'missing' + ? 'queue-item-missing' + : 'invalid-queue-item' + ); + yield* flush(); + const result = yield* active.cancel(sessionId, expectedTurnId).pipe( + Effect.as(respond(request, 'accepted', { userTurnId: entry.id })), + Effect.catchTag('ProviderRejected', (error) => + Effect.succeed( + respond(request, 'error', { userTurnId: entry.id, error: error.message }) + ) + ) + ); + return remember(operationKey, result); + } + const inputConfig = normalizeSessionTurnInputConfig(entry.inputConfig); + if (!inputConfig || !entry.timestamp?.trim()) + return respond(request, 'invalid-queue-item'); + const marker = yield* write(sessionId, { + version: 2, + operationKey, + queueItemId, + expectedTurnId, + userTurnId: entry.id, + queueRevision: queueItemRevision(row), + phase: 'reserved', + }); + const consumed = yield* persist(() => + doc.consumeMessageQueueItemAsUserTurn( + queueItemId, + (item) => + queueItemRevision(item) === marker.queueRevision + ? { ...entry, userId: target.requesterUserId } + : null, + { publishDispatch: false } + ) + ); + if (consumed.type !== 'consumed') { + return yield* complete( + sessionId, + marker, + respond( + request, + consumed.type === 'editing' + ? 'queue-item-editing' + : consumed.type === 'missing' + ? 'queue-item-missing' + : 'invalid-queue-item' + ) + ); + } + yield* flush(); + yield* removeReservedRow(doc, marker); + const preparation = yield* Effect.either( + active.prepareSteer({ + sessionId, + expectedTurnId, + turn: { + id: entry.id, + userId: target.requesterUserId, + timestamp: entry.timestamp, + inputConfig, + }, + }) + ); + if (preparation._tag === 'Left') { + const error = preparation.left; + return yield* fallback( + sessionId, + doc, + marker, + respond( + request, + error._tag === 'SteerPreparationFailure' ? 'error' : error.disposition, + { userTurnId: entry.id, error: error.message } + ) + ); + } + const submitting = yield* write(sessionId, { ...marker, phase: 'submitting' }); + return yield* active.submitSteer(preparation.right).pipe( + Effect.flatMap(() => + flush().pipe( + Effect.flatMap(() => write(sessionId, { ...marker, phase: 'applied' })), + Effect.flatMap((applied) => + complete( + sessionId, + applied, + respond(request, 'accepted', { userTurnId: entry.id }) + ) + ) + ) + ), + Effect.catchTag('ProviderRejected', (error) => + fallback( + sessionId, + doc, + submitting, + respond(request, error.disposition, { + userTurnId: entry.id, + error: error.message, + }) + ) + ), + // Pre-submission ownership loss never cancels a newer active turn. + Effect.catchTag('StaleTurn', (error) => + fallback( + sessionId, + doc, + submitting, + respond(request, error.disposition, { + userTurnId: entry.id, + error: error.message, + }) + ) + ) + ); + }).pipe( + Effect.catchTags({ + StaleTurn: (error) => + Effect.succeed(respond(request, error.disposition, { error: error.message })), + ProviderRejected: (error) => + Effect.succeed(respond(request, error.disposition, { error: error.message })), + ProviderDeliveryUnknown: (error) => + Effect.succeed(respond(request, 'error', { error: error.message })), + PersistenceFailure: (error) => + Effect.succeed(respond(request, 'error', { error: error.message })), + }) + ) + ); + + const mutate = (request: SessionQueueMutation) => + Effect.scoped( + Effect.gen(function* () { + yield* active.guard(request.sessionId); + const doc = yield* persist(() => + deps.workspaceDocument.getOrCreateSessionDoc(request.sessionId) + ); + const meta = yield* persist(() => doc.getMetaState()); + if (meta?.machineId !== deps.machineId) { + return { + type: 'session/queue-mutate_response' as const, + success: false, + error: 'This daemon does not own the queue.', + }; + } + const marker = yield* persist(() => + deps.queueSteerOperationStore.read(request.sessionId) + ); + if ( + marker && + !marker.completedAt && + (request.mutation.kind === 'reorder' || + request.mutation.queueItemId === marker.queueItemId) + ) { + return { + type: 'session/queue-mutate_response' as const, + success: false, + error: 'A queue item is reserved for Steer. Retry after recovery.', + }; + } + yield* persist(() => doc.mutateMessageQueue(request.mutation)); + yield* flush(); + return { type: 'session/queue-mutate_response' as const, success: true }; + }).pipe( + Effect.catchAll((error) => + Effect.succeed({ + type: 'session/queue-mutate_response' as const, + success: false, + error: error.message, + }) + ) + ) + ); + + return { steerQueueItem, recover, mutate }; + }); + } +} diff --git a/apps/cli/src/session/queued-message-turn.ts b/apps/cli/src/session/queued-message-turn.ts new file mode 100644 index 000000000..9e68ae7cf --- /dev/null +++ b/apps/cli/src/session/queued-message-turn.ts @@ -0,0 +1,65 @@ +import { + buildPendingUserHistoryEntry, + buildSessionTurnInputConfig, + normalizeMcpServerIdSelection, + type AcpConfigOptionValue, + type MessageQueueItem, + type SessionHistoryInput, + type SessionMeta, +} from '@lody/shared'; +import { + extractPromptPreviewFromInputBlocks, + normalizeSessionInputBlocks, +} from './session-execution-helpers'; +import { resolveResumableAcpSessionId } from './session-dispatch-logic'; + +const isConfigOptionValueRecord = ( + value: unknown +): value is Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return Object.values(value as Record).every( + (item) => typeof item === 'string' || typeof item === 'boolean' + ); +}; + +/** Materialize one durable queue item as the user turn that will execute it. */ +export function buildQueuedMessageUserTurn( + queuedItem: MessageQueueItem, + meta: SessionMeta, + options: { status?: 'pending' | 'pending_apply' } = {} +): SessionHistoryInput | null { + const inputBlocks = normalizeSessionInputBlocks( + queuedItem.acpSessionConfig?.inputBlocks, + queuedItem.acpSessionConfig?.prompt ?? queuedItem.task + ); + const inputConfig = buildSessionTurnInputConfig({ + inputBlocks, + prompt: queuedItem.acpSessionConfig?.prompt ?? extractPromptPreviewFromInputBlocks(inputBlocks), + cliType: queuedItem.acpSessionConfig?.cliType ?? meta.cliType, + agentType: queuedItem.acpSessionConfig?.agentType ?? meta.agentType, + modeId: queuedItem.acpSessionConfig?.modeId, + modelId: queuedItem.acpSessionConfig?.modelId, + configOptionValues: isConfigOptionValueRecord(queuedItem.acpSessionConfig?.configOptionValues) + ? queuedItem.acpSessionConfig.configOptionValues + : undefined, + mcpServerIds: normalizeMcpServerIdSelection(queuedItem.acpSessionConfig?.mcpServerIds) ?? [], + taskToolsEnabled: queuedItem.acpSessionConfig?.taskToolsEnabled === true, + agentRoleId: queuedItem.acpSessionConfig?.agentRoleId, + agentRoleRevision: queuedItem.acpSessionConfig?.agentRoleRevision, + issuePRMentions: queuedItem.acpSessionConfig?.issuePRMentions, + resume: resolveResumableAcpSessionId(meta), + }); + const pendingEntry = buildPendingUserHistoryEntry({ + userId: queuedItem.userId ?? meta.userId, + inputBlocks, + timestamp: queuedItem.timestamp, + inputConfig, + ...(options.status ? { status: options.status } : {}), + }); + if (!pendingEntry) return null; + + return { + ...pendingEntry, + id: queuedItem.userTurnId?.trim() || `queued-${queuedItem.$cid}`, + }; +} diff --git a/apps/cli/src/session/session-dispatch-watcher.ts b/apps/cli/src/session/session-dispatch-watcher.ts index 507326123..4a7ef999e 100644 --- a/apps/cli/src/session/session-dispatch-watcher.ts +++ b/apps/cli/src/session/session-dispatch-watcher.ts @@ -4,12 +4,10 @@ import { Effect, Fiber } from 'effect'; import { buildMissingEmail, buildPendingUserHistoryEntry, - buildSessionTurnInputConfig, getSessionRoomId, type ChatFailedReason, isLoroRepoDocDeleted, isSessionDocRoomId, - type AcpConfigOptionValue, type MessageQueueItem, type MachineId, SESSION_DOC_PREFIX, @@ -23,7 +21,6 @@ import { SessionStatusFactory, type SessionTurnInputConfig, type WorkspaceId, - normalizeMcpServerIdSelection, getPendingUserTurnActivationId, hasPendingUserTurnActivation, } from '@lody/shared'; @@ -33,17 +30,13 @@ import { startTraceSpan, traceAsync } from '@/utils/trace-span'; import type { LoroDocumentManager } from '@/lib/loro/doc'; import { subscribeSessionChanges } from '@/lib/loro/doc'; import { SessionExecutionService, type SessionDispatchSource } from './session-execution-service'; -import { - extractPromptPreviewFromInputBlocks, - normalizeSessionInputBlocks, -} from './session-execution-helpers'; +import { normalizeSessionInputBlocks } from './session-execution-helpers'; import type { SessionUserResolver, SessionUserProfile } from './session-user-resolver'; import { findNextDispatchableUserTurn, isActivationAwaitingHistory, resolveDispatchTurnInput, resolveDispatchAcpSessionId, - resolveResumableAcpSessionId, resolveSessionCancelAction, resolveSessionDispatchAction, shouldWatchSession, @@ -58,6 +51,7 @@ import { resolveSessionLaunchConfig } from './session-launch-config-resolver'; import type { SessionAccessPolicyService } from './session-access-policy'; import { mapWithConcurrency } from '@/lib/bounded-concurrency'; import { listAliveRoomIds } from '@/lib/loro/repo-existence'; +import { buildQueuedMessageUserTurn } from './queued-message-turn'; const SESSION_RECONCILE_CONCURRENCY = 4; @@ -201,17 +195,6 @@ type SessionReconcilePhase = type SessionDocumentHandle = Awaited>; -const isConfigOptionValueRecord = ( - value: unknown -): value is Record => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - return Object.values(value as Record).every( - (item) => typeof item === 'string' || typeof item === 'boolean' - ); -}; - /** * ## Session Dispatch Watcher — Behavioral Design * @@ -2155,48 +2138,12 @@ export class SessionDispatchWatcher { return null; } - const inputBlocks = normalizeSessionInputBlocks( - queuedItem.acpSessionConfig?.inputBlocks, - queuedItem.acpSessionConfig?.prompt ?? queuedItem.task - ); - const inputConfig = buildSessionTurnInputConfig({ - inputBlocks, - prompt: - queuedItem.acpSessionConfig?.prompt ?? extractPromptPreviewFromInputBlocks(inputBlocks), - cliType: queuedItem.acpSessionConfig?.cliType ?? meta.cliType, - agentType: queuedItem.acpSessionConfig?.agentType ?? meta.agentType, - modeId: queuedItem.acpSessionConfig?.modeId, - modelId: queuedItem.acpSessionConfig?.modelId, - configOptionValues: isConfigOptionValueRecord( - queuedItem.acpSessionConfig?.configOptionValues - ) - ? queuedItem.acpSessionConfig.configOptionValues - : undefined, - mcpServerIds: - normalizeMcpServerIdSelection(queuedItem.acpSessionConfig?.mcpServerIds) ?? [], - taskToolsEnabled: queuedItem.acpSessionConfig?.taskToolsEnabled === true, - agentRoleId: queuedItem.acpSessionConfig?.agentRoleId, - agentRoleRevision: queuedItem.acpSessionConfig?.agentRoleRevision, - issuePRMentions: queuedItem.acpSessionConfig?.issuePRMentions, - resume: resolveResumableAcpSessionId(meta), - }); - const pendingEntry = buildPendingUserHistoryEntry({ - userId: queuedItem.userId ?? meta.userId, - inputBlocks, - timestamp: queuedItem.timestamp, - inputConfig, - }); - - if (!pendingEntry) { + const entry = buildQueuedMessageUserTurn(queuedItem, meta); + if (!entry) { this.deps.logger.debug(`[${meta.id}] Retaining invalid queued message ${queuedItem.$cid}`); return null; } - const entry: SessionHistoryInput = { - ...pendingEntry, - id: queuedTurnId, - }; - // Promotion is a dispatch producer; `appendUserTurn` publishes the pointer. await sessionDoc.appendUserTurn(entry); await sessionDoc.removeMessageQueueItem(queuedItem.$cid); diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 88227b6d3..ef1ea0051 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -46,6 +46,7 @@ import { SessionChatRequestValidated, SessionCancelRequestValidated, type SessionSteerResponse, + type SessionQueueSteerResponse, type WorkspaceId, hasRecentResumeNotice, buildReplayPromptFromHistory, @@ -63,7 +64,19 @@ import { import type { ContentBlock } from '@agentclientprotocol/sdk'; import { randomUUID } from 'node:crypto'; import type { ModelInfo } from '@lody/shared'; -import { Cause, Data, Effect, Exit, Fiber, type Scope } from 'effect'; +import { Cause, Context, Data, Effect, Exit, Fiber, Layer, type Scope } from 'effect'; +import { + ActiveTurnSteerPort, + SteerPreparationFailure, + PreparedSteer, + ProviderRejected, + ProviderDeliveryUnknown, + StaleTurn, + type NativeSteerInput, + type NativeSteerFailure, +} from './active-turn-steer-port'; +import { QueueSteerService } from './queue-steer-service'; +import type { SessionQueueMutation } from '@lody/shared'; import { captureGitWorkingTreeDiffBaseline, getCurrentCommitHash, @@ -113,6 +126,10 @@ import { resolveResumableAcpSessionId, } from './session-dispatch-logic'; import { resolveSessionLaunchConfig } from './session-launch-config-resolver'; +import { + isQueueSteerMarkerOwnedBy, + type QueueSteerOperationStore, +} from './session-queue-steer-operation-store'; import type { MachineAccessVerification } from './session-access-retry'; import { GIT_EXECUTABLE_NOT_FOUND_CODE, @@ -491,6 +508,7 @@ export type SessionExecutionServiceDeps = { machineId: MachineId; userId: string; workspaceId: WorkspaceId; + queueSteerOperationStore: QueueSteerOperationStore; preferredBaseBranch: string; touchSession: (sessionId: SessionId) => void; startSessionActivePresence: ( @@ -738,6 +756,11 @@ export class SessionExecutionService { // application never race the boundary. No global concurrency cap (Infinity): // this is pure per-session serialization, matching the old hand-rolled lock. private readonly steerMutationQueue = new ConcurrentQueue(Number.POSITIVE_INFINITY); + private readonly queueSteer: Context.Tag.Service; + private readonly preparedSteers = new WeakMap< + PreparedSteer, + Effect.Effect<{ userTurnId: string }, NativeSteerFailure> + >(); // Analytics-only state (spec §5b). Tracks per-turn timing + the last status // we reported so status_changed can carry from→to + dwell time. Never read by // product logic; kept here so capture stays side-effect-only. @@ -759,6 +782,84 @@ export class SessionExecutionService { constructor(private readonly deps: SessionExecutionServiceDeps) { this.acpAuthenticationManager = new AcpAuthenticationManager(deps.logger); + const self = this; + const active = ActiveTurnSteerPort.of({ + guard: (sessionId) => + Effect.acquireRelease( + Effect.sync(() => self.tryAcquireSessionRewriteConflictLease(sessionId)).pipe( + Effect.flatMap((release) => + release + ? Effect.succeed(release) + : Effect.fail( + new StaleTurn({ + disposition: 'busy', + message: 'The session history is being replaced.', + }) + ) + ) + ), + (release) => Effect.sync(release) + ).pipe(Effect.asVoid), + inspect: (sessionId, expectedTurnId) => + Effect.gen(function* () { + const runtime = self.turnRuntimeBySession.get(sessionId); + if (!runtime?.session || !runtime.promptInFlight || runtime.cancelRequested) { + return yield* new StaleTurn({ + disposition: 'no-active-turn', + message: 'No active prompt owns this turn.', + }); + } + if (runtime.turnId !== expectedTurnId) { + return yield* new StaleTurn({ + disposition: 'stale-turn', + message: 'The active turn changed.', + }); + } + const native = Boolean( + runtime.session.acpSessionId && + runtime.session.agentClient?.getAcknowledgedSteerCapability() + ); + const requesterUserId = runtime.invocation?.requesterUserId?.trim() ?? ''; + if (native && !requesterUserId) { + return yield* new ProviderRejected({ + disposition: 'error', + message: 'Active invocation identity is unavailable for native queue Steer.', + }); + } + return { native, requesterUserId }; + }), + prepareSteer: (input) => self.prepareNativeSteerEffect(input), + submitSteer: (prepared) => self.submitNativeSteerEffect(prepared), + cancel: (sessionId, expectedTurnId) => + Effect.tryPromise({ + try: async () => { + const response = await self.cancelSession({ + type: 'session/cancel', + sessionId, + turnId: expectedTurnId, + machineId: deps.machineId, + workspaceId: deps.workspaceId, + }); + if (!response.success) + throw new Error(response.error ?? 'The active turn could not be stopped.'); + }, + catch: (cause) => + new ProviderRejected({ disposition: 'error', message: formatErrorMessage(cause) }), + }), + ownsPrompt: (sessionId, expectedTurnId) => { + const runtime = self.turnRuntimeBySession.get(sessionId); + return runtime?.turnId === expectedTurnId && runtime.promptInFlight; + }, + activeUserTurnId: (sessionId) => self.getActiveUserTurnId(sessionId), + }); + const queueLayer = QueueSteerService.layer({ + ...deps, + requeue: (sessionId, userTurnId) => + self.requeueUndeliveredSteer(sessionId, userTurnId, { canWriteHistory: true }), + failTurn: (sessionId, doc, userTurnId) => + self.setTerminalUserTurnStatus(sessionId, doc, userTurnId, 'failed'), + }).pipe(Layer.provide(Layer.succeed(ActiveTurnSteerPort, active))); + this.queueSteer = Effect.runSync(QueueSteerService.pipe(Effect.provide(queueLayer))); } private createPromptHandoffRun(options: { @@ -1369,7 +1470,7 @@ export class SessionExecutionService { // Nothing was submitted, so this guide is still ours to run. Only the // dispatch pointer is written: the history flip needs the lease we just // failed to take, and dispatch honors the pointer on its own. - await this.requeueUndeliveredSteer(options.sessionId, options.userTurnId, { + const recovery = await this.requeueUndeliveredSteer(options.sessionId, options.userTurnId, { canWriteHistory: false, }); return { @@ -1377,8 +1478,11 @@ export class SessionExecutionService { sessionId: options.sessionId, userTurnId: options.userTurnId, applied: false, - disposition: 'busy', - error: 'The session history is being replaced.', + disposition: recovery === 'requeue-failed' ? 'error' : 'busy', + error: + recovery === 'requeue-failed' + ? 'The session history is being replaced and Steer recovery failed.' + : 'The session history is being replaced.', }; } try { @@ -1389,6 +1493,22 @@ export class SessionExecutionService { }); } + async steerQueuedMessage(options: { + sessionId: SessionId; + expectedTurnId: string; + queueItemId: string; + }): Promise { + return this.steerMutationQueue.enqueue(options.sessionId, () => + Effect.runPromise(this.queueSteer.steerQueueItem(options)) + ); + } + + async mutateQueuedMessage(request: SessionQueueMutation) { + return this.steerMutationQueue.enqueue(request.sessionId, () => + Effect.runPromise(this.queueSteer.mutate(request)) + ); + } + private async steerSessionLocked(options: { sessionId: SessionId; expectedTurnId: string; @@ -1397,234 +1517,392 @@ export class SessionExecutionService { timestamp: string; inputConfig: SessionTurnInputConfig; }): Promise { - const reject = ( - disposition: Exclude, - error?: string - ): SessionSteerResponse => ({ - type: 'session/steer_response', - sessionId: options.sessionId, - userTurnId: options.userTurnId, - applied: false, - disposition, - ...(error ? { error } : {}), - }); - /** - * The agent never took this prompt, so the user turn is still ours to run. - * Only for rejections that provably happened before (or instead of) provider - * submission — after submission the provider may already have committed the - * steer, and re-sending would duplicate it. - */ - const rejectUndelivered = async ( - disposition: Exclude, - error?: string - ): Promise => { - await this.requeueUndeliveredSteer(options.sessionId, options.userTurnId, { - canWriteHistory: true, - }); - return reject(disposition, error); - }; - const runtime = this.turnRuntimeBySession.get(options.sessionId); - if (!runtime || !runtime.session) { - return await rejectUndelivered('no-active-turn'); - } - if (runtime.turnId !== options.expectedTurnId) { - return await rejectUndelivered('stale-turn'); - } - if (!runtime.promptInFlight || runtime.cancelRequested) { - return await rejectUndelivered('no-active-turn'); - } - if (runtime.userTurnId === options.userTurnId) { - return { - type: 'session/steer_response', + const self = this; + return Effect.runPromise( + this.nativeSteerEffect({ sessionId: options.sessionId, - userTurnId: options.userTurnId, - applied: true, - disposition: 'applied', - }; - } - const { agentClient, acpSessionId } = runtime.session; - const steerCapability = agentClient?.getAcknowledgedSteerCapability(); - if (!agentClient || !acpSessionId || !steerCapability) { - return await rejectUndelivered('unsupported'); - } - if (steerCapability.configPolicy === 'active') { - const mismatch = agentClient.findSteerConfigMismatch(options.inputConfig); - if (mismatch) { - return await rejectUndelivered( - 'unsupported', - `Active turn configuration differs: ${mismatch}` - ); - } - } - const rejectBeforeProviderSubmission = async (): Promise => { - if ( - this.turnRuntimeBySession.get(options.sessionId) !== runtime || - runtime.turnId !== options.expectedTurnId - ) { - return await rejectUndelivered('stale-turn'); - } - // No provider request has been submitted yet, so this guide is still - // ours to run as an ordinary follow-up turn. - if (!runtime.promptInFlight || runtime.cancelRequested) { - return await rejectUndelivered('no-active-turn'); - } - return null; - }; + expectedTurnId: options.expectedTurnId, + turn: { + id: options.userTurnId, + userId: options.userId, + timestamp: options.timestamp, + inputConfig: options.inputConfig, + }, + }).pipe( + Effect.map( + (): SessionSteerResponse => ({ + type: 'session/steer_response', + sessionId: options.sessionId, + userTurnId: options.userTurnId, + applied: true, + disposition: 'applied', + }) + ), + Effect.catchAll((error) => + Effect.gen(function* () { + if ( + error._tag === 'ProviderRejected' || + error._tag === 'StaleTurn' || + error._tag === 'SteerPreparationFailure' + ) { + yield* Effect.promise(() => + self.requeueUndeliveredSteer(options.sessionId, options.userTurnId, { + canWriteHistory: true, + }) + ); + } + return { + type: 'session/steer_response' as const, + sessionId: options.sessionId, + userTurnId: options.userTurnId, + applied: false, + disposition: + error._tag === 'ProviderRejected' || error._tag === 'StaleTurn' + ? error.disposition + : ('error' as const), + error: error.message, + }; + }) + ) + ) + ); + } - // Everything up to `steerPrompt` returning is provably undelivered; after - // that only the agent's own inject-or-refuse verdict can say so. - let submittedToAgent = false; - try { - const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(options.sessionId); - const inputBlocks = normalizeSessionInputBlocks( - options.inputConfig.inputBlocks, - options.inputConfig.prompt ?? '' - ); - const promptBlocks = await this.deps.buildAcpPromptBlocks({ - workspaceId: this.deps.workspaceId, - sessionId: options.sessionId, - inputBlocks, - issuePRMentions: options.inputConfig.issuePRMentions, - }); - const preConfigRejection = await rejectBeforeProviderSubmission(); - if (preConfigRejection) { - return preConfigRejection; - } - if (steerCapability.configPolicy === 'apply') { - await this.deps.applyAcpModeAndModel(runtime.session, options.inputConfig, { - sessionDoc, - basedOnUserTurnId: options.userTurnId, - }); - } + private registerPreparedSteer( + submission: Effect.Effect<{ userTurnId: string }, NativeSteerFailure> + ): PreparedSteer { + const prepared = new PreparedSteer(); + this.preparedSteers.set(prepared, submission); + return prepared; + } - const preSubmitRejection = await rejectBeforeProviderSubmission(); - if (preSubmitRejection) { - return preSubmitRejection; - } - const ownedPromptRun = runtime.activePromptRun; - if (runtime.cancelRequested || !ownedPromptRun || ownedPromptRun.turnId !== runtime.turnId) { - return await rejectUndelivered( - 'busy', - 'Prompt owner is cancelling or transitioning between logical turns' + private submitNativeSteerEffect( + prepared: PreparedSteer + ): Effect.Effect<{ userTurnId: string }, NativeSteerFailure> { + return Effect.suspend(() => { + const submission = this.preparedSteers.get(prepared); + if (!submission) + return Effect.fail( + new ProviderDeliveryUnknown({ + message: 'Prepared steer is invalid or already submitted.', + }) ); - } - - const previousTurnId = runtime.turnId; - const previousUserTurnId = runtime.userTurnId; - const steerRun = agentClient.steerPrompt(acpSessionId, promptBlocks); - submittedToAgent = true; - const application = await steerRun.applied; - try { - if ( - runtime.cancelRequested || - this.turnRuntimeBySession.get(options.sessionId) !== runtime || - !runtime.promptInFlight || - runtime.turnId !== previousTurnId || - runtime.activePromptRun !== ownedPromptRun - ) { - // Provider acceptance forbids replay; Stop keeps the source cancellation owner. - if (runtime.cancelRequested) { - await this.setTerminalUserTurnStatus( - options.sessionId, - sessionDoc, - options.userTurnId, - 'canceled' - ); - } - return reject( - 'stale-turn', - 'Steer application arrived after cancellation or ownership changed' - ); - } + this.preparedSteers.delete(prepared); + return submission; + }); + } - // The provider has accepted this steer and may execute tools before - // history/finalization catches up. Switch causal identity first. - runtime.invocation = { - sourceTurnId: options.userTurnId, - requesterUserId: options.userId, - inputConfig: options.inputConfig, - }; - // Provider acceptance hands the original dispatch forward. A later - // user-owned steer turn must not cancel or reopen that responsibility. - await this.settleVisibleTurn(runtime, 'handled', { force: true }); + private nativeSteerEffect(input: NativeSteerInput) { + return this.prepareNativeSteerEffect(input).pipe( + Effect.flatMap((prepared) => this.submitNativeSteerEffect(prepared)) + ); + } - try { - await this.finalizeYieldedTurnOutput(runtime, options.sessionId, previousTurnId); - } catch (error) { - this.deps.logger.error( - `[${options.sessionId}] Failed to seal applied steer source ${previousTurnId}: ${formatErrorMessage(error)}` - ); + private prepareNativeSteerEffect( + input: NativeSteerInput + ): Effect.Effect { + const self = this; + const options = { + ...input, + userTurnId: input.turn.id, + userId: input.turn.userId, + inputConfig: input.turn.inputConfig, + timestamp: input.turn.timestamp, + }; + const prepare = (run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (cause) => + new SteerPreparationFailure({ cause, message: formatErrorMessage(cause) }), + }); + return Effect.scoped( + Effect.gen(function* () { + const runtime = self.turnRuntimeBySession.get(options.sessionId); + if (!runtime?.session || !runtime.promptInFlight || runtime.cancelRequested) { + return yield* new StaleTurn({ + disposition: 'no-active-turn', + message: 'No active prompt owns this turn.', + }); } - try { - await this.transitionDispatchOwnership({ - sessionId: options.sessionId, - sessionDoc, - previousUserTurnId, - nextUserTurnId: options.userTurnId, + if (runtime.turnId !== options.expectedTurnId) { + return yield* new StaleTurn({ + disposition: 'stale-turn', + message: 'The active turn changed.', }); - } catch (error) { - this.deps.logger.error( - `[${options.sessionId}] Failed to persist applied steer ownership for ${options.userTurnId}: ${formatErrorMessage(error)}` - ); } - const nextTurnId = this.deps.beginConversationTurn(options.sessionId, options.userTurnId, { - dispatchSource: 'rpc', - sessionDoc, - }); - try { - await this.deps.createAssistantEntryForTurn( - options.sessionId, - sessionDoc, - nextTurnId, - agentClient.currentModel, - options.userTurnId - ); - } catch (error) { - this.deps.logger.error( - `[${options.sessionId}] Failed to create assistant entry for applied steer ${nextTurnId}: ${formatErrorMessage(error)}` + if (runtime.userTurnId === options.userTurnId) + return self.registerPreparedSteer(Effect.succeed({ userTurnId: options.userTurnId })); + const session = runtime.session; + const { agentClient, acpSessionId } = session; + const capability = agentClient?.getAcknowledgedSteerCapability(); + if (!agentClient || !acpSessionId || !capability) { + return yield* new ProviderRejected({ + disposition: 'unsupported', + message: 'Native Steer is unavailable.', + }); + } + if (capability.configPolicy === 'active') { + const mismatch = agentClient.findSteerConfigMismatch(options.inputConfig); + if (mismatch) + return yield* new ProviderRejected({ + disposition: 'unsupported', + message: `Active turn configuration differs: ${mismatch}`, + }); + } + const validateOwner = () => + Effect.gen(function* () { + if ( + self.turnRuntimeBySession.get(options.sessionId) !== runtime || + runtime.turnId !== options.expectedTurnId + ) { + return yield* new StaleTurn({ + disposition: 'stale-turn', + message: 'The active turn changed.', + }); + } + if (!runtime.promptInFlight || runtime.cancelRequested) { + return yield* new StaleTurn({ + disposition: 'no-active-turn', + message: 'The active turn is stopping.', + }); + } + return undefined; + }); + const sessionDoc = yield* prepare(() => + self.deps.workspaceDocument.getOrCreateSessionDoc(options.sessionId) + ); + const inputBlocks = normalizeSessionInputBlocks( + options.inputConfig.inputBlocks, + options.inputConfig.prompt ?? '' + ); + const promptBlocks = yield* prepare(() => + self.deps.buildAcpPromptBlocks({ + workspaceId: self.deps.workspaceId, + sessionId: options.sessionId, + inputBlocks, + issuePRMentions: options.inputConfig.issuePRMentions, + }) + ); + yield* validateOwner(); + if (capability.configPolicy === 'apply') { + yield* prepare(() => + self.deps.applyAcpModeAndModel(session, options.inputConfig, { + sessionDoc, + basedOnUserTurnId: options.userTurnId, + }) ); } - this.deps.activateConversationTurnForACPUpdates(options.sessionId, nextTurnId); - const nextPromptRun = this.createPromptHandoffRun({ - turnId: nextTurnId, - promptPromise: steerRun.completion, - }); - void ownedPromptRun.promptOutcome.then((outcome) => { - if (outcome.status === 'rejected') { - this.deps.logger.debug( - `[${options.sessionId}] Yielded prompt ${ownedPromptRun.turnId} failed after ownership moved forward: ${formatErrorMessage(outcome.error)}` - ); - } - }); - ownedPromptRun.successor = nextPromptRun; - runtime.activePromptRun = nextPromptRun; - runtime.turnId = nextTurnId; - runtime.userTurnId = options.userTurnId; - this.markCurrentTurn(options.sessionId, nextTurnId); - ownedPromptRun.signalSuccessor(); - return { - type: 'session/steer_response', - sessionId: options.sessionId, - userTurnId: options.userTurnId, - applied: true, - disposition: 'applied', - }; - } finally { - application.release(); - } - } catch (error) { - const notDelivered = !submittedToAgent || error instanceof AgentSteerNotDeliveredError; - if (!notDelivered) { - return reject('error', formatErrorMessage(error)); - } - // `no-active-turn` for the agent's own refusal: it is the disposition - // steer-aware clients already treat as "re-send this turn normally", so - // an older client recovers the message too. - return await rejectUndelivered( - error instanceof AgentSteerNotDeliveredError ? 'no-active-turn' : 'error', - formatErrorMessage(error) - ); - } + yield* validateOwner(); + const ownedPromptRun = runtime.activePromptRun; + if (!ownedPromptRun || ownedPromptRun.turnId !== runtime.turnId) { + return yield* new StaleTurn({ + disposition: 'busy', + message: 'Prompt ownership is transitioning.', + }); + } + return self.registerPreparedSteer( + Effect.scoped( + Effect.gen(function* () { + yield* validateOwner(); + if (runtime.activePromptRun !== ownedPromptRun) { + return yield* new StaleTurn({ + disposition: 'busy', + message: 'Prompt ownership is transitioning.', + }); + } + const previousTurnId = runtime.turnId; + const previousUserTurnId = runtime.userTurnId; + // Do not allow interruption between submission and registration of the local ACK cleanup. + const steerRun = yield* Effect.uninterruptible( + Effect.gen(function* () { + const run = yield* Effect.try({ + try: () => agentClient.steerPrompt(acpSessionId, promptBlocks), + catch: (cause) => + cause instanceof AgentSteerNotDeliveredError + ? new ProviderRejected({ + disposition: 'no-active-turn', + message: formatErrorMessage(cause), + }) + : new ProviderDeliveryUnknown({ + cause, + message: formatErrorMessage(cause), + }), + }); + // This handle releases only the adapter's local ACK gate; submission is not a resource. + yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => run.applied, + catch: (cause) => + cause instanceof AgentSteerNotDeliveredError + ? new ProviderRejected({ + disposition: 'no-active-turn', + message: formatErrorMessage(cause), + }) + : new ProviderDeliveryUnknown({ + cause, + message: formatErrorMessage(cause), + }), + }), + (application) => Effect.sync(() => application.release()) + ); + return run; + }) + ); + if ( + runtime.cancelRequested || + self.turnRuntimeBySession.get(options.sessionId) !== runtime || + !runtime.promptInFlight || + runtime.turnId !== previousTurnId || + runtime.activePromptRun !== ownedPromptRun + ) { + if (runtime.cancelRequested) { + yield* Effect.tryPromise({ + try: () => + self.setTerminalUserTurnStatus( + options.sessionId, + sessionDoc, + options.userTurnId, + 'canceled' + ), + catch: (cause) => + new ProviderDeliveryUnknown({ cause, message: formatErrorMessage(cause) }), + }); + } + return yield* new ProviderDeliveryUnknown({ + message: 'Steer application arrived after cancellation or ownership changed.', + }); + } + yield* Effect.tryPromise({ + try: async () => { + // The provider has accepted this steer and may execute tools before + // history/finalization catches up. Switch causal identity first. + runtime.invocation = { + sourceTurnId: options.userTurnId, + requesterUserId: options.userId, + inputConfig: options.inputConfig, + }; + // Provider acceptance hands the original dispatch forward. A later + // user-owned steer turn must not cancel or reopen that responsibility. + await self.settleVisibleTurn(runtime, 'handled', { force: true }); + + try { + await self.finalizeYieldedTurnOutput( + runtime, + options.sessionId, + previousTurnId + ); + } catch (error) { + self.deps.logger.error( + `[${options.sessionId}] Failed to seal applied steer source ${previousTurnId}: ${formatErrorMessage(error)}` + ); + } + try { + await self.transitionDispatchOwnership({ + sessionId: options.sessionId, + sessionDoc, + previousUserTurnId, + nextUserTurnId: options.userTurnId, + }); + } catch (error) { + self.deps.logger.error( + `[${options.sessionId}] Failed to persist applied steer ownership for ${options.userTurnId}: ${formatErrorMessage(error)}` + ); + throw error; + } + const nextTurnId = self.deps.beginConversationTurn( + options.sessionId, + options.userTurnId, + { + dispatchSource: 'rpc', + sessionDoc, + } + ); + try { + await self.deps.createAssistantEntryForTurn( + options.sessionId, + sessionDoc, + nextTurnId, + agentClient.currentModel, + options.userTurnId + ); + } catch (error) { + self.deps.logger.error( + `[${options.sessionId}] Failed to create assistant entry for applied steer ${nextTurnId}: ${formatErrorMessage(error)}` + ); + } + self.deps.activateConversationTurnForACPUpdates(options.sessionId, nextTurnId); + const nextPromptRun = self.createPromptHandoffRun({ + turnId: nextTurnId, + promptPromise: steerRun.completion, + }); + void ownedPromptRun.promptOutcome.then((outcome) => { + if (outcome.status === 'rejected') { + self.deps.logger.debug( + `[${options.sessionId}] Yielded prompt ${ownedPromptRun.turnId} failed after ownership moved forward: ${formatErrorMessage(outcome.error)}` + ); + } + }); + ownedPromptRun.successor = nextPromptRun; + runtime.activePromptRun = nextPromptRun; + runtime.turnId = nextTurnId; + runtime.userTurnId = options.userTurnId; + self.markCurrentTurn(options.sessionId, nextTurnId); + ownedPromptRun.signalSuccessor(); + }, + catch: (cause) => + new ProviderDeliveryUnknown({ cause, message: formatErrorMessage(cause) }), + }); + return { userTurnId: options.userTurnId }; + }) + ) + ); + }) + ); + } + + async recoverPendingQueueSteer(sessionId: SessionId, sessionDoc: SessionDocument) { + return this.steerMutationQueue.enqueue(sessionId, () => + Effect.runPromise(this.queueSteer.recover(sessionId, sessionDoc)) + ); + } + + async recoverPendingQueueSteers(): Promise { + const self = this; + await Effect.runPromise( + Effect.tryPromise(() => this.deps.queueSteerOperationStore.list()).pipe( + Effect.flatMap((markers) => + Effect.forEach( + markers.filter( + (marker) => + !marker.completedAt && + isQueueSteerMarkerOwnedBy(marker, self.deps.workspaceId, self.deps.machineId) + ), + (marker) => + Effect.tryPromise(async () => { + const sessionId = marker.sessionId as SessionId; + const doc = await self.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); + await self.recoverPendingQueueSteer(sessionId, doc); + }).pipe( + Effect.catchAll((error) => + Effect.sync(() => + self.deps.logger.error( + `[${marker.sessionId}] Queue Steer recovery failed: ${formatErrorMessage(error)}` + ) + ) + ) + ), + { discard: true } + ) + ), + Effect.catchAll((error) => + Effect.sync(() => + self.deps.logger.error( + `[queue-steer] Failed to list recovery markers: ${formatErrorMessage(error)}` + ) + ) + ) + ) + ); } /** @@ -1644,7 +1922,7 @@ export class SessionExecutionService { sessionId: SessionId, userTurnId: string, { canWriteHistory }: { canWriteHistory: boolean } - ): Promise { + ): Promise<'requeued' | 'not-requeueable' | 'requeue-failed'> { try { // Guards against a late duplicate steer request resurrecting a turn that // already ran: it is running now, it finished here, or it finished before @@ -1653,16 +1931,16 @@ export class SessionExecutionService { this.getActiveUserTurnId(sessionId) === userTurnId || this.getTerminalUserTurnStatusWithoutEntry(sessionId, userTurnId) !== undefined ) { - return; + return 'not-requeueable'; } const meta = await this.getSessionMeta(sessionId); if (meta?.lastHandledUserMsgId === userTurnId) { - return; + return 'not-requeueable'; } if (canWriteHistory) { const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); if (!(await this.markSteerTurnPending(sessionDoc, userTurnId))) { - return; + return 'not-requeueable'; } } await this.upsertSessionMeta(sessionId, { @@ -1672,12 +1950,12 @@ export class SessionExecutionService { this.deps.logger.info( `[${sessionId}] Undelivered steer ${userTurnId} requeued as a follow-up turn` ); + return 'requeued'; } catch (error) { this.deps.logger.error( - `[${sessionId}] Failed to requeue undelivered steer ${userTurnId}: ${formatErrorMessage( - error - )}` + `[${sessionId}] Failed to requeue undelivered steer ${userTurnId}: ${formatErrorMessage(error)}` ); + return 'requeue-failed'; } } diff --git a/apps/cli/src/session/session-queue-steer-operation-store.test.ts b/apps/cli/src/session/session-queue-steer-operation-store.test.ts new file mode 100644 index 000000000..50aa01ea8 --- /dev/null +++ b/apps/cli/src/session/session-queue-steer-operation-store.test.ts @@ -0,0 +1,73 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MachineId, SessionId, WorkspaceId } from '@lody/shared'; +import { + createFileQueueSteerOperationStore, + type QueueSteerOperationMarker, +} from './session-queue-steer-operation-store'; + +const marker: QueueSteerOperationMarker = { + version: 1, + workspaceId: 'workspace-1', + machineId: 'machine-1', + sessionId: 'session-1', + operationKey: '["session-1","assistant-1","C"]', + queueItemId: 'C', + expectedTurnId: 'assistant-1', + userTurnId: 'user:C', + phase: 'reserved', + updatedAt: 1, +}; + +describe('file queue Steer operation store', () => { + let tempHome: string; + + beforeEach(() => { + tempHome = mkdtempSync(path.join(os.tmpdir(), 'lody-queue-steer-operation-store-')); + vi.stubEnv('HOME', tempHome); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(tempHome, { recursive: true, force: true }); + }); + + it('atomically records, overwrites, reads, lists, and clears a session marker', async () => { + const store = createFileQueueSteerOperationStore({ + workspaceId: 'workspace-1' as WorkspaceId, + machineId: 'machine-1' as MachineId, + }); + await store.record(marker); + await expect(store.read('session-1' as SessionId)).resolves.toEqual(marker); + + const completed = { + ...marker, + phase: 'applied' as const, + response: { disposition: 'accepted' as const, userTurnId: 'user:C' }, + completedAt: 2, + updatedAt: 2, + }; + await store.record(completed); + await expect(store.list()).resolves.toEqual([completed]); + + const root = path.join(tempHome, '.lody', 'session-queue-steer-operations'); + expect((await stat(root)).mode & 0o777).toBe(0o700); + await store.clear('session-1' as SessionId); + await expect(store.read('session-1' as SessionId)).resolves.toBeNull(); + }); + + it('ignores corrupt or unknown-version marker files', async () => { + const store = createFileQueueSteerOperationStore({ + workspaceId: 'workspace-1' as WorkspaceId, + machineId: 'machine-1' as MachineId, + }); + await store.record(marker); + const root = path.join(tempHome, '.lody', 'session-queue-steer-operations'); + await writeFile(path.join(root, 'corrupt.json'), 'not json', 'utf8'); + await writeFile(path.join(root, 'future.json'), JSON.stringify({ ...marker, version: 999 })); + await expect(store.list()).resolves.toEqual([marker]); + }); +}); diff --git a/apps/cli/src/session/session-queue-steer-operation-store.ts b/apps/cli/src/session/session-queue-steer-operation-store.ts new file mode 100644 index 000000000..d8513efea --- /dev/null +++ b/apps/cli/src/session/session-queue-steer-operation-store.ts @@ -0,0 +1,161 @@ +import { createHash, randomUUID } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { z } from 'zod'; +import type { MachineId, SessionId, WorkspaceId } from '@lody/shared'; + +/** + * Machine-local authority for the native queue-Steer saga. Shared Session data + * cannot prove that the daemon accepted an RPC because collaborators may write + * it. One owner-scoped file per Session survives process death and is replaced + * by the next native operation; a completed marker doubles as the latest + * crash-safe receipt. Atomic rename prevents recovery from reading a partial + * phase transition. + */ +const QueueSteerResponseSchema = z + .object({ + disposition: z.enum([ + 'accepted', + 'queue-item-missing', + 'queue-item-editing', + 'invalid-queue-item', + 'no-active-turn', + 'stale-turn', + 'unsupported', + 'busy', + 'error', + ]), + userTurnId: z.string().min(1).optional(), + error: z.string().min(1).optional(), + }) + .strict(); + +const QueueSteerOperationMarkerSchema = z + .object({ + version: z.union([z.literal(1), z.literal(2)]), + workspaceId: z.string().min(1), + machineId: z.string().min(1), + sessionId: z.string().min(1), + operationKey: z.string().min(1), + queueItemId: z.string().min(1), + expectedTurnId: z.string().min(1), + userTurnId: z.string().min(1), + queueRevision: z.string().min(1).optional(), + phase: z.enum(['reserved', 'submitting', 'acknowledged', 'applied', 'fallback']), + response: QueueSteerResponseSchema.optional(), + completedAt: z.number().finite().optional(), + updatedAt: z.number().finite(), + }) + .strict(); + +export type QueueSteerOperationMarker = z.infer; + +export type QueueSteerOperationStore = { + record(marker: QueueSteerOperationMarker): Promise; + read(sessionId: SessionId): Promise; + clear(sessionId: SessionId): Promise; + list(): Promise; +}; + +function getStoreRoot(): string { + return path.join(os.homedir(), '.lody', 'session-queue-steer-operations'); +} + +function getMarkerPath( + sessionId: SessionId, + owner: { workspaceId: WorkspaceId; machineId: MachineId } +): string { + const key = createHash('sha256') + .update(JSON.stringify([owner.workspaceId, owner.machineId, sessionId])) + .digest('hex'); + return path.join(getStoreRoot(), `${key}.json`); +} + +async function readMarkerFile(markerPath: string): Promise { + try { + const parsed = QueueSteerOperationMarkerSchema.safeParse( + JSON.parse(await readFile(markerPath, 'utf8')) as unknown + ); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +export function createFileQueueSteerOperationStore(owner: { + workspaceId: WorkspaceId; + machineId: MachineId; +}): QueueSteerOperationStore { + return { + async record(marker) { + if (!isQueueSteerMarkerOwnedBy(marker, owner.workspaceId, owner.machineId)) { + throw new Error('Cannot persist a queue Steer marker for another owner'); + } + const markerPath = getMarkerPath(marker.sessionId as SessionId, owner); + await mkdir(path.dirname(markerPath), { recursive: true, mode: 0o700 }); + const temporaryPath = `${markerPath}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(marker)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rename(temporaryPath, markerPath); + }, + + async read(sessionId) { + const marker = await readMarkerFile(getMarkerPath(sessionId, owner)); + return marker && isQueueSteerMarkerOwnedBy(marker, owner.workspaceId, owner.machineId) + ? marker + : null; + }, + + async clear(sessionId) { + await rm(getMarkerPath(sessionId, owner), { force: true }); + }, + + async list() { + let entries: string[]; + try { + entries = await readdir(getStoreRoot()); + } catch { + return []; + } + const markers: QueueSteerOperationMarker[] = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + const marker = await readMarkerFile(path.join(getStoreRoot(), entry)); + if (marker && isQueueSteerMarkerOwnedBy(marker, owner.workspaceId, owner.machineId)) { + markers.push(marker); + } + } + return markers; + }, + }; +} + +export function createMemoryQueueSteerOperationStore(): QueueSteerOperationStore { + const markers = new Map(); + return { + async record(marker) { + markers.set(marker.sessionId, structuredClone(marker)); + }, + async read(sessionId) { + const marker = markers.get(sessionId); + return marker ? structuredClone(marker) : null; + }, + async clear(sessionId) { + markers.delete(sessionId); + }, + async list() { + return [...markers.values()].map((marker) => structuredClone(marker)); + }, + }; +} + +export function isQueueSteerMarkerOwnedBy( + marker: QueueSteerOperationMarker, + workspaceId: WorkspaceId, + machineId: MachineId +): boolean { + return marker.workspaceId === workspaceId && marker.machineId === machineId; +} diff --git a/apps/cli/tests/message-handler-machine-registration.test.ts b/apps/cli/tests/message-handler-machine-registration.test.ts index 2933d59e6..39fb95301 100644 --- a/apps/cli/tests/message-handler-machine-registration.test.ts +++ b/apps/cli/tests/message-handler-machine-registration.test.ts @@ -187,6 +187,7 @@ describe('MessageHandler machine registration', () => { localFileResources: 1, providerSetup: 1, acpProtocolAuthentication: 2, + queueItemSteer: 2, subagentCancellation: 1, }); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index b303cc200..72d2c8d99 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -1,3 +1,15 @@ +import { queueItemRevision, isSessionHistoryPendingForDispatch } from '@lody/shared'; +import { CURRENT_MACHINE_PROTOCOL_CAPABILITIES } from '@lody/shared'; +import { createWorkspaceMachineRpcFacade } from '../../../packages/components/src/providers/workspace-machine-rpc-facade'; +import { isSessionVisibleToUser } from '../../../packages/components/src/lib/session-visibility'; +import { + LoroStreamsMachineRpcClient, + LoroStreamsMachineRpcServer, + type LoroStreamsJsonStreamClient, + type LoroJsonLiveBatchHandler, +} from '@lody/loro-streams-rpc'; +import { SessionDocument } from '../src/lib/loro/doc'; +import { composeTestSessionDoc } from './session-doc-fixture'; import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import fs from 'node:fs'; @@ -26,6 +38,7 @@ import { type ChatFailedReason, type LocalProjectId, type MachineId, + type MessageQueueItem, type SessionGoalMessage, type SessionHistoryInput, type SessionId, @@ -45,6 +58,7 @@ import { GitExecutableNotFoundError } from '../src/session/worktree/git-process- import { LodyOperationStore } from '../src/orchestration/operation-store'; import { markAssistantTurnFinished } from '../src/lib/assistant-turn-finalize'; import { shouldWatchSession } from '../src/session/session-dispatch-logic'; +import { createMemoryQueueSteerOperationStore } from '../src/session/session-queue-steer-operation-store'; const capabilityConfigId = 'config-1' as AgentConfigId; @@ -114,167 +128,1518 @@ const createDeferred = () => { return { promise, resolve, reject }; }; -const createGitLocalProject = (): string => { - const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), 'lody-session-local-project-')); - runGit(rootPath, ['init', '-b', 'main']); - runGit(rootPath, ['config', 'user.email', 'test@example.com']); - runGit(rootPath, ['config', 'user.name', 'Test User']); - fs.writeFileSync(path.join(rootPath, 'README.md'), 'main\n', 'utf8'); - runGit(rootPath, ['add', 'README.md']); - runGit(rootPath, ['commit', '-m', 'initial']); - runGit(rootPath, ['checkout', '-b', 'feature/remote-local']); - fs.writeFileSync(path.join(rootPath, 'feature.txt'), 'feature\n', 'utf8'); - runGit(rootPath, ['add', 'feature.txt']); - runGit(rootPath, ['commit', '-m', 'feature']); - runGit(rootPath, ['checkout', 'main']); - return rootPath; -}; +const createGitLocalProject = (): string => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), 'lody-session-local-project-')); + runGit(rootPath, ['init', '-b', 'main']); + runGit(rootPath, ['config', 'user.email', 'test@example.com']); + runGit(rootPath, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(rootPath, 'README.md'), 'main\n', 'utf8'); + runGit(rootPath, ['add', 'README.md']); + runGit(rootPath, ['commit', '-m', 'initial']); + runGit(rootPath, ['checkout', '-b', 'feature/remote-local']); + fs.writeFileSync(path.join(rootPath, 'feature.txt'), 'feature\n', 'utf8'); + runGit(rootPath, ['add', 'feature.txt']); + runGit(rootPath, ['commit', '-m', 'feature']); + runGit(rootPath, ['checkout', 'main']); + return rootPath; +}; + +const createBaseDeps = ( + overrides: Partial +): SessionExecutionServiceDeps => { + const logger = createSilentLogger(); + const sessionManager = { + getSession: vi.fn(() => null), + getPendingSession: vi.fn(() => null), + createSession: vi.fn(), + setSessionError: vi.fn(), + terminateSession: vi.fn(), + refreshGhTokenForSession: vi.fn(async () => {}), + } as unknown as SessionManager; + const workspaceDocument = { + repo: { + upsertDocMeta: vi.fn(async () => {}), + getDocMeta: vi.fn(async () => undefined), + openFlockDoc: vi.fn(async () => ({ + flock: { scan: () => [] }, + })), + }, + getOrCreateSessionDoc: vi.fn(), + updateAcpCapabilities: vi.fn(async () => {}), + persistPendingChanges: vi.fn(async () => {}), + } as unknown as LoroDocumentManager; + + const deps = { + logger, + sessionManager, + workspaceDocument, + machineId: 'machine-1', + userId: 'owner-user', + workspaceId: 'workspace-1' as WorkspaceId, + queueSteerOperationStore: createMemoryQueueSteerOperationStore(), + preferredBaseBranch: 'main', + touchSession: vi.fn(), + startSessionActivePresence: vi.fn(async () => {}), + clearSessionActivePresence: vi.fn(), + setSessionActivePresencePhase: vi.fn(), + beginACPReplaySuppression: vi.fn(), + endACPReplaySuppression: vi.fn(), + beginConversationTurn: vi.fn(() => 'turn-1'), + activateConversationTurnForACPUpdates: vi.fn(), + clearConversationTurn: vi.fn(), + getActiveTurnId: vi.fn(() => undefined), + clearActiveTurnId: vi.fn(() => {}), + buildAcpPromptBlocks: vi.fn(async () => [{ type: 'text', text: 'hello' }] as any), + applyAcpModeAndModel: vi.fn(async () => {}), + createAssistantEntryForTurn: vi.fn(async () => {}), + turnFinalization: { + finalizeACPState: vi.fn(async () => {}), + flushSessionUsage: vi.fn(async () => {}), + syncSessionBranchName: vi.fn(async () => null), + updateSessionDiffStats: vi.fn(async () => []), + detectAndAssociatePR: vi.fn(async () => null), + syncWorkspaceGitState: vi.fn(async () => {}), + notifySessionCompleted: vi.fn(async () => {}), + }, + recordChatFailure: vi.fn(async () => {}), + maybeGenerateAndStoreSessionTitle: vi.fn(async () => {}), + processMessageQueue: vi.fn(async () => {}), + collectMachineResources: vi.fn(async () => ({ + totalMemoryGB: 1, + usedMemoryGB: 0.5, + freeMemoryGB: 0.5, + totalCpus: 8, + cpuUsagePercent: 10, + })), + fetchAcpCapabilities: vi.fn(async () => ({ + modes: [], + models: [], + })), + evictForMemoryPressure: vi.fn(async () => ({ + availableMemoryBytes: 4 * 1024 * 1024 * 1024, + thresholdBytes: 1024 * 1024 * 1024, + hadMemoryPressure: false, + stillUnderPressure: false, + evictedSessionIds: [], + pressureReason: null, + })), + ...overrides, + }; + + const repo = (deps.workspaceDocument as unknown as { repo?: Record }).repo; + if (repo && !('openFlockDoc' in repo)) { + repo.openFlockDoc = vi.fn(async () => ({ + flock: { scan: () => [] }, + })); + } + + const workspaceWithLaunchConfig = deps.workspaceDocument as unknown as { + getAgentConfigForMachineLaunch?: ( + agentConfigId: AgentConfigId, + machineId: MachineId + ) => Promise; + }; + workspaceWithLaunchConfig.getAgentConfigForMachineLaunch ??= vi.fn( + async (agentConfigId: AgentConfigId, machineId: MachineId) => + createLaunchConfig({ id: agentConfigId, machineId }) + ); + + const workspaceWithDocFactory = deps.workspaceDocument as unknown as { + getOrCreateSessionDoc: (...args: unknown[]) => Promise; + persistPendingChanges?: (reason: string) => Promise; + }; + workspaceWithDocFactory.persistPendingChanges ??= vi.fn(async () => {}); + const originalGetOrCreateSessionDoc = workspaceWithDocFactory.getOrCreateSessionDoc; + workspaceWithDocFactory.getOrCreateSessionDoc = vi.fn(async (...args: unknown[]) => + ensureSessionDocDefaults(await originalGetOrCreateSessionDoc(...args)) + ); + + return deps; +}; + +describe('SessionExecutionService', () => { + const ownedQueue = async () => { + const sessionId = 'session-owned-queue' as SessionId; + let meta = { + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + } as SessionMeta; + const repo = { + getDocMeta: async () => ({ meta }), + upsertDocMeta: async (_room: string, patch: Partial) => { + meta = { ...meta, ...patch }; + }, + }; + const doc = new SessionDocument(repo as never, sessionId, async () => {}, createSilentLogger()); + composeTestSessionDoc(doc); + vi.spyOn(doc, 'waitUntilSynced').mockResolvedValue(undefined); + for (const task of ['A', 'B', 'C']) + await doc.pushMessageQueue({ + task, + userId: 'queue-author', + userTurnId: 'user:' + task, + timestamp: '2026-09-14T00:00:00.000Z', + acpSessionConfig: { prompt: task, cliType: 'builtin', agentType: 'codex' }, + }); + const rows = await doc.getMessageQueue(); + const flush = vi.fn(async (_reason: string) => {}); + const evidence: string[] = []; + const steerPrompt = vi.fn(() => { + evidence.push('submitted'); + return { + applied: Promise.reject<{ release: () => void }>(new Error('delivery unknown')), + completion: Promise.resolve(), + }; + }); + const deps = createBaseDeps({ + workspaceDocument: { + repo, + getOrCreateSessionDoc: async () => doc, + persistPendingChanges: flush, + } as never, + buildAcpPromptBlocks: async ({ inputBlocks }) => inputBlocks as ContentBlock[], + }); + const service = new SessionExecutionService(deps); + const runtime = { + sessionId, + turnId: 'assistant:active', + userTurnId: 'user:active', + promptInFlight: true, + cancelRequested: false, + activePromptRun: { + turnId: 'assistant:active', + promptOutcome: Promise.resolve({ status: 'fulfilled' as const }), + signalSuccessor: () => {}, + }, + invocation: { requesterUserId: 'authenticated-user', inputConfig: { prompt: 'active' } }, + session: { + acpSessionId: 'acp-owned', + agentClient: { + getAcknowledgedSteerCapability: () => ({ provider: 'codex', configPolicy: 'active' }), + findSteerConfigMismatch: () => null, + steerPrompt, + }, + }, + }; + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.set(sessionId, runtime); + return { + service, + doc, + rows, + deps, + flush, + steerPrompt, + evidence, + runtime, + request: { sessionId, expectedTurnId: runtime.turnId, queueItemId: rows[2]!.$cid }, + }; + }; + + it.each(['prompt-build', 'config-apply'] as const)( + 'dispatches C ordinarily when native %s preparation fails', + async (stage) => { + const h = await ownedQueue(); + const failPreparation = async () => { + expect(await h.deps.queueSteerOperationStore.read(h.request.sessionId)).toMatchObject({ + phase: 'reserved', + }); + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B']); + expect( + (await h.doc.sessionData.history.readAll()).find((row) => row.id === 'user:C') + ).toMatchObject({ status: 'pending_apply' }); + throw new Error(stage + ' failed'); + }; + if (stage === 'prompt-build') h.deps.buildAcpPromptBlocks = failPreparation; + else { + h.runtime.session.agentClient.getAcknowledgedSteerCapability = () => ({ + provider: 'codex', + configPolicy: 'apply', + }); + h.deps.applyAcpModeAndModel = failPreparation; + } + expect(await h.service.steerQueuedMessage(h.request)).toMatchObject({ + accepted: false, + error: stage + ' failed', + }); + expect(h.steerPrompt).not.toHaveBeenCalled(); + expect( + isSessionHistoryPendingForDispatch( + (await h.doc.sessionData.history.readAll()).find((row) => row.id === 'user:C') + ) + ).toBe(true); + expect(await h.doc.getMetaState()).toMatchObject({ latestUserMsgId: 'user:C' }); + expect(await h.deps.queueSteerOperationStore.read(h.request.sessionId)).toMatchObject({ + phase: 'fallback', + completedAt: expect.any(Number), + }); + expect(h.runtime.userTurnId).toBe('user:active'); + } + ); + + it('revalidates Stop after preparation while the submitting marker is persisted', async () => { + const h = await ownedQueue(); + const record = h.deps.queueSteerOperationStore.record; + h.deps.queueSteerOperationStore.record = async (marker) => { + await record(marker); + if (marker.phase === 'submitting') h.runtime.cancelRequested = true; + }; + expect(await h.service.steerQueuedMessage(h.request)).toMatchObject({ + accepted: false, + disposition: 'no-active-turn', + }); + expect(h.steerPrompt).not.toHaveBeenCalled(); + expect(await h.doc.getMetaState()).toMatchObject({ latestUserMsgId: 'user:C' }); + expect(await h.deps.queueSteerOperationStore.read(h.request.sessionId)).toMatchObject({ + phase: 'fallback', + }); + expect(h.runtime.userTurnId).toBe('user:active'); + }); + + it.each(['synchronous', 'acknowledgement'] as const)( + 'never falls back after a %s provider connection failure', + async (stage) => { + const h = await ownedQueue(); + h.steerPrompt.mockImplementation(() => { + h.evidence.push('submitted'); + if (stage === 'synchronous') throw new Error('connection failed'); + return { + applied: Promise.reject(new Error('connection failed')), + completion: Promise.resolve(), + }; + }); + expect(await h.service.steerQueuedMessage(h.request)).toMatchObject({ + accepted: false, + error: 'connection failed', + }); + expect(h.evidence).toEqual(['submitted']); + const marker = await h.deps.queueSteerOperationStore.read(h.request.sessionId); + expect(marker).toMatchObject({ phase: 'submitting' }); + expect(marker?.completedAt).toBeUndefined(); + expect( + (await h.doc.sessionData.history.readAll()).find((row) => row.id === 'user:C') + ).toMatchObject({ status: 'pending_apply' }); + expect((await h.doc.getMetaState())?.latestUserMsgId).not.toBe('user:C'); + expect(h.runtime.userTurnId).toBe('user:active'); + await h.service.steerQueuedMessage(h.request); + expect(h.evidence).toEqual(['submitted']); + } + ); + + it.each(['startup', 'same-request', 'clear-failure'] as const)( + 'retries C/T after pre-history recovery via %s', + async (mode) => { + const h = await ownedQueue(); + await h.deps.queueSteerOperationStore.record({ + version: 2, + workspaceId: 'workspace-1', + machineId: 'machine-1', + ...h.request, + operationKey: JSON.stringify([ + h.request.sessionId, + h.request.expectedTurnId, + h.request.queueItemId, + ]), + userTurnId: 'user:C', + phase: 'reserved', + updatedAt: 1, + }); + const restarted = new SessionExecutionService(h.deps); + if (mode === 'startup') { + await restarted.recoverPendingQueueSteers(); + expect(await h.deps.queueSteerOperationStore.read(h.request.sessionId)).toBeNull(); + expect(await h.doc.getMessageQueue()).toEqual(h.rows); + expect(await h.doc.sessionData.history.readAll()).toEqual([]); + } + ( + restarted as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.set(h.request.sessionId, h.runtime); + h.steerPrompt.mockImplementation(() => ({ + applied: Promise.resolve({ release: () => {} }), + completion: Promise.resolve(), + })); + if (mode === 'clear-failure') { + vi.spyOn(h.deps.queueSteerOperationStore, 'clear').mockRejectedValueOnce( + new Error('marker clear failed') + ); + expect(await restarted.steerQueuedMessage(h.request)).toMatchObject({ + accepted: false, + error: 'marker clear failed', + }); + expect(await h.deps.queueSteerOperationStore.read(h.request.sessionId)).toMatchObject({ + phase: 'reserved', + }); + expect(await h.doc.getMessageQueue()).toEqual(h.rows); + expect(await h.doc.sessionData.history.readAll()).toEqual([]); + expect(h.runtime.userTurnId).toBe('user:active'); + } + expect(await restarted.steerQueuedMessage(h.request)).toMatchObject({ accepted: true }); + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B']); + expect(h.runtime.userTurnId).toBe('user:C'); + expect(await h.deps.queueSteerOperationStore.read(h.request.sessionId)).toMatchObject({ + phase: 'applied', + response: { disposition: 'accepted' }, + }); + } + ); + + it.each([ + 'private-project', + 'local-sender-missing', + 'revoked-owner-machine', + 'revoked-owner-project', + 'owner-private-project', + ] as const)('traces %s rejection before Streams and daemon state changes', async (scenario) => { + const h = await ownedQueue(); + const machineId = 'machine-1' as MachineId; + const meta = await h.doc.getMetaState(); + const sessionMeta = { + ...meta, + userId: scenario.includes('owner') ? 'user-U' : meta?.userId, + project: + scenario === 'revoked-owner-machine' + ? undefined + : { kind: 'local', localProjectId: 'private-P' }, + } as SessionMeta; + vi.spyOn(h.doc, 'getMetaState').mockResolvedValue(sessionMeta); + const appended: unknown[] = []; + const readers = new Map(); + const streamClient: LoroStreamsJsonStreamClient = { + ensureJsonStream: async () => {}, + appendJson: async (streamId, value) => { + appended.push(value); + const reader = readers.get(streamId); + if (!reader) throw new Error('Missing test stream reader'); + await reader({ messages: [value], nextOffset: String(appended.length), upToDate: true }); + return String(appended.length); + }, + readJsonLive: async (streamId, _state, onBatch, options) => { + readers.set(streamId, onBatch); + await new Promise((resolve) => { + if (options?.signal?.aborted) resolve(); + else options?.signal?.addEventListener('abort', () => resolve(), { once: true }); + }); + readers.delete(streamId); + }, + }; + const server = new LoroStreamsMachineRpcServer({ + workspaceId: h.deps.workspaceId, + machineId, + logger: createSilentLogger(), + streamClient, + getMachineStatus: vi.fn(), + refreshMachineAcpCapabilities: vi.fn(), + steerQueuedMessage: (args) => h.service.steerQueuedMessage(args), + mutateQueuedMessage: (args) => h.service.mutateQueuedMessage(args), + }); + const client = new LoroStreamsMachineRpcClient({ + workspaceId: h.deps.workspaceId, + machineId, + streamClient, + }); + let projectVisible = scenario.startsWith('revoked'); + let machineVisible = !scenario.startsWith('revoked'); + let plane: 'local' | 'cloud' = scenario === 'local-sender-missing' ? 'local' : 'cloud'; + const getMachineRpcClient = vi.fn(async () => client); + const facade = createWorkspaceMachineRpcFacade({ + workspaceId: h.deps.workspaceId, + targetRouter: { + getPlaneForMachine: () => plane, + resolvePlaneForMachine: async () => plane, + }, + getMachineProtocolCapabilities: async () => CURRENT_MACHINE_PROTOCOL_CAPABILITIES, + getSessionMeta: async () => h.doc.getMetaState(), + getSessionControlAuthorization: () => ({ + visibleMachineIds: new Set(machineVisible ? [machineId] : []), + visibleLocalProjectKeys: new Set(projectVisible ? [machineId + ':private-P'] : []), + }), + getMachineRpcClient, + }); + await server.start(); + try { + const error = + scenario === 'local-sender-missing' + ? 'Local queue control is unavailable.' + : 'Source authorization for this session is unavailable or denied.'; + if (scenario.includes('owner')) { + expect(isSessionVisibleToUser(sessionMeta, new Set(), new Set(), 'user-U')).toBe(true); + } + expect(await facade.requestSessionQueueSteer(machineId, h.request)).toMatchObject({ + accepted: false, + error, + }); + expect( + await facade.requestSessionQueueMutation(machineId, { + sessionId: h.request.sessionId, + mutation: { + kind: 'remove', + queueItemId: h.request.queueItemId, + expectedRevision: queueItemRevision(h.rows[2]), + }, + }) + ).toMatchObject({ success: false, error }); + expect(getMachineRpcClient).not.toHaveBeenCalled(); + expect(appended).toEqual([]); + expect(await h.deps.queueSteerOperationStore.read(h.request.sessionId)).toBeNull(); + expect(await h.doc.getMessageQueue()).toEqual(h.rows); + expect(await h.doc.sessionData.history.readAll()).toEqual([]); + expect(h.runtime.userTurnId).toBe('user:active'); + expect(h.runtime.promptInFlight).toBe(true); + // Positive control traverses the same real RPC client/server and execution service. + projectVisible = true; + machineVisible = true; + plane = 'cloud'; + h.steerPrompt.mockImplementation(() => ({ + applied: Promise.resolve({ release: () => {} }), + completion: Promise.resolve(), + })); + expect(await facade.requestSessionQueueSteer(machineId, h.request)).toMatchObject({ + accepted: true, + }); + expect(appended.length).toBeGreaterThan(0); + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B']); + expect(h.runtime.userTurnId).toBe('user:C'); + expect(await h.deps.queueSteerOperationStore.read(h.request.sessionId)).toMatchObject({ + phase: 'applied', + }); + const secondRow = h.rows[1]; + if (!secondRow) throw new Error('Missing B fixture'); + expect( + await facade.requestSessionQueueMutation(machineId, { + sessionId: h.request.sessionId, + mutation: { + kind: 'remove', + queueItemId: secondRow.$cid, + expectedRevision: queueItemRevision(secondRow), + }, + }) + ).toMatchObject({ success: true }); + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A']); + } finally { + client.stop(); + server.stop(); + } + }); + + it.each(['update', 'remove', 'reorder'] as const)( + 'rejects a stale second-client %s after reservation, with removal durable before submission', + async (kind) => { + const h = await ownedQueue(); + const entered = createDeferred(); + const release = createDeferred(); + let commits = 0; + h.flush.mockImplementation(async () => { + commits++; + if (commits === 1) { + expect((await h.doc.sessionData.history.readAll()).at(-1)).toMatchObject({ + id: 'user:C', + status: 'pending_apply', + userId: 'authenticated-user', + }); + entered.resolve(); + await release.promise; + } + if (commits === 2) { + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B']); + h.evidence.push('removal-durable'); + } + }); + const steering = h.service.steerQueuedMessage(h.request); + await entered.promise; + expect(h.service.tryAcquireSessionRewriteConflictLease(h.request.sessionId)).toBeNull(); + const mutation = + kind === 'reorder' + ? { + kind, + orderedItemIds: h.rows.map((row) => row.$cid).reverse(), + expectedItemIds: h.rows.map((row) => row.$cid), + } + : { + kind, + queueItemId: h.request.queueItemId, + expectedRevision: queueItemRevision(h.rows[2]), + ...(kind === 'update' ? { patch: { task: 'Roll back instead' } } : {}), + }; + const editing = h.service.mutateQueuedMessage({ + sessionId: h.request.sessionId, + mutation, + } as never); + release.resolve(); + await expect(steering).resolves.toMatchObject({ accepted: false, disposition: 'error' }); + await expect(editing).resolves.toMatchObject({ success: false }); + expect(h.evidence).toEqual(['removal-durable', 'submitted']); + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B']); + // A retry cannot turn unknown external delivery into ordinary dispatch. + await h.service.steerQueuedMessage(h.request); + expect(h.evidence).toEqual(['removal-durable', 'submitted']); + const releaseOwnership = h.service.tryAcquireSessionRewriteConflictLease(h.request.sessionId); + expect(releaseOwnership).not.toBeNull(); + releaseOwnership?.(); + } + ); + + it('commits a native handoff and returns its durable receipt after restart', async () => { + const h = await ownedQueue(); + let released = false; + h.steerPrompt.mockImplementation(() => ({ + applied: Promise.resolve({ + release: () => { + released = true; + }, + }), + completion: Promise.resolve(), + })); + await expect(h.service.steerQueuedMessage(h.request)).resolves.toMatchObject({ + accepted: true, + userTurnId: 'user:C', + }); + expect(released).toBe(true); + expect(h.runtime.userTurnId).toBe('user:C'); + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B']); + await expect(h.deps.queueSteerOperationStore.read(h.request.sessionId)).resolves.toMatchObject({ + phase: 'applied', + response: { disposition: 'accepted', userTurnId: 'user:C' }, + }); + const restarted = new SessionExecutionService(h.deps); + await expect(restarted.steerQueuedMessage(h.request)).resolves.toMatchObject({ + accepted: true, + userTurnId: 'user:C', + }); + }); + + it('preserves an accepted edit on a surviving legacy reservation instead of deleting it', async () => { + const h = await ownedQueue(); + await h.doc.consumeMessageQueueItemAsUserTurn( + h.request.queueItemId, + () => ({ + id: 'user:C', + userId: 'authenticated-user', + timestamp: '2026-09-14T00:00:00.000Z', + role: 'user', + status: 'pending_apply', + read: false, + items: [{ type: 'text', text: 'C' }], + inputConfig: { prompt: 'C', cliType: 'builtin', agentType: 'codex' }, + }), + { publishDispatch: false } + ); + await h.deps.queueSteerOperationStore.record({ + version: 1, + workspaceId: 'workspace-1', + machineId: 'machine-1', + sessionId: h.request.sessionId, + operationKey: 'legacy', + queueItemId: h.request.queueItemId, + userTurnId: 'user:C', + expectedTurnId: h.request.expectedTurnId, + phase: 'reserved', + updatedAt: 1, + }); + await h.doc.mutateMessageQueue({ + kind: 'update', + queueItemId: h.request.queueItemId, + expectedRevision: queueItemRevision(h.rows[2]), + patch: { + task: 'Rollback', + acpSessionConfig: { prompt: 'Rollback', cliType: 'builtin', agentType: 'codex' }, + }, + }); + const restarted = new SessionExecutionService(h.deps); + await expect(restarted.recoverPendingQueueSteer(h.request.sessionId, h.doc)).rejects.toThrow( + 'preserved' + ); + expect((await h.doc.getMessageQueue())[2]?.task).toBe('Rollback'); + expect(h.evidence).toEqual([]); + }); + + it('freezes an edit committed before reservation, not the stale renderer text', async () => { + const h = await ownedQueue(); + await expect( + h.service.mutateQueuedMessage({ + sessionId: h.request.sessionId, + mutation: { + kind: 'update', + queueItemId: h.request.queueItemId, + expectedRevision: queueItemRevision(h.rows[2]), + patch: { + task: 'Roll back instead', + acpSessionConfig: { + prompt: 'Roll back instead', + cliType: 'builtin', + agentType: 'codex', + }, + }, + }, + }) + ).resolves.toMatchObject({ success: true }); + await h.service.steerQueuedMessage(h.request); + const entry = (await h.doc.sessionData.history.readAll()).find( + (candidate) => candidate.id === 'user:C' + ); + expect(entry).toMatchObject({ inputConfig: { prompt: 'Roll back instead' } }); + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B']); + }); + + it.each(['remove', 'reorder'] as const)( + 'honors a %s committed before reservation', + async (kind) => { + const h = await ownedQueue(); + const mutation = + kind === 'remove' + ? { + kind, + queueItemId: h.request.queueItemId, + expectedRevision: queueItemRevision(h.rows[2]), + } + : { + kind, + orderedItemIds: h.rows.map((row) => row.$cid).reverse(), + expectedItemIds: h.rows.map((row) => row.$cid), + }; + await expect( + h.service.mutateQueuedMessage({ sessionId: h.request.sessionId, mutation }) + ).resolves.toMatchObject({ success: true }); + const result = await h.service.steerQueuedMessage(h.request); + if (kind === 'remove') { + expect(result.disposition).toBe('queue-item-missing'); + expect(h.evidence).toEqual([]); + } + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual( + kind === 'remove' ? ['A', 'B'] : ['B', 'A'] + ); + } + ); + + it('rejects queue writes sent to a daemon other than the session owner', async () => { + const h = await ownedQueue(); + const meta = await h.doc.getMetaState(); + vi.spyOn(h.doc, 'getMetaState').mockResolvedValue({ + ...meta, + machineId: 'another-machine', + } as SessionMeta); + await expect( + h.service.mutateQueuedMessage({ + sessionId: h.request.sessionId, + mutation: { + kind: 'remove', + queueItemId: h.request.queueItemId, + expectedRevision: queueItemRevision(h.rows[2]), + }, + }) + ).resolves.toMatchObject({ success: false, error: 'This daemon does not own the queue.' }); + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B', 'C']); + }); + + it.each(['reservation', 'history', 'removal', 'submission-marker'] as const)( + 'does not submit after a %s persistence failure and recovers without a shared row', + async (stage) => { + const h = await ownedQueue(); + let count = 0; + const record = h.deps.queueSteerOperationStore.record.bind(h.deps.queueSteerOperationStore); + vi.spyOn(h.deps.queueSteerOperationStore, 'record').mockImplementation(async (marker) => { + if ( + (stage === 'reservation' && marker.phase === 'reserved') || + (stage === 'submission-marker' && marker.phase === 'submitting') + ) + throw new Error('disk unavailable'); + await record(marker); + }); + h.flush.mockImplementation(async () => { + count++; + if ((stage === 'history' && count === 1) || (stage === 'removal' && count === 2)) + throw new Error('disk unavailable'); + }); + await expect(h.service.steerQueuedMessage(h.request)).resolves.toMatchObject({ + accepted: false, + error: 'disk unavailable', + }); + expect(h.evidence).toEqual([]); + const release = h.service.tryAcquireSessionRewriteConflictLease(h.request.sessionId); + expect(release).not.toBeNull(); + release?.(); + if (stage !== 'reservation') { + const restarted = new SessionExecutionService(h.deps); + await restarted.recoverPendingQueueSteer(h.request.sessionId, h.doc); + expect( + (await h.doc.sessionData.history.readAll()).find((entry) => entry.id === 'user:C')?.status + ).toMatch(/^(pending|seen)$/); + expect((await h.doc.getMessageQueue()).map((row) => row.task)).toEqual(['A', 'B']); + expect(h.evidence).toEqual([]); + } + } + ); + + it('cancels only the named native child and rejects a stale parent turn', async () => { + const runningChildren = new Set(['child-1', 'child-2']); + const sessionManager = { + getSession: () => ({ + agentClient: { + isCreated: () => true, + cancelSubagent: async (id: string) => { + runningChildren.delete(id); + }, + }, + }), + } as unknown as SessionManager; + const deps = createBaseDeps({ sessionManager, getActiveTurnId: () => 'parent-1' }); + // A child control must never enter the parent Stop/history mutation path. + deps.workspaceDocument.getOrCreateSessionDoc = async () => { + throw new Error('Parent Stop was invoked'); + }; + const service = new SessionExecutionService(deps); + const request = { + type: 'session/cancel' as const, + sessionId: 'session-1' as SessionId, + machineId: 'machine-1', + workspaceId: 'workspace-1' as WorkspaceId, + turnId: 'parent-1', + subagentTaskId: 'child-1', + }; + expect(await service.cancelSession({ ...request, turnId: 'old-parent' })).toMatchObject({ + success: false, + }); + expect([...runningChildren]).toEqual(['child-1', 'child-2']); + expect(await service.cancelSession(request)).toEqual({ success: true }); + expect([...runningChildren]).toEqual(['child-2']); + expect(deps.getActiveTurnId(request.sessionId)).toBe('parent-1'); + }); + + it('steers an exact later queue item without reordering the remaining queue', async () => { + const sessionId = 'session-queue-steer' as SessionId; + const activeTurnId = 'assistant:active'; + const queue = ['A', 'B', 'C'].map( + (id): MessageQueueItem => ({ + $cid: id, + task: `task ${id}`, + userId: 'owner-user', + userTurnId: `user:${id}`, + timestamp: '2026-09-13T00:00:00.000Z', + acpSessionConfig: { + prompt: `task ${id}`, + cliType: 'builtin', + agentType: 'codex', + }, + }) + ); + const history: SessionHistoryInput[] = []; + const sessionDoc = { + getMessageQueue: async () => queue, + getMetaState: vi.fn(async () => ({ + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + })), + consumeMessageQueueItemAsUserTurn: vi.fn( + async (cid: string, buildEntry: (item: MessageQueueItem) => SessionHistoryInput | null) => { + const index = queue.findIndex((item) => item.$cid === cid); + if (index < 0) return { type: 'missing' as const }; + const entry = buildEntry(queue[index]!); + if (!entry) return { type: 'invalid' as const }; + history.push(entry); + queue.splice(index, 1); + return { type: 'consumed' as const, entry }; + } + ), + }; + const queueSteerOperationStore = createMemoryQueueSteerOperationStore(); + const deps = createBaseDeps({ + queueSteerOperationStore, + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }); + const service = new SessionExecutionService(deps); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: {}, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { + turnRuntimeBySession: Map; + } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi.spyOn(service, 'cancelSession').mockResolvedValue({ success: true }); + + const request = { + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + }; + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: true, + disposition: 'accepted', + queueItemId: 'C', + userTurnId: 'user:C', + }); + + // A response-loss retry returns the receipt instead of consuming or + // cancelling a second time after the active turn has moved on. + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: true, + disposition: 'accepted', + userTurnId: 'user:C', + }); + + expect(queue.map((item) => item.$cid)).toEqual(['A', 'B']); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ id: 'user:C', role: 'user' }); + expect(cancel).toHaveBeenCalledWith(expect.objectContaining({ turnId: activeTurnId })); + expect(sessionDoc.consumeMessageQueueItemAsUserTurn.mock.invocationCallOrder[0]).toBeLessThan( + cancel.mock.invocationCallOrder[0]! + ); + expect(sessionDoc.consumeMessageQueueItemAsUserTurn).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('keeps the active turn running when the selected queue identity is gone', async () => { + const sessionId = 'session-queue-steer-missing' as SessionId; + const activeTurnId = 'assistant:active'; + const sessionDoc = { + getMetaState: vi.fn(async () => ({ + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + })), + getMessageQueue: async () => [], + consumeMessageQueueItemAsUserTurn: vi.fn(async () => ({ type: 'missing' as const })), + }; + const deps = createBaseDeps({ + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }); + const service = new SessionExecutionService(deps); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: {}, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { + turnRuntimeBySession: Map; + } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi.spyOn(service, 'cancelSession').mockResolvedValue({ success: true }); + + await expect( + service.steerQueuedMessage({ + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + }) + ).resolves.toMatchObject({ + accepted: false, + disposition: 'queue-item-missing', + queueItemId: 'C', + }); + expect(cancel).not.toHaveBeenCalled(); + expect( + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.get(sessionId) + ).toBe(runtime); + }); + + it('keeps the active turn running when queue activation publication fails and allows retry', async () => { + const sessionId = 'session-queue-steer-publication-failed' as SessionId; + const activeTurnId = 'assistant:active'; + const entry = { + id: 'user:C', + role: 'user' as const, + userId: 'owner-user', + timestamp: '2026-09-13T00:00:00.000Z', + items: [{ type: 'text' as const, text: 'task C' }], + status: 'pending' as const, + read: false, + inputConfig: { prompt: 'task C' }, + }; + const consumeMessageQueueItemAsUserTurn = vi + .fn() + .mockRejectedValueOnce(new Error('metadata unavailable')) + .mockResolvedValue({ type: 'consumed' as const, entry }); + const sessionDoc = { + getMetaState: vi.fn(async () => ({ id: sessionId })), + getMessageQueue: async () => [ + { + $cid: 'C', + task: 'task C', + userId: 'owner-user', + userTurnId: 'user:C', + timestamp: '2026-09-13T00:00:00.000Z', + acpSessionConfig: { prompt: 'task C' }, + }, + ], + consumeMessageQueueItemAsUserTurn, + }; + const service = new SessionExecutionService( + createBaseDeps({ + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }) + ); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: {}, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi.spyOn(service, 'cancelSession').mockResolvedValue({ success: true }); + const request = { + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + }; + + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: false, + disposition: 'error', + error: 'metadata unavailable', + }); + expect(cancel).not.toHaveBeenCalled(); + expect( + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.get(sessionId) + ).toBe(runtime); + + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: true, + disposition: 'accepted', + userTurnId: 'user:C', + }); + expect(consumeMessageQueueItemAsUserTurn).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('keeps the active turn and queue row while the target editing lease is active', async () => { + const sessionId = 'session-queue-steer-editing' as SessionId; + const activeTurnId = 'assistant:active'; + const sessionDoc = { + getMetaState: vi.fn(async () => ({ + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + })), + getMessageQueue: async () => + [ + { + $cid: 'C', + task: 'task C', + userId: 'owner-user', + userTurnId: 'user:C', + timestamp: '2026-09-13T00:00:00.000Z', + acpSessionConfig: { prompt: 'task C' }, + }, + ].map((item) => ({ ...item, isEditing: true, editingStartedAt: Number.MAX_SAFE_INTEGER })), + consumeMessageQueueItemAsUserTurn: vi.fn(async () => ({ type: 'editing' as const })), + }; + const service = new SessionExecutionService( + createBaseDeps({ + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }) + ); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: {}, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi.spyOn(service, 'cancelSession'); + + await expect( + service.steerQueuedMessage({ + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + }) + ).resolves.toMatchObject({ accepted: false, disposition: 'queue-item-editing' }); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('does not consume or cancel again after consumption succeeded but cancellation failed', async () => { + const sessionId = 'session-queue-steer-cancel-failed' as SessionId; + const activeTurnId = 'assistant:active'; + const entry = { + id: 'user:C', + role: 'user' as const, + userId: 'owner-user', + timestamp: '2026-09-13T00:00:00.000Z', + items: [{ type: 'text' as const, text: 'task C' }], + status: 'pending' as const, + read: false, + inputConfig: { prompt: 'task C' }, + }; + const sessionDoc = { + getMetaState: vi.fn(async () => ({ id: sessionId })), + getMessageQueue: async () => [ + { + $cid: 'C', + task: 'task C', + userId: 'owner-user', + userTurnId: 'user:C', + timestamp: '2026-09-13T00:00:00.000Z', + acpSessionConfig: { prompt: 'task C' }, + }, + ], + consumeMessageQueueItemAsUserTurn: vi.fn(async () => ({ + type: 'consumed' as const, + entry, + })), + }; + const service = new SessionExecutionService( + createBaseDeps({ + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }) + ); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: {}, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi + .spyOn(service, 'cancelSession') + .mockResolvedValue({ success: false, error: 'cancel failed' }); + const request = { + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + }; + + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: false, + disposition: 'error', + userTurnId: 'user:C', + error: 'cancel failed', + }); + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: false, + disposition: 'error', + userTurnId: 'user:C', + }); + expect(sessionDoc.consumeMessageQueueItemAsUserTurn).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('rejects native queued Steer before consume when active requester identity is unavailable', async () => { + const sessionId = 'session-queue-steer-no-identity' as SessionId; + const activeTurnId = 'assistant:active'; + const steerPrompt = vi.fn(); + const consumeMessageQueueItemAsUserTurn = vi.fn(); + const sessionDoc = { + getMetaState: vi.fn(async () => ({ id: sessionId })), + consumeMessageQueueItemAsUserTurn, + }; + const service = new SessionExecutionService( + createBaseDeps({ + workspaceDocument: { + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }) + ); + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'active', + session: { + agentClient: { + getAcknowledgedSteerCapability: vi.fn(() => ({ + provider: 'claudeCode', + appliedNotificationMethod: 'claude/steerApplied', + upstreamTurn: 'handoff', + configPolicy: 'apply', + })), + steerPrompt, + }, + acpSessionId: 'acp-steer' as ACPSessionId, + }, + promptInFlight: true, + cancelRequested: false, + }; + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.set(sessionId, runtime); + const cancel = vi.spyOn(service, 'cancelSession'); + + await expect( + service.steerQueuedMessage({ + sessionId, + expectedTurnId: activeTurnId, + queueItemId: 'C', + }) + ).resolves.toMatchObject({ + accepted: false, + disposition: 'error', + error: expect.stringContaining('identity is unavailable'), + }); + expect(consumeMessageQueueItemAsUserTurn).not.toHaveBeenCalled(); + expect(steerPrompt).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('does not cache a native rejection until fallback publication is durable', async () => { + const sessionId = 'session-native-steer-recovery-retry' as SessionId; + const queuedItem: MessageQueueItem = { + $cid: 'C', + task: 'task C', + userId: 'forged-owner', + userTurnId: 'user:C', + timestamp: '2026-09-14T00:00:00.000Z', + acpSessionConfig: { prompt: 'task C' }, + }; + let queue = [queuedItem]; + let history: SessionHistoryInput[] = []; + let meta = { + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + } as SessionMeta; + let rejectNextActivation = true; + let queueSteerPersistenceCount = 0; + const upsertDocMeta = vi.fn(async (_roomId: string, patch: Partial) => { + if (patch.latestUserMsgId && rejectNextActivation) { + rejectNextActivation = false; + throw new Error('activation unavailable'); + } + meta = { ...meta, ...patch }; + }); + const persistPendingChanges = vi.fn(async (reason: string) => { + if (reason !== 'queue-steer-commit') return; + queueSteerPersistenceCount += 1; + if (queueSteerPersistenceCount === 4) { + throw new Error('queue Steer persistence unavailable'); + } + }); + const sessionDoc = withHistoryPort({ + getMetaState: vi.fn(async () => meta), + getMessageQueue: vi.fn(async () => queue), + waitUntilSynced: vi.fn(async () => {}), + getHistory: () => history, + updateHistory: vi.fn( + async (update: (entries: SessionHistoryInput[]) => SessionHistoryInput[]) => { + history = update(history); + } + ), + consumeMessageQueueItemAsUserTurn: vi.fn( + async (_cid: string, build: (item: MessageQueueItem) => SessionHistoryInput | null) => { + const entry = build(queuedItem); + if (!entry) return { type: 'invalid' as const }; + history.push(entry); + return { type: 'consumed' as const, entry }; + } + ), + removeMessageQueueItem: vi.fn(async (cid: string) => { + queue = queue.filter((item) => item.$cid !== cid); + }), + }); + const queueSteerOperationStore = createMemoryQueueSteerOperationStore(); + const deps = createBaseDeps({ + queueSteerOperationStore, + workspaceDocument: { + repo: { upsertDocMeta, getDocMeta: vi.fn(async () => ({ meta })) }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + persistPendingChanges, + } as unknown as LoroDocumentManager, + }); + const service = new SessionExecutionService(deps); + const activeTurnId = 'assistant:active'; + const runtime = { + sessionId, + turnId: activeTurnId, + userTurnId: 'user:active', + session: { + agentClient: { + getAcknowledgedSteerCapability: vi.fn(() => ({ + provider: 'codex', + appliedNotificationMethod: 'codex/steerApplied', + upstreamTurn: 'same', + configPolicy: 'active', + })), + findSteerConfigMismatch: vi.fn(() => null), + steerPrompt: vi.fn(() => { + throw new AgentSteerNotDeliveredError('provider refused'); + }), + }, + acpSessionId: 'acp-native-retry' as ACPSessionId, + }, + promptInFlight: true, + cancelRequested: false, + invocation: { + sourceTurnId: 'user:active', + requesterUserId: 'authenticated-user', + inputConfig: { prompt: 'active' }, + }, + activePromptRun: { turnId: activeTurnId }, + }; + ( + service as unknown as { turnRuntimeBySession: Map } + ).turnRuntimeBySession.set(sessionId, runtime); + const request = { sessionId, expectedTurnId: activeTurnId, queueItemId: 'C' }; + + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: false, + disposition: 'error', + error: expect.stringContaining('Failed to recover'), + }); + await expect(queueSteerOperationStore.read(sessionId)).resolves.toMatchObject({ + phase: 'fallback', + completedAt: undefined, + }); + expect(history).toEqual([expect.objectContaining({ id: 'user:C', status: 'pending' })]); + expect(queue).toEqual([]); -const createBaseDeps = ( - overrides: Partial -): SessionExecutionServiceDeps => { - const logger = createSilentLogger(); - const sessionManager = { - getSession: vi.fn(() => null), - getPendingSession: vi.fn(() => null), - createSession: vi.fn(), - setSessionError: vi.fn(), - terminateSession: vi.fn(), - refreshGhTokenForSession: vi.fn(async () => {}), - } as unknown as SessionManager; - const workspaceDocument = { - repo: { - upsertDocMeta: vi.fn(async () => {}), - getDocMeta: vi.fn(async () => undefined), - openFlockDoc: vi.fn(async () => ({ - flock: { scan: () => [] }, - })), - }, - getOrCreateSessionDoc: vi.fn(), - updateAcpCapabilities: vi.fn(async () => {}), - persistPendingChanges: vi.fn(async () => {}), - } as unknown as LoroDocumentManager; + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: false, + disposition: 'error', + error: 'queue Steer persistence unavailable', + }); + await expect(queueSteerOperationStore.read(sessionId)).resolves.toMatchObject({ + phase: 'fallback', + completedAt: undefined, + }); - const deps = { - logger, - sessionManager, - workspaceDocument, - machineId: 'machine-1', - userId: 'owner-user', - workspaceId: 'workspace-1' as WorkspaceId, - preferredBaseBranch: 'main', - touchSession: vi.fn(), - startSessionActivePresence: vi.fn(async () => {}), - clearSessionActivePresence: vi.fn(), - setSessionActivePresencePhase: vi.fn(), - beginACPReplaySuppression: vi.fn(), - endACPReplaySuppression: vi.fn(), - beginConversationTurn: vi.fn(() => 'turn-1'), - activateConversationTurnForACPUpdates: vi.fn(), - clearConversationTurn: vi.fn(), - getActiveTurnId: vi.fn(() => undefined), - clearActiveTurnId: vi.fn(() => {}), - buildAcpPromptBlocks: vi.fn(async () => [{ type: 'text', text: 'hello' }] as any), - applyAcpModeAndModel: vi.fn(async () => {}), - createAssistantEntryForTurn: vi.fn(async () => {}), - turnFinalization: { - finalizeACPState: vi.fn(async () => {}), - flushSessionUsage: vi.fn(async () => {}), - syncSessionBranchName: vi.fn(async () => null), - updateSessionDiffStats: vi.fn(async () => []), - detectAndAssociatePR: vi.fn(async () => null), - syncWorkspaceGitState: vi.fn(async () => {}), - notifySessionCompleted: vi.fn(async () => {}), - }, - recordChatFailure: vi.fn(async () => {}), - maybeGenerateAndStoreSessionTitle: vi.fn(async () => {}), - processMessageQueue: vi.fn(async () => {}), - collectMachineResources: vi.fn(async () => ({ - totalMemoryGB: 1, - usedMemoryGB: 0.5, - freeMemoryGB: 0.5, - totalCpus: 8, - cpuUsagePercent: 10, - })), - fetchAcpCapabilities: vi.fn(async () => ({ - modes: [], - models: [], - })), - evictForMemoryPressure: vi.fn(async () => ({ - availableMemoryBytes: 4 * 1024 * 1024 * 1024, - thresholdBytes: 1024 * 1024 * 1024, - hadMemoryPressure: false, - stillUnderPressure: false, - evictedSessionIds: [], - pressureReason: null, - })), - ...overrides, - }; + await expect(service.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: false, + disposition: 'no-active-turn', + error: 'provider refused', + }); + expect(queue).toEqual([]); + expect(meta.latestUserMsgId).toBe('user:C'); + await expect(queueSteerOperationStore.read(sessionId)).resolves.toMatchObject({ + phase: 'fallback', + completedAt: expect.any(Number), + response: { disposition: 'no-active-turn', error: 'provider refused' }, + }); + expect(meta.latestUserMsgId).toBe('user:C'); - const repo = (deps.workspaceDocument as unknown as { repo?: Record }).repo; - if (repo && !('openFlockDoc' in repo)) { - repo.openFlockDoc = vi.fn(async () => ({ - flock: { scan: () => [] }, - })); - } + const restartedService = new SessionExecutionService(deps); + await expect(restartedService.steerQueuedMessage(request)).resolves.toMatchObject({ + accepted: false, + disposition: 'no-active-turn', + error: 'provider refused', + }); + expect(meta.latestUserMsgId).toBe('user:C'); + }); - const workspaceWithLaunchConfig = deps.workspaceDocument as unknown as { - getAgentConfigForMachineLaunch?: ( - agentConfigId: AgentConfigId, - machineId: MachineId - ) => Promise; - }; - workspaceWithLaunchConfig.getAgentConfigForMachineLaunch ??= vi.fn( - async (agentConfigId: AgentConfigId, machineId: MachineId) => - createLaunchConfig({ id: agentConfigId, machineId }) - ); + it.each(['submitting', 'acknowledged', 'applied'] as const)( + 'recovers a crashed native Steer in %s without replaying the provider call', + async (phase) => { + const sessionId = `session-native-steer-crash-${phase}` as SessionId; + const operation = { + version: 2 as const, + queueRevision: queueItemRevision({ $cid: 'C' }), + workspaceId: 'workspace-1', + machineId: 'machine-1', + sessionId, + operationKey: `operation-${phase}`, + queueItemId: 'C', + expectedTurnId: 'assistant:old', + userTurnId: 'user:C', + phase, + updatedAt: 1, + }; + let queue = [{ $cid: 'A' }, { $cid: 'C' }]; + let history: SessionHistoryInput[] = [ + { + id: 'user:C', + role: 'user', + userId: 'authenticated-user', + status: 'pending_apply', + read: false, + items: [{ type: 'text', text: 'task C' }], + } as SessionHistoryInput, + ]; + let meta = { + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + } as SessionMeta; + const upsertDocMeta = vi.fn(async (_roomId: string, patch: Partial) => { + meta = { ...meta, ...patch }; + }); + const sessionDoc = withHistoryPort({ + getMetaState: vi.fn(async () => meta), + getMessageQueue: vi.fn(async () => queue), + waitUntilSynced: vi.fn(async () => {}), + getHistory: () => history, + updateHistory: vi.fn( + async (update: (entries: SessionHistoryInput[]) => SessionHistoryInput[]) => { + history = update(history); + } + ), + removeMessageQueueItem: vi.fn(async (cid: string) => { + queue = queue.filter((item) => item.$cid !== cid); + }), + }); + const queueSteerOperationStore = createMemoryQueueSteerOperationStore(); + await queueSteerOperationStore.record(operation); + const deps = createBaseDeps({ + queueSteerOperationStore, + workspaceDocument: { + repo: { upsertDocMeta, getDocMeta: vi.fn(async () => ({ meta })) }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }); + const service = new SessionExecutionService(deps); - const workspaceWithDocFactory = deps.workspaceDocument as unknown as { - getOrCreateSessionDoc: (...args: unknown[]) => Promise; - }; - const originalGetOrCreateSessionDoc = workspaceWithDocFactory.getOrCreateSessionDoc; - workspaceWithDocFactory.getOrCreateSessionDoc = vi.fn(async (...args: unknown[]) => - ensureSessionDocDefaults(await originalGetOrCreateSessionDoc(...args)) + await service.recoverPendingQueueSteer(sessionId, sessionDoc); + expect(queue).toEqual([{ $cid: 'A' }]); + expect(history).toEqual([expect.objectContaining({ id: 'user:C', status: 'failed' })]); + expect(meta.latestUserMsgId).toBeUndefined(); + await expect(queueSteerOperationStore.read(sessionId)).resolves.toMatchObject({ + phase, + completedAt: expect.any(Number), + response: { + disposition: phase === 'applied' ? 'accepted' : 'error', + userTurnId: 'user:C', + }, + }); + expect(deps.recordChatFailure).toHaveBeenCalledWith( + sessionDoc, + 'agent_disconnected', + expect.stringContaining('not replayed') + ); + } ); - return deps; -}; - -describe('SessionExecutionService', () => { - it('cancels only the named native child and rejects a stale parent turn', async () => { - const runningChildren = new Set(['child-1', 'child-2']); - const sessionManager = { - getSession: () => ({ - agentClient: { - isCreated: () => true, - cancelSubagent: async (id: string) => { - runningChildren.delete(id); - }, - }, + it('recovers a crash after native reservation by dispatching that exact row', async () => { + const sessionId = 'session-native-steer-crash-reserved' as SessionId; + let queue = [{ $cid: 'A' }, { $cid: 'B' }, { $cid: 'C' }]; + let history: SessionHistoryInput[] = [ + { + id: 'user:C', + role: 'user', + userId: 'authenticated-user', + status: 'pending_apply', + read: false, + items: [{ type: 'text', text: 'task C' }], + } as SessionHistoryInput, + ]; + let meta = { + id: sessionId, + userId: 'owner-user', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + } as SessionMeta; + const upsertDocMeta = vi.fn(async (_roomId: string, patch: Partial) => { + meta = { ...meta, ...patch }; + }); + const sessionDoc = withHistoryPort({ + getMetaState: vi.fn(async () => meta), + getMessageQueue: vi.fn(async () => queue), + waitUntilSynced: vi.fn(async () => {}), + getHistory: () => history, + updateHistory: vi.fn( + async (update: (entries: SessionHistoryInput[]) => SessionHistoryInput[]) => { + history = update(history); + } + ), + removeMessageQueueItem: vi.fn(async (cid: string) => { + queue = queue.filter((item) => item.$cid !== cid); }), - } as unknown as SessionManager; - const deps = createBaseDeps({ sessionManager, getActiveTurnId: () => 'parent-1' }); - // A child control must never enter the parent Stop/history mutation path. - deps.workspaceDocument.getOrCreateSessionDoc = async () => { - throw new Error('Parent Stop was invoked'); - }; - const service = new SessionExecutionService(deps); - const request = { - type: 'session/cancel' as const, - sessionId: 'session-1' as SessionId, + }); + const queueSteerOperationStore = createMemoryQueueSteerOperationStore(); + await queueSteerOperationStore.record({ + version: 1, + workspaceId: 'workspace-1', machineId: 'machine-1', - workspaceId: 'workspace-1' as WorkspaceId, - turnId: 'parent-1', - subagentTaskId: 'child-1', - }; - expect(await service.cancelSession({ ...request, turnId: 'old-parent' })).toMatchObject({ - success: false, + sessionId, + queueRevision: queueItemRevision({ $cid: 'C' }), + operationKey: 'operation-reserved', + queueItemId: 'C', + expectedTurnId: 'assistant:old', + userTurnId: 'user:C', + phase: 'reserved', + updatedAt: 1, }); - expect([...runningChildren]).toEqual(['child-1', 'child-2']); - expect(await service.cancelSession(request)).toEqual({ success: true }); - expect([...runningChildren]).toEqual(['child-2']); - expect(deps.getActiveTurnId(request.sessionId)).toBe('parent-1'); + const service = new SessionExecutionService( + createBaseDeps({ + queueSteerOperationStore, + workspaceDocument: { + repo: { upsertDocMeta, getDocMeta: vi.fn(async () => ({ meta })) }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + } as unknown as LoroDocumentManager, + }) + ); + + await service.recoverPendingQueueSteers(); + expect(history).toEqual([expect.objectContaining({ id: 'user:C', status: 'pending' })]); + expect(queue).toEqual([{ $cid: 'A' }, { $cid: 'B' }]); + expect(meta).toMatchObject({ latestUserMsgId: 'user:C' }); + await expect(queueSteerOperationStore.read(sessionId)).resolves.toMatchObject({ + phase: 'fallback', + completedAt: expect.any(Number), + response: { disposition: 'error', userTurnId: 'user:C' }, + }); + }); + + it('ignores queue Steer recovery markers owned by another machine', async () => { + const queueSteerOperationStore = createMemoryQueueSteerOperationStore(); + await queueSteerOperationStore.record({ + version: 1, + workspaceId: 'workspace-1', + machineId: 'machine-2', + sessionId: 'session-foreign', + operationKey: 'foreign-operation', + queueItemId: 'C', + expectedTurnId: 'assistant:old', + userTurnId: 'user:C', + phase: 'reserved', + updatedAt: 1, + }); + const deps = createBaseDeps({ queueSteerOperationStore }); + const service = new SessionExecutionService(deps); + + await service.recoverPendingQueueSteers(); + + expect(deps.workspaceDocument.getOrCreateSessionDoc).not.toHaveBeenCalled(); + await expect( + queueSteerOperationStore.read('session-foreign' as SessionId) + ).resolves.toMatchObject({ machineId: 'machine-2', phase: 'reserved' }); }); + it('advances one session owner through consecutive prompt handoffs', async () => { const steerPrompt = vi.fn(() => ({ completion: new Promise(() => {}), @@ -293,7 +1658,33 @@ describe('SessionExecutionService', () => { steerPrompt, currentModel: undefined, }; + const queuedItem: MessageQueueItem = { + $cid: 'queue-user-2', + task: 'change direction', + userId: 'forged-owner', + userTurnId: 'user-2', + timestamp: '2026-07-11T00:00:00.000Z', + acpSessionConfig: { prompt: 'change direction' }, + }; const sessionDoc = withHistoryPort({ + getMetaState: vi.fn(async () => ({ + id: 'session-steer', + userId: 'user-1', + machineId: 'machine-1', + cliType: 'builtin', + agentType: 'codex', + })), + getMessageQueue: vi.fn(async () => [queuedItem]), + removeMessageQueueItem: vi.fn(async () => {}), + consumeMessageQueueItemAsUserTurn: vi.fn( + async (cid: string, buildEntry: (item: MessageQueueItem) => SessionHistoryInput | null) => { + expect(cid).toBe(queuedItem.$cid); + const entry = buildEntry(queuedItem); + if (!entry) return { type: 'invalid' as const }; + expect(entry.userId).toBe('authenticated-user'); + return { type: 'consumed' as const, entry }; + } + ), updateHistory: vi.fn(async () => {}), }); const upsertDocMeta = vi.fn(async () => {}); @@ -338,7 +1729,7 @@ describe('SessionExecutionService', () => { promptInFlight: true, invocation: { sourceTurnId: 'user-1', - requesterUserId: 'user-1', + requesterUserId: 'authenticated-user', inputConfig: { prompt: 'initial prompt' }, }, activePromptRun: initialPromptRun, @@ -352,15 +1743,12 @@ describe('SessionExecutionService', () => { ).turnRuntimeBySession.set(sessionId, runtime); await expect( - service.steerSession({ + service.steerQueuedMessage({ sessionId, expectedTurnId: 'assistant:user-1', - userTurnId: 'user-2', - userId: 'user-1', - timestamp: '2026-07-11T00:00:00.000Z', - inputConfig: { prompt: 'change direction' }, + queueItemId: queuedItem.$cid, }) - ).resolves.toMatchObject({ applied: true, disposition: 'applied' }); + ).resolves.toMatchObject({ accepted: true, disposition: 'accepted', userTurnId: 'user-2' }); expect(onTurnSettled).toHaveBeenCalledOnce(); expect(onTurnSettled).toHaveBeenCalledWith('handled'); @@ -379,16 +1767,27 @@ describe('SessionExecutionService', () => { ); expect(runtime.turnId).toBe('assistant:user-2'); expect(runtime.userTurnId).toBe('user-2'); - expect(runtime.invocation).toEqual({ - requesterUserId: 'user-1', + expect(runtime.invocation).toMatchObject({ + requesterUserId: 'authenticated-user', sourceTurnId: 'user-2', inputConfig: { prompt: 'change direction' }, }); - expect(service.getActiveInvocationContext(sessionId)).toEqual({ - requesterUserId: 'user-1', + expect(service.getActiveInvocationContext(sessionId)).toMatchObject({ + requesterUserId: 'authenticated-user', sourceTurnId: 'user-2', inputConfig: { prompt: 'change direction' }, }); + expect(sessionDoc.consumeMessageQueueItemAsUserTurn).toHaveBeenCalledWith( + queuedItem.$cid, + expect.any(Function), + { publishDispatch: false } + ); + expect(sessionDoc.removeMessageQueueItem).toHaveBeenCalledWith(queuedItem.$cid); + await expect(deps.queueSteerOperationStore.read(sessionId)).resolves.toMatchObject({ + phase: 'applied', + completedAt: expect.any(Number), + response: { disposition: 'accepted', userTurnId: 'user-2' }, + }); expect(initialPromptRun.successor?.turnId).toBe('assistant:user-2'); expect(runtime.activePromptRun.turnId).toBe('assistant:user-2'); @@ -5576,7 +6975,7 @@ describe('SessionExecutionService', () => { steerApplied.resolve({ release: () => steerReleased.resolve() }); await expect(steering).resolves.toMatchObject({ applied: false, - disposition: 'stale-turn', + disposition: 'error', }); await steerReleased.promise; expect(onTurnSettled).not.toHaveBeenCalled(); diff --git a/locales/en.json b/locales/en.json index 49023d225..76d99cdda 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1795,6 +1795,7 @@ "sessions.messageQueue.saveEdit": "Save changes (Enter)", "sessions.messageQueue.title": "Queued messages", "sessions.messageQueue.upNext": "Up next", + "sessions.messageQueue.draftDisplaced": "This message left the queue. Your unsaved draft is kept below; copy it before dismissing.", "sessions.messageStatus.deliverNow": "Deliver now", "sessions.messageStatus.deliverNowFailed": "Failed to deliver the message - please try again", "sessions.messageStatus.notDelivered": "Not delivered", @@ -2026,6 +2027,8 @@ "sessions.proposedPlanDecision.executing": "Implementing plan...", "sessions.queueEditError": "Failed to edit message", "sessions.queueError": "Failed to queue message", + "sessions.queueItemEditing": "Finish editing this queued message before steering it", + "sessions.queueItemMissing": "This queued message is no longer available", "sessions.queueRemoveError": "Failed to remove message from queue", "sessions.queueReorderError": "Failed to reorder messages", "sessions.removeImage": "Remove image", @@ -3495,6 +3498,7 @@ "commands.session.toggleCurrentPinned": "Toggle Current Chat Pinned", "commands.session.searchCurrent": "Find in Current Chat", "commands.session.focusInput": "Focus Current Input", + "commands.session.sendWithInverseQueueBehavior": "Send with Opposite Queue/Steer Behavior", "commands.session.saveCurrentFile": "Save Current File", "commands.session.toggleExplorerSidebar": "Toggle Files and Changes Sidebar", "commands.session.copyCurrentBranch": "Copy Current Branch", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index e8b18be38..630f009a2 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1795,6 +1795,7 @@ "sessions.messageQueue.saveEdit": "保存修改(回车)", "sessions.messageQueue.title": "排队中的消息", "sessions.messageQueue.upNext": "接下来", + "sessions.messageQueue.draftDisplaced": "这条消息已离开队列。未保存的草稿仍保留在下方,请在关闭前复制。", "sessions.messageStatus.deliverNow": "重新发送", "sessions.messageStatus.deliverNowFailed": "送达失败,请重试", "sessions.messageStatus.notDelivered": "未送达", @@ -2026,6 +2027,8 @@ "sessions.proposedPlanDecision.executing": "正在实施计划...", "sessions.queueEditError": "编辑消息失败", "sessions.queueError": "消息入队失败", + "sessions.queueItemEditing": "请先完成这条排队消息的编辑,再进行引导", + "sessions.queueItemMissing": "这条排队消息已不存在", "sessions.queueRemoveError": "移除消息失败", "sessions.queueReorderError": "调整消息顺序失败", "sessions.removeImage": "移除图片", @@ -3495,6 +3498,7 @@ "commands.session.toggleCurrentPinned": "切换当前对话置顶", "commands.session.searchCurrent": "在当前对话中查找", "commands.session.focusInput": "聚焦当前输入框", + "commands.session.sendWithInverseQueueBehavior": "以相反的排队/引导方式发送", "commands.session.saveCurrentFile": "保存当前文件", "commands.session.toggleExplorerSidebar": "切换文件与更改侧边栏", "commands.session.copyCurrentBranch": "复制当前分支", diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index f0d6fba22..12364f432 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -18,6 +18,7 @@ import type { SessionPreparationSpec, SessionPrepareCancelResponse, SessionPrepareResponse, + SessionQueueSteerResponse, SessionSteerResponse, SessionGoalAction, SessionGoalResponse, @@ -315,6 +316,15 @@ export type WorkspaceRuntime = { }, options?: { timeoutMs?: number } ) => Promise; + requestSessionQueueSteer: ( + machineId: MachineId, + args: { + sessionId: SessionId; + expectedTurnId: string; + queueItemId: string; + }, + options?: { timeoutMs?: number } + ) => Promise; requestSessionGoal: ( machineId: MachineId, args: { diff --git a/packages/components/src/components/sessions/message-queue/AGENTS.md b/packages/components/src/components/sessions/message-queue/AGENTS.md index 7ed62a025..b444364fd 100644 --- a/packages/components/src/components/sessions/message-queue/AGENTS.md +++ b/packages/components/src/components/sessions/message-queue/AGENTS.md @@ -9,10 +9,12 @@ queued-turn list (`message-queue-display.tsx`, `message-queue-row.tsx`, `../session-message-submit-route.ts` and is described in [.agents/docs/sessions-live-status.md](../../../../../../.agents/docs/sessions-live-status.md). -A queued item's Steer action uses native acknowledged steering only when the -authoritative ACP capability cache advertises it. Never infer steering support -from built-in/custom config type or agent identity; unsupported and stale cache -entries retain the interrupt-and-send fallback. +Exact-item Steer requires the negotiated `queueItemSteer` daemon protocol; missing means +unsupported. The daemon chooses acknowledged native Steer or exact cancel-and-dispatch. +No supported version means no Steer on any row, including the head; no legacy native/cancel path. +Daemon-reserved rows reject edits/removal/reordering; retain a rejected edit's local draft even +when the last row disappears. Never reorder to emulate Steer. A missing, stale, or edited target leaves the current +turn running. A row's number and message body are one drag activator; its actions stay out. The queue intentionally stays OUT of the composer info bar ([.agents/docs/sessions-info-bar.md](../../../../../../.agents/docs/sessions-info-bar.md)). diff --git a/packages/components/src/components/sessions/message-queue/index.ts b/packages/components/src/components/sessions/message-queue/index.ts index ae0123174..078978312 100644 --- a/packages/components/src/components/sessions/message-queue/index.ts +++ b/packages/components/src/components/sessions/message-queue/index.ts @@ -2,7 +2,6 @@ export { MessageQueueDisplay } from './message-queue-display'; export type { MessageQueueDisplayProps } from './message-queue-display'; export { MessageQueueRow } from './message-queue-row'; export type { MessageQueueRowProps } from './message-queue-row'; -export { shouldRequestNativeQueueSteer } from './queued-message-steer'; export { QueuedImagePreview } from './queued-image-preview'; export type { QueuedImageBlock } from './queued-image-preview'; export { diff --git a/packages/components/src/components/sessions/message-queue/message-queue-display.tsx b/packages/components/src/components/sessions/message-queue/message-queue-display.tsx index 99446078f..94e92a4c4 100644 --- a/packages/components/src/components/sessions/message-queue/message-queue-display.tsx +++ b/packages/components/src/components/sessions/message-queue/message-queue-display.tsx @@ -37,6 +37,8 @@ export type MessageQueueDisplayProps = { onEditSave: (item: MessageQueueItem, task: string) => void | Promise; onSteer: (item: MessageQueueItem) => void | Promise; showSteerAction?: boolean; + steerActionScope?: 'all' | 'head'; + steerDisabledReason?: string; className?: string; }; @@ -52,6 +54,8 @@ export function MessageQueueDisplay({ onEditSave, onSteer, showSteerAction = false, + steerActionScope = 'all', + steerDisabledReason, className, }: MessageQueueDisplayProps) { const { t } = useTranslation(); @@ -59,6 +63,11 @@ export function MessageQueueDisplay({ const [overflow, setOverflow] = useState(NO_SCROLL_EDGE_OVERFLOW); const editing = useMessageQueueEditing(items, { onEditStart, onEditCancel, onEditSave }); + const displacedDraft = + editing.editingItem && !items.some((item) => item.$cid === editing.editingCid) + ? editing.editingItem + : null; + const visibleItems = displacedDraft ? [...items, displacedDraft] : items; const itemIds = useMemo(() => items.map((item) => item.$cid), [items]); const canReorder = items.length > 1; @@ -103,7 +112,7 @@ export function MessageQueueDisplay({ [onReorder] ); - if (items.length === 0) { + if (items.length === 0 && !displacedDraft) { return null; } @@ -142,13 +151,21 @@ export function MessageQueueDisplay({ WebkitMaskImage: fadeMask, }} > + {displacedDraft && ( +

+ {t( + 'sessions.messageQueue.draftDisplaced', + 'This message left the queue. Your unsaved draft is kept below; copy it before dismissing.' + )} +

+ )} - {items.map((item, index) => { + {visibleItems.map((item, index) => { const isEditing = editing.editingCid === item.$cid; return ( 0} + steerDisabledReason={steerDisabledReason} + canReorder={canReorder && !displacedDraft} isEditing={isEditing} editValue={isEditing ? editing.editValue : ''} isPending={editing.pendingCid === item.$cid} diff --git a/packages/components/src/components/sessions/message-queue/message-queue-row.tsx b/packages/components/src/components/sessions/message-queue/message-queue-row.tsx index f32cf818b..7dc1b5fcf 100644 --- a/packages/components/src/components/sessions/message-queue/message-queue-row.tsx +++ b/packages/components/src/components/sessions/message-queue/message-queue-row.tsx @@ -17,8 +17,9 @@ export type MessageQueueRowProps = { sessionId: SessionId; item: MessageQueueItem; index: number; - isFirst: boolean; showSteerAction: boolean; + steerDisabled?: boolean; + steerDisabledReason?: string; canReorder: boolean; isEditing: boolean; editValue: string; @@ -37,8 +38,10 @@ type EditCommitProps = { }; export function MessageQueueRow(props: MessageQueueRowProps) { + const { t } = useTranslation(); const { item, canReorder, isEditing, isPending, editValue, onCancelEdit, onSaveEdit } = props; const sortable = useSortable({ id: item.$cid, disabled: !canReorder || isEditing }); + const dragEnabled = canReorder && !isEditing; const constrainedTransform = sortable.transform ? { ...sortable.transform, x: 0, scaleX: 1, scaleY: 1 } : null; @@ -95,22 +98,28 @@ export function MessageQueueRow(props: MessageQueueRowProps) { isEditing && 'bg-background/60' )} > - - +
+ + +
); } -function LeadingHandle({ - index, - canReorder, - isEditing, - sortable, -}: MessageQueueRowProps & { sortable: ReturnType }) { - const { t } = useTranslation(); - const label = t('sessions.messageQueue.dragToReorder', 'Drag to reorder'); - +function LeadingHandle({ index, canReorder, isEditing }: MessageQueueRowProps) { if (!canReorder || isEditing) { return (
- - - - {label} - + ); } @@ -288,7 +286,16 @@ function RowBody(props: MessageQueueRowProps & EditCommitProps) { function RowActions(props: MessageQueueRowProps) { const { t } = useTranslation(); - const { item, isFirst, showSteerAction, isEditing, onStartEdit, onRemove, onSteer } = props; + const { + item, + showSteerAction, + steerDisabled, + steerDisabledReason, + isEditing, + onStartEdit, + onRemove, + onSteer, + } = props; // In edit mode the textarea owns the row: it carries its own confirm button, so we // render no row-level actions that would compete for the click mid-edit. @@ -298,13 +305,15 @@ function RowActions(props: MessageQueueRowProps) { return (
- {isFirst && showSteerAction ? ( + {showSteerAction ? ( { void onSteer(item); }} @@ -330,21 +339,28 @@ function RowActions(props: MessageQueueRowProps) { function TextAction({ text, ariaLabel, + disabled, + disabledReason, onClick, }: { text: string; ariaLabel: string; + disabled?: boolean; + disabledReason?: string; onClick: () => void; }) { return (