Преглед на файлове

refactor(file-upload): scope prompt binding rollback

creatixchu преди 1 седмица
родител
ревизия
3eb9736eb6

+ 2 - 2
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

+ 4 - 3
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.

+ 4 - 3
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.

+ 4 - 8
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) {

+ 1 - 1
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)

+ 1 - 1
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)
   }

+ 2 - 2
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

+ 1 - 1
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 |
 |---|---|

+ 1 - 1
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 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。
 
 | 文件 | 职责 |
 |---|---|

+ 32 - 5
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<Agent>
 
+/** 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
       }
-    }
+    })
   }
 
   /**

+ 7 - 3
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}',

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -346,6 +346,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   SessionUpdateQueueValue: 'session.md',
   EncodedFileUploadRequest: 'attachment.md',
   AgentResolver: 'attachment.md',
+  PromptFileBinding: 'attachment.md',
   FileUploadReceiptId: 'attachment.md',
   FileUploadValue: 'attachment.md',
   SessionStartSource: 'core.md',