refactor(file-upload): scope prompt binding rollback

This commit is contained in:
creatixchu
2026-09-03 20:15:18 +08:00
parent b2c4483c9d
commit 3eb9736eb6
12 changed files with 60 additions and 30 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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.
@@ -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) {
@@ -49,7 +49,7 @@ describe('SessionController facade', () => {
return () => {}
},
resolve: () => undefined,
bindPrompt: () => () => {},
bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
retirePrompt: () => {},
} as never)
const controller = createSessionTestController(ctx, defaults)
@@ -240,7 +240,7 @@ function installControllers(
ctx.provide('fileUploads', {
registerAgentResolver: () => () => {},
resolve: () => undefined,
bindPrompt: () => () => {},
bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
retirePrompt: () => {},
} as never)
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
})
}
/**
@@ -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
View File
@@ -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',