mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(web): address queue review feedback
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md
|
||||
2026-07-29-addressable-queue-operations.md: 93b11dc590728a3c236971fbadf9c7c0187d943c
|
||||
2026-07-29-addressable-queue-operations.zh.md: 3a8e46946ad8cbe122bc3d89c6b470654ea97baf
|
||||
2026-07-29-addressable-queue-operations.md: 78a7d346163bb7e5e76c989c6e93576b4a6cee64
|
||||
2026-07-29-addressable-queue-operations.zh.md: 050b9755ad4ebe70e2bdcafb711ef279331e27af
|
||||
|
||||
@@ -14,9 +14,11 @@ The Web queue rendered pending messages but could not edit or delete one row. `M
|
||||
|
||||
**Mutation ends at driver claim.** `Agent.updateInbox(id, action)` synchronously searches the pending queued FIFO. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, wake policy, and position. Remove emits the occurrence’s terminal discard. Steering and driver-claimed occurrences return `not-found`, so queue operations never rewrite active-turn input or durable history.
|
||||
|
||||
**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes.
|
||||
**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. A synchronously re-entrant update or terminal event may reach the mirror before its outer enqueue listener; the mirror retains that unseen outcome for the current dispatch and folds it into the enqueue, so listener registration order cannot publish stale content or a ghost row. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes.
|
||||
|
||||
**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock exposes edit and delete, but no send-now control. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence.
|
||||
**Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`.
|
||||
|
||||
**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock exposes edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,9 +30,11 @@ The Web queue rendered pending messages but could not edit or delete one row. `M
|
||||
|
||||
**Expose a protocol-only promotion operation.** Rejected because no product interaction reorders Queue. A public operation without a current consumer would add ordering semantics and tests for speculative use.
|
||||
|
||||
**Resume a cold Agent for a queue operation.** Rejected because durable session identity does not preserve the process-local inbox capability. Resuming can only produce `not-found` after creating unrelated live state.
|
||||
|
||||
## Verification
|
||||
|
||||
AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, reconnect, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive the exposed edit and delete actions through the built Web composition and real HTTP/SSE wire.
|
||||
AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive the exposed edit and delete actions through the built Web composition and real HTTP/SSE wire.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行
|
||||
|
||||
**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索待处理的 queued FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、唤醒策略和位置。移除会发出该次入队项的终态 discard。steering(中途引导)项和已被驱动器认领的项会返回 `not-found`,因此队列操作绝不会改写活动轮次输入或持久历史。
|
||||
|
||||
**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。
|
||||
**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。同步可重入的 update 或终态事件可能先于外层 enqueue 监听器到达镜像;镜像会在当前分发期间保留这一尚不可见的结果,并在处理 enqueue 时把它合并进去,因此监听器注册顺序不会导致系统发布陈旧内容或不存在的行。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。
|
||||
|
||||
**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 暴露编辑和删除,不提供立即发送控件。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。
|
||||
**Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。
|
||||
|
||||
**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 暴露编辑和删除,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -28,9 +30,11 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行
|
||||
|
||||
**暴露仅协议层的前移操作。** 不予采纳,因为当前没有产品交互会重新排序 Queue。公开一个没有当前消费方的操作,会为了推测性用途引入排序语义和测试。
|
||||
|
||||
**为队列操作恢复冷 Agent。** 不予采纳,因为持久会话标识不会保留进程本地的 inbox 寻址凭据。恢复只能在创建无关的实时状态后得到 `not-found`。
|
||||
|
||||
## 验证
|
||||
|
||||
AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、重连、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除。
|
||||
AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
13
packages/client/ui-conversation/src/client/contract/queue.ts
Normal file
13
packages/client/ui-conversation/src/client/contract/queue.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** Queue contracts derived from the runtime session face and snapshot. */
|
||||
import type {
|
||||
ConversationSnapshot, SessionFace,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One address accepted by the runtime session's queue mutation verb. */
|
||||
export type QueueItemId = Parameters<SessionFace['updateQueue']>[0]
|
||||
|
||||
/** One mutation accepted by the runtime session's queue mutation verb. */
|
||||
export type QueueAction = Parameters<SessionFace['updateQueue']>[1]
|
||||
|
||||
/** One row projected by the runtime session's authoritative queue snapshot. */
|
||||
export type QueueRow = ConversationSnapshot['queue'][number]
|
||||
@@ -6,11 +6,11 @@
|
||||
* (machine.ts) is package-private and never exported.
|
||||
*/
|
||||
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
|
||||
ReferenceInsert, SubmitOutcome, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { QueueRow } from '../contract/queue.ts'
|
||||
|
||||
/**
|
||||
* The scoped-event application verbs: the hub's bail listeners call these,
|
||||
@@ -101,11 +101,7 @@ export interface ComposerKeyboard {
|
||||
}
|
||||
|
||||
/** One independently addressable row projected from the transient queue snapshot. */
|
||||
export interface QueuedMessage {
|
||||
readonly id: InboxItemId
|
||||
readonly preview: string
|
||||
readonly text: string | null
|
||||
}
|
||||
export type QueuedMessage = QueueRow
|
||||
|
||||
/** Guard union of the scoped consume-token event, checked by the machine. */
|
||||
export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
|
||||
|
||||
@@ -7,15 +7,15 @@ import type { Context } from 'cordis'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
||||
import css from './QueueDock.module.css'
|
||||
|
||||
/** Queue operations injected by the session-scoped registration. */
|
||||
export interface QueueDockInjected {
|
||||
updateQueue: (itemId: InboxItemId, action: QueueAction) => Promise<void>
|
||||
updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise<void>
|
||||
notify: (level: 'info' | 'error', text: string) => void
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
|
||||
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
|
||||
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
const queue = useSession(s => s.queue)
|
||||
const [editing, setEditing] = useState<{ id: InboxItemId; text: string } | null>(null)
|
||||
const [busy, setBusy] = useState<InboxItemId | null>(null)
|
||||
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
|
||||
const [busy, setBusy] = useState<QueueItemId | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
|
||||
@@ -35,7 +35,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
if (queue.length === 0) return null
|
||||
|
||||
const applyAction = async (
|
||||
itemId: InboxItemId,
|
||||
itemId: QueueItemId,
|
||||
action: QueueAction,
|
||||
failure: string,
|
||||
): Promise<boolean> => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { Context } from 'cordis'
|
||||
// error, so scope resolution goes through the sessions service (scopeOf
|
||||
// method) instead of the standalone helper.
|
||||
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { QueueAction, QueueItemId } from './contract/queue.ts'
|
||||
import type { InputService } from './input/contract.ts'
|
||||
|
||||
/**
|
||||
@@ -37,7 +37,7 @@ export interface IConversation {
|
||||
* @param action - edit or remove operation.
|
||||
* @returns completion; business failures reject.
|
||||
*/
|
||||
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<void>
|
||||
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
|
||||
/**
|
||||
* Cancel the scoped session's in-flight turn.
|
||||
* @returns completion; failures reject as in send.
|
||||
@@ -80,7 +80,7 @@ export class ConversationService extends Service implements IConversation {
|
||||
}
|
||||
|
||||
/** Apply one operation to a pending queue occurrence. */
|
||||
async updateQueue(itemId: InboxItemId, action: QueueAction): Promise<void> {
|
||||
async updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void> {
|
||||
const session = this.scopedSession('updateQueue')
|
||||
const result = await session.updateQueue(itemId, action)
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -9,15 +9,15 @@ import { useSyncExternalStore } from 'react'
|
||||
import type {
|
||||
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { QueueItemId } from '../src/client/contract/queue.ts'
|
||||
import type { InputState } from '../src/client/input/contract.ts'
|
||||
import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
const iid = (id: string): InboxItemId => id as InboxItemId
|
||||
const iid = (id: string): QueueItemId => id as QueueItemId
|
||||
|
||||
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
|
||||
return { id: iid(id), preview, text }
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: e4a29dc288d7279366c2a28ed43573792979aa92
|
||||
README.zh.md: 9f5eb0c4f84eb909d931dda8cbac764bf83aa2fe
|
||||
README.md: 7129842a0cc89f5fa10c0bceec7cf0997ac71a99
|
||||
README.zh.md: 8765627236ea7af9e5cd3b7029133181ef499905
|
||||
|
||||
@@ -16,7 +16,7 @@ Session titles ride the generic projection pair like every other domain — the
|
||||
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
|
||||
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The client never infers retirement from turn or status events.
|
||||
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
|
||||
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。客户端绝不根据轮次或状态事件推断项已退役。
|
||||
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem,
|
||||
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, InboxItemId,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
@@ -514,6 +514,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
* identified message remain visible until every occurrence is claimed.
|
||||
*/
|
||||
const queuedMirror = new Map<SessionId, InboxItem[]>()
|
||||
type UnseenQueueEvent =
|
||||
| { readonly kind: 'update'; readonly item: InboxItem }
|
||||
| { readonly kind: 'terminal' }
|
||||
const unseenQueueEvents = new Map<SessionId, Map<InboxItemId, UnseenQueueEvent>>()
|
||||
const rememberUnseen = (sessionId: SessionId, itemId: InboxItemId, event: UnseenQueueEvent): void => {
|
||||
let events = unseenQueueEvents.get(sessionId)
|
||||
if (events === undefined) {
|
||||
events = new Map()
|
||||
unseenQueueEvents.set(sessionId, events)
|
||||
}
|
||||
events.set(itemId, event)
|
||||
// Only synchronous re-entrancy may deliver a mutation before its outer
|
||||
// enqueue observer. Drop unmatched protocol-invalid observations instead
|
||||
// of retaining process-local ids indefinitely.
|
||||
queueMicrotask(() => {
|
||||
const current = unseenQueueEvents.get(sessionId)
|
||||
if (current?.get(itemId) !== event) return
|
||||
current.delete(itemId)
|
||||
if (current.size === 0) unseenQueueEvents.delete(sessionId)
|
||||
})
|
||||
}
|
||||
const takeUnseen = (sessionId: SessionId, itemId: InboxItemId): UnseenQueueEvent | undefined => {
|
||||
const events = unseenQueueEvents.get(sessionId)
|
||||
const event = events?.get(itemId)
|
||||
if (event === undefined) return undefined
|
||||
events?.delete(itemId)
|
||||
if (events?.size === 0) unseenQueueEvents.delete(sessionId)
|
||||
return event
|
||||
}
|
||||
const publishQueue = (sessionId: SessionId): void => {
|
||||
const items = queuedMirror.get(sessionId) ?? []
|
||||
broadcast({
|
||||
@@ -526,49 +555,59 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const retire = (agent: Agent, item: InboxItem): void => {
|
||||
const retire = (agent: Agent, item: InboxItem): boolean => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) return
|
||||
if (entries === undefined) {
|
||||
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
|
||||
return false
|
||||
}
|
||||
const index = entries.findIndex(entry => entry.id === item.id)
|
||||
if (index === -1) return
|
||||
if (index === -1) {
|
||||
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
|
||||
return false
|
||||
}
|
||||
entries.splice(index, 1)
|
||||
if (entries.length === 0) queuedMirror.delete(agent.id)
|
||||
publishQueue(agent.id)
|
||||
return true
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
|
||||
if (item.placement !== 'queued') return
|
||||
const unseen = takeUnseen(agent.id, item.id)
|
||||
if (unseen?.kind === 'terminal') return
|
||||
let entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) {
|
||||
entries = []
|
||||
queuedMirror.set(agent.id, entries)
|
||||
}
|
||||
entries.push(item)
|
||||
entries.push(unseen?.kind === 'update' ? unseen.item : item)
|
||||
publishQueue(agent.id)
|
||||
}),
|
||||
ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) return
|
||||
if (entries === undefined) {
|
||||
rememberUnseen(agent.id, item.id, { kind: 'update', item })
|
||||
return
|
||||
}
|
||||
const index = entries.findIndex(entry => entry.id === item.id)
|
||||
if (index === -1) return
|
||||
if (index === -1) {
|
||||
rememberUnseen(agent.id, item.id, { kind: 'update', item })
|
||||
return
|
||||
}
|
||||
entries.splice(index, 1, item)
|
||||
publishQueue(agent.id)
|
||||
}),
|
||||
ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => {
|
||||
retire(agent, item)
|
||||
if (retire(agent, item)) publishQueue(agent.id)
|
||||
}),
|
||||
ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => {
|
||||
const entries = queuedMirror.get(agent.id)
|
||||
if (entries === undefined) return
|
||||
const ids = new Set(items.map(item => item.id))
|
||||
const kept = entries.filter(entry => !ids.has(entry.id))
|
||||
if (kept.length === entries.length) return
|
||||
if (kept.length === 0) queuedMirror.delete(agent.id)
|
||||
else queuedMirror.set(agent.id, kept)
|
||||
publishQueue(agent.id)
|
||||
let changed = false
|
||||
for (const item of items) changed = retire(agent, item) || changed
|
||||
if (changed) publishQueue(agent.id)
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
queuedMirror.delete(session.id)
|
||||
unseenQueueEvents.delete(session.id)
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
@@ -1120,18 +1159,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
|
||||
async updateQueue(request) {
|
||||
updateQueue(request) {
|
||||
const { sessionId, itemId, action } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
if (found.agent.updateInbox(itemId, action) === 'not-found') {
|
||||
return err(request, {
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'queue-item-not-found',
|
||||
message: 'queued item is no longer pending',
|
||||
details: { itemId },
|
||||
})
|
||||
}))
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
return Promise.resolve(ok(request, { accepted: true as const }))
|
||||
},
|
||||
|
||||
cancel(request) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
* open-time queue snapshot.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent'
|
||||
@@ -313,9 +313,55 @@ describe('session.updateQueue', () => {
|
||||
{ id: 'claimed', action: { kind: 'remove' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a stale occurrence without resuming a cold agent', async () => {
|
||||
const ctx = await harness()
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const response = await api.sessions.updateQueue({
|
||||
rpcId: RpcId('q-cold'),
|
||||
payload: {
|
||||
sessionId: 'cold-session' as SessionId,
|
||||
itemId: InboxItemId('stale-item'),
|
||||
action: { kind: 'remove' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' })
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/queue frames', () => {
|
||||
it('folds nested mutations observed before their outer enqueue', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = stubAgent(ctx)
|
||||
const original = inboxItem('i-edit', inboxMessage('m-edit', 'before'), 'queued')
|
||||
const edited = inboxItem('i-edit', inboxMessage('m-edit', 'after'), 'queued')
|
||||
const removed = inboxItem('i-remove', inboxMessage('m-remove', 'remove me'), 'queued')
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent) return
|
||||
if (item.id === original.id) ctx.emit('agent/inbox/update', agent, edited)
|
||||
if (item.id === removed.id) ctx.emit('agent/inbox/discard', agent, [removed])
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const live = new AbortController()
|
||||
const collected = collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-reentrant'), payload: {} }, live.signal), 2, live)
|
||||
|
||||
ctx.emit('agent/inbox/enqueue', agent, original)
|
||||
ctx.emit('agent/inbox/enqueue', agent, removed)
|
||||
|
||||
const liveFrames = (await collected).filter(frame => frame.type === 'session/queue')
|
||||
expect(liveFrames.map(frame => frame.items)).toEqual([
|
||||
[{ id: edited.id, message: edited.message }],
|
||||
])
|
||||
const replay = new AbortController()
|
||||
const replayFrames = await collect<MuxFrame>(
|
||||
api.events.mux({ rpcId: RpcId('t-mux-reentrant-replay'), payload: {} }, replay.signal), 2, replay)
|
||||
expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames)
|
||||
})
|
||||
|
||||
it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
|
||||
Reference in New Issue
Block a user