From 3eb9736eb6eeebfdfcbc5fda5e9e1120ee15bf88 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 20:15:18 +0800 Subject: [PATCH] refactor(file-upload): scope prompt binding rollback --- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 7 ++-- docs/subsystems/attachment.zh.md | 7 ++-- .../api/session-controller/src/commands.ts | 12 ++---- .../tests/controller.host.spec.ts | 2 +- .../session-controller/tests/test-remote.ts | 2 +- packages/client/file-upload/README.i18n.yaml | 4 +- packages/client/file-upload/README.md | 2 +- packages/client/file-upload/README.zh.md | 2 +- packages/client/file-upload/src/index.ts | 37 ++++++++++++++++--- .../extensions/tool-cordis/src/api-catalog.ts | 10 +++-- scripts/gen-cordis-catalog.ts | 1 + 12 files changed, 60 insertions(+), 30 deletions(-) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 6b849c3679..f6bcc65c23 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -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 docs/subsystems/attachment.md -attachment.md: 3fe72b70af27a7597bca8eed24070589c1ba64d7 -attachment.zh.md: db43180565baaf0512cd5dc261324c79dddd1552 +attachment.md: 6d9d4c86aa9435450a766c6aa74f4062824ba96b +attachment.zh.md: 4a8a7c3acf4203ed4d3cff2b92098086a5aa39a9 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 3fe72b70af..6d9d4c86aa 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -305,13 +305,14 @@ async uploadStream(request: { readonly sessionId: SessionId readonly data: Async resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined /** - * Bind receipts to an accepted prompt and return a rollback for delivery failure. + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. * @param agent - receiving Agent. * @param receiptIds - distinct staged receipts referenced by the prompt. * @param requestId - prompt identity later observed in queue or history. - * @returns rollback restoring every prior binding. + * @returns binding kept after commit until queue or history observation retires its receipts. */ -bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void +bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding /** * Retire every receipt accepted by one removed queue occurrence. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index db43180565..4a8a7c3acf 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -305,13 +305,14 @@ async uploadStream(request: { readonly sessionId: SessionId readonly data: Async resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined /** - * Bind receipts to an accepted prompt and return a rollback for delivery failure. + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. * @param agent - receiving Agent. * @param receiptIds - distinct staged receipts referenced by the prompt. * @param requestId - prompt identity later observed in queue or history. - * @returns rollback restoring every prior binding. + * @returns binding kept after commit until queue or history observation retires its receipts. */ -bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void +bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding /** * Retire every receipt accepted by one removed queue occurrence. diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 2471f0990a..8b2a4ad658 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -341,14 +341,10 @@ export class SessionCommandController { { sessionId: agent.id }, ) } - const rollback = this.ctx.fileUploads.bindPrompt(agent, durable.receiptIds, request.requestId) - try { - if (request.mode === 'steer') agent.steer(message) - else agent.followup(message) - } catch (error) { - rollback() - throw error - } + using binding = this.ctx.fileUploads.bindPrompt(agent, durable.receiptIds, request.requestId) + if (request.mode === 'steer') agent.steer(message) + else agent.followup(message) + binding.commit() } catch (error) { if (remoteErrorOf(error) !== undefined) throw error if (error instanceof AttachmentError) { diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index a0b73df11f..422252ed8d 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -49,7 +49,7 @@ describe('SessionController facade', () => { return () => {} }, resolve: () => undefined, - bindPrompt: () => () => {}, + bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), retirePrompt: () => {}, } as never) const controller = createSessionTestController(ctx, defaults) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 531db817cb..ece2ad1512 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -240,7 +240,7 @@ function installControllers( ctx.provide('fileUploads', { registerAgentResolver: () => () => {}, resolve: () => undefined, - bindPrompt: () => () => {}, + bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), retirePrompt: () => {}, } as never) } diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml index 17fe405c3a..736d5e6480 100644 --- a/packages/client/file-upload/README.i18n.yaml +++ b/packages/client/file-upload/README.i18n.yaml @@ -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/client/file-upload/README.md -README.md: be7f923fc7769bb337597a53a82b22be73866b8f -README.zh.md: fb355562c1249f85585cef4cbbaaa674a8ced690 +README.md: 5527bf6f544529088a255934a87af77be4865e12 +README.zh.md: 7825326863511cd7098635d563237bddeb46d035 diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md index be7f923fc7..5527bf6f54 100644 --- a/packages/client/file-upload/README.md +++ b/packages/client/file-upload/README.md @@ -44,7 +44,7 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins The Client plugin provides `ctx.fileUpload`. Its `upload()` method asks Typert's registered `agent` Context adapter for the caller's identity, assembles the raw route request, and invokes the generated scoped Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. -The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, byte storage, command receipt resolver, and staged-receipt lifecycle. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. +The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, byte storage, command receipt resolver, and staged-receipt lifecycle. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. | File | Role | |---|---| diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md index fb355562c1..7825326863 100644 --- a/packages/client/file-upload/README.zh.md +++ b/packages/client/file-upload/README.zh.md @@ -44,7 +44,7 @@ kind: "package-reference" Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法通过 Typert 已注册的 `agent` Context adapter 取得调用方身份,组装原始路由请求,并为可重放输入调用生成的按 scope 寻址 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 -Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、字节存储、命令凭证解析器与暂存凭证生命周期。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。 +Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、字节存储、命令凭证解析器与暂存凭证生命周期。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 | 文件 | 职责 | |---|---| diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index 4cd257f7e0..4d94d53b1b 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -33,6 +33,12 @@ interface StagedFileUpload { /** Resolve or resume the ordinary Agent that owns one Session identity. */ export type AgentResolver = (sessionId: SessionId) => Promise +/** Prompt receipt binding that restores its previous owners unless delivery commits it. */ +export interface PromptFileBinding extends Disposable { + /** Keep the receipt bindings until queue or history observation retires them. */ + commit(): void +} + type FileUploadHttpResult = | { readonly ok: true; readonly value: FileUploadValue } | { @@ -40,6 +46,22 @@ type FileUploadHttpResult = readonly error: { readonly code: string; readonly message: string; readonly details: object } } +class PromptFileBindingGuard implements PromptFileBinding { + private settled = false + + constructor(private readonly rollback: () => void) {} + + commit(): void { + this.settled = true + } + + [Symbol.dispose](): void { + if (this.settled) return + this.settled = true + this.rollback() + } +} + /** Host service owning upload storage and Agent-scoped staged receipts. */ export class FileUploads extends TypertRemoteService { static inject = ['agents', 'attachments', 'commands', 'connection'] @@ -129,13 +151,18 @@ export class FileUploads extends TypertRemoteService { } /** - * Bind receipts to an accepted prompt and return a rollback for delivery failure. + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. * @param agent - receiving Agent. * @param receiptIds - distinct staged receipts referenced by the prompt. * @param requestId - prompt identity later observed in queue or history. - * @returns rollback restoring every prior binding. + * @returns binding kept after commit until queue or history observation retires its receipts. */ - bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void { + bindPrompt( + agent: Agent, + receiptIds: readonly FileUploadReceiptId[], + requestId: string, + ): PromptFileBinding { this.assertAgentScope(agent) const staged = this.stagedFiles.get(agent.session) const bound = receiptIds.map((receiptId) => { @@ -144,12 +171,12 @@ export class FileUploads extends TypertRemoteService { return { upload, previous: upload.requestId } }) for (const { upload } of bound) upload.requestId = requestId - return () => { + return new PromptFileBindingGuard(() => { for (const { upload, previous } of bound) { if (previous === undefined) delete upload.requestId else upload.requestId = previous } - } + }) } /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 60e7b54d9e..d2f808d89d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -918,10 +918,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'durable file reference, or `undefined` for an unknown or foreign receipt.', }, { - signature: 'bindPrompt(agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string): () => void', - description: 'Bind receipts to an accepted prompt and return a rollback for delivery failure.', + signature: 'bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding', + description: 'Bind receipts while one prompt enters an Agent inbox. Disposal restores every prior binding unless the caller commits successful delivery.', parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptIds', description: 'distinct staged receipts referenced by the prompt.' }, { name: 'requestId', description: 'prompt identity later observed in queue or history.' }], - returns: 'rollback restoring every prior binding.', + returns: 'binding kept after commit until queue or history observation retires its receipts.', }, { signature: 'retirePrompt(agent: Agent, requestId: string): void', @@ -4678,6 +4678,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptContextOrderName', declaration: 'export type PromptContextOrderName = keyof typeof CONTEXT_ORDERS;', }, + { + name: 'PromptFileBinding', + declaration: 'export interface PromptFileBinding extends Disposable {\n commit(): void;\n}', + }, { name: 'PromptSection', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 371d6e62cd..ca4cc4aafa 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -346,6 +346,7 @@ export const LINK_MAP: Readonly> = { SessionUpdateQueueValue: 'session.md', EncodedFileUploadRequest: 'attachment.md', AgentResolver: 'attachment.md', + PromptFileBinding: 'attachment.md', FileUploadReceiptId: 'attachment.md', FileUploadValue: 'attachment.md', SessionStartSource: 'core.md',