@springbrand/agent-runtime 0.2.0-alpha.72 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/package.json +3 -3
  2. package/src/{agent-tool-runtime.ts → adapter/cloudflare/agent-tool.ts} +1 -1
  3. package/src/adapter/cloudflare/agent.ts +1539 -0
  4. package/src/{db/index.ts → adapter/cloudflare/database.ts} +18 -55
  5. package/src/{runtime-agent.ts → adapter/cloudflare/define-agent.ts} +25 -35
  6. package/src/adapter/cloudflare/dynamic-worker-observability.ts +1 -1
  7. package/src/adapter/cloudflare/resources/runtime-resources.ts +4 -4
  8. package/src/adapter/cloudflare/sandbox/adapter.ts +1 -1
  9. package/src/{db → adapter/cloudflare}/schema.ts +16 -83
  10. package/src/adapter/cloudflare/subagent/definition.ts +63 -33
  11. package/src/adapter/cloudflare/subagent/runner.ts +174 -120
  12. package/src/adapter/cloudflare/subagent/tools.ts +13 -14
  13. package/src/adapter/cloudflare/universal-agent/preparation.ts +7 -7
  14. package/src/adapter/cloudflare/universal-agent/tools.ts +10 -10
  15. package/src/adapter/cloudflare/workspace/git-fs.ts +1 -1
  16. package/src/adapter/cloudflare/workspace/scoped-workspace.ts +1 -1
  17. package/src/adapter/cloudflare/workspace/version-control.ts +2 -2
  18. package/src/config/activation.ts +140 -0
  19. package/src/{runtime-agent-context.ts → config/agent-context.ts} +3 -3
  20. package/src/{runtime-assembler.ts → config/assembler.ts} +20 -19
  21. package/src/{kernel/runtime-assembly.ts → config/assembly-projection.ts} +4 -4
  22. package/src/{pi/assembly/snapshot.ts → config/assembly-snapshot.ts} +6 -6
  23. package/src/{runtime-definition.ts → config/definition.ts} +13 -13
  24. package/src/config/harness-runtime.ts +182 -0
  25. package/src/config/index.ts +14 -0
  26. package/src/{pi/runtime-adapter/assembly.ts → config/pinning.ts} +38 -16
  27. package/src/{kernel/bindings.ts → config/ports.ts} +9 -13
  28. package/src/{kernel → config}/profile.ts +7 -0
  29. package/src/{kernel → config}/public-contracts.ts +6 -6
  30. package/src/{kernel → config}/runtime-load.ts +1 -1
  31. package/src/{pi/assembly/context.ts → context/assembly.ts} +4 -206
  32. package/src/{pi/assembly/extensions.ts → context/extension/assembly.ts} +5 -5
  33. package/src/context/extension/index.ts +3 -0
  34. package/src/context/index.ts +6 -0
  35. package/src/{layers/context → context/memory}/budget/gate.ts +2 -2
  36. package/src/conversation/harness-resume.ts +288 -0
  37. package/src/conversation/harness-stream.ts +98 -0
  38. package/src/{pi/runtime-adapter/transcript.ts → conversation/history.ts} +206 -233
  39. package/src/conversation/index.ts +6 -0
  40. package/src/{pi → conversation}/message/contract.ts +3 -0
  41. package/src/{pi → conversation}/message/conversion.ts +75 -0
  42. package/src/{pi → conversation}/message/index.ts +1 -0
  43. package/src/{pi → conversation}/message/model-input.ts +1 -1
  44. package/src/{pi → conversation}/message/projection.ts +76 -10
  45. package/src/{db/message-ui.repo.ts → conversation/repository.ts} +4 -0
  46. package/src/conversation/state.ts +175 -0
  47. package/src/harness/index.ts +7 -0
  48. package/src/harness/session-harness.ts +481 -0
  49. package/src/harness/submission-operation.ts +49 -0
  50. package/src/index.ts +38 -43
  51. package/src/lib/mcp.ts +1 -1
  52. package/src/{pi/runtime-adapter/models.ts → models/index.ts} +64 -18
  53. package/src/{pi/turn/tool-recovery.ts → session/cloudflare/legacy-tool-recovery.ts} +33 -525
  54. package/src/{pi/session → session/cloudflare}/migration.ts +334 -8
  55. package/src/{pi/session → session/cloudflare}/storage.ts +8 -36
  56. package/src/session/index.ts +39 -0
  57. package/src/submission/admission.ts +275 -0
  58. package/src/{kernel/receipts.ts → submission/contracts.ts} +14 -0
  59. package/src/submission/delivery.ts +212 -0
  60. package/src/submission/finalization.ts +111 -0
  61. package/src/submission/index.ts +9 -0
  62. package/src/{kernel/submission-lifecycle.ts → submission/lifecycle.ts} +41 -28
  63. package/src/{db/submission.repo.ts → submission/repository.ts} +61 -118
  64. package/src/submission/steer-delivery.ts +167 -0
  65. package/src/{db/steer.repo.ts → submission/steer-repository.ts} +27 -5
  66. package/src/telemetry/contract.ts +12 -0
  67. package/src/telemetry/coordinator.ts +65 -0
  68. package/src/telemetry/delivery.ts +2 -2
  69. package/src/telemetry/index.ts +5 -0
  70. package/src/{model-usage.ts → telemetry/model-usage.ts} +1 -1
  71. package/src/telemetry/recorder.ts +1 -1
  72. package/src/telemetry/runtime-telemetry.ts +12 -0
  73. package/src/{kernel/durable-lifecycle.ts → telemetry/subagent-lifecycle.ts} +1 -1
  74. package/src/telemetry/turn-events.ts +279 -0
  75. package/src/{pi/tool → tools}/ai-adapter.ts +2 -2
  76. package/src/tools/approval/index.ts +1 -0
  77. package/src/{db/approval.repo.ts → tools/approval/repository.ts} +10 -30
  78. package/src/{pi/tool → tools}/base.ts +14 -30
  79. package/src/tools/compiler.ts +327 -0
  80. package/src/{pi/tool → tools}/core-host.ts +3 -3
  81. package/src/{pi/tool → tools}/core.ts +3 -3
  82. package/src/tools/decision-gate.ts +227 -0
  83. package/src/{pi/tool → tools}/gateway.ts +1 -1
  84. package/src/tools/human-decisions.ts +127 -0
  85. package/src/tools/index.ts +30 -0
  86. package/src/tools/interaction/index.ts +1 -0
  87. package/src/{pi/tool → tools}/mcp.ts +1 -1
  88. package/src/{pi/tool → tools}/schedule.ts +3 -3
  89. package/src/{pi/tool → tools}/skill.ts +4 -4
  90. package/src/{kernel → tools}/subagent-runtime.ts +1 -1
  91. package/src/{pi/tool → tools}/subagent.ts +4 -4
  92. package/src/tools/temporary-agent/approvals.ts +126 -0
  93. package/src/{layers/orchestration/temporary-agent/core.ts → tools/temporary-agent/coordinator.ts} +41 -49
  94. package/src/tools/temporary-agent/core.ts +53 -0
  95. package/src/{layers/orchestration → tools}/temporary-agent/runner.ts +1 -1
  96. package/src/{layers/orchestration → tools}/temporary-agent/workspace.ts +1 -1
  97. package/src/{pi/tool → tools}/web-search/api.ts +1 -1
  98. package/src/{pi/tool → tools}/workspace-revision.ts +2 -2
  99. package/src/{pi/tool → tools}/workspace-sandbox.ts +2 -2
  100. package/src/{pi/tool → tools}/workspace-state-connector.ts +1 -1
  101. package/src/db/milestone.repo.ts +0 -104
  102. package/src/db/settlement.repo.ts +0 -104
  103. package/src/kernel/approval-lifecycle.ts +0 -638
  104. package/src/kernel/interaction-lifecycle.ts +0 -433
  105. package/src/kernel/recoverable-chat-agent.ts +0 -1020
  106. package/src/kernel/state.ts +0 -91
  107. package/src/pi/assembly/index.ts +0 -19
  108. package/src/pi/runtime-adapter/execution.ts +0 -1163
  109. package/src/pi/runtime-adapter/index.ts +0 -293
  110. package/src/pi/runtime-adapter/recovery.ts +0 -982
  111. package/src/pi/session/index.ts +0 -24
  112. package/src/pi/tool/compiler.ts +0 -483
  113. package/src/pi/tool/index.ts +0 -38
  114. package/src/pi/turn/approval.ts +0 -181
  115. package/src/pi/turn/index.ts +0 -82
  116. package/src/pi/turn/interaction.ts +0 -181
  117. package/src/runtime.ts +0 -4760
  118. /package/src/{kernel/runtime-assembly-view.ts → config/assembly-view.ts} +0 -0
  119. /package/src/{kernel → config}/degradation.ts +0 -0
  120. /package/src/{kernel → config}/runtime-config.ts +0 -0
  121. /package/src/{kernel/extensions.ts → context/extension/contracts.ts} +0 -0
  122. /package/src/{db/ext-context.repo.ts → context/extension/repository.ts} +0 -0
  123. /package/src/{pi/runtime-adapter/openrouter-messages.ts → models/openrouter/messages.ts} +0 -0
  124. /package/src/{pi/runtime-adapter → models/openrouter}/test-fixtures/openrouter-tool-search.sse +0 -0
  125. /package/src/{db/submission-admission.repo.ts → submission/admission-repository.ts} +0 -0
  126. /package/src/{db/telemetry-outbox.repo.ts → telemetry/repository.ts} +0 -0
  127. /package/src/{db/runtime-event-outbox.repo.ts → telemetry/runtime-event-repository.ts} +0 -0
  128. /package/src/{pi/tool → tools}/declared.ts +0 -0
  129. /package/src/{db/interaction.repo.ts → tools/interaction/repository.ts} +0 -0
  130. /package/src/{pi/tool → tools}/nested-tools.ts +0 -0
  131. /package/src/{kernel/submission-authority.ts → tools/subagent-authority.ts} +0 -0
  132. /package/src/{db/agent-tool.repo.ts → tools/subagent-run-repository.ts} +0 -0
  133. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/contract.ts +0 -0
  134. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/extract/index.ts +0 -0
  135. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/fanout/index.ts +0 -0
  136. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/registry.ts +0 -0
  137. /package/src/{pi/tool → tools}/time.ts +0 -0
  138. /package/src/{pi/tool → tools}/web-fetch.ts +0 -0
  139. /package/src/{pi/tool → tools}/web-search/index.ts +0 -0
  140. /package/src/{pi/tool → tools}/web-search/web-search.ts +0 -0
  141. /package/src/{workspace-versioning.ts → tools/workspace-versioning.ts} +0 -0
@@ -2,7 +2,7 @@ import { TurnQueue } from "agents/chat";
2
2
  import {
3
3
  isTerminalSubmissionStatus,
4
4
  type SubmissionStatus,
5
- } from "../db/submission.repo";
5
+ } from "./repository";
6
6
 
7
7
  /**
8
8
  * 本文件统一管理一次提交从接收到结束的过程。
@@ -130,10 +130,9 @@ export type SubmissionOutcome = "succeeded" | "failed" | "aborted";
130
130
  * @remarks
131
131
  * `SubmissionLifecycle` 把它交给 `execute`,Runtime 据此选择执行前置动作。
132
132
  *
133
- * `recovery` 和 `continuation` 必须分开:前者的内存状态已经丢了、transcript 可能停在半路,
134
- * 要走完整的里程碑重放;后者是本轮主动让出执行片,transcript 完好、什么都不用重建。
135
- * 合成一个布尔量意味着最常跑的那条路径每次都去趟最脆弱的恢复代码,
136
- * 而且重放成本会随让步次数增长。
133
+ * `recovery` 和 `continuation` 必须分开:前者的进程内配置已经丢失,需重新装配原 runtime key 再打开 Session;
134
+ * 后者是本轮主动让出执行片,持久 operation 和已固定配置保持完好。
135
+ * 区分两者可避免主动让出时重复加载原 runtime key。
137
136
  */
138
137
  export type SubmissionExecutionMode =
139
138
  /** 首次执行一条新接收的提交。 */
@@ -156,6 +155,8 @@ export interface SubmissionInput<
156
155
  > {
157
156
  requestId: string;
158
157
  idempotencyKey?: string;
158
+ /** Only a repository-validated accepted Steer may transfer despite a full new-work queue. */
159
+ isSteerHandoff?: true;
159
160
  /**
160
161
  * 准备接收所需数据,并返回同步创建记录的函数。
161
162
  *
@@ -202,10 +203,6 @@ interface SubmissionLifecycleOptions<
202
203
  submissionId: string,
203
204
  mode: SubmissionExecutionMode,
204
205
  ): Promise<TSubmission>;
205
- // 作用:记录可恢复的取消意图。
206
- // 调用:`cancel` 在同一存储事务内与取消原因一起写入。
207
- // 原因:持久化里程碑可以让恢复路径看到已经发生的取消。
208
- appendAbortIntent(submission: TSubmission, reason: string): void;
209
206
  // 作用:把一条非终态提交写成最终结果。
210
207
  // 调用:`finish` 在重读并确认仍非终态后调用。
211
208
  // 原因:状态映射和其他终态副作用必须由 Runtime 在一个边界完成。
@@ -217,7 +214,9 @@ interface SubmissionLifecycleOptions<
217
214
  // 作用:通知当前执行器中断运行。
218
215
  // 调用:`cancel` 命中活动执行器,或 `activate` 发现早到取消时调用。
219
216
  // 原因:生命周期不依赖具体执行器类型,只通过这个回调中断。
220
- abortActive(active: TActive): void;
217
+ abortActive(active: TActive): void | Promise<void>;
218
+ /** Close a persisted operation after its process-local executor has parked. */
219
+ abortDormant(submission: TSubmission): void | Promise<void>;
221
220
  /**
222
221
  * 中断活动执行器,但让这条提交留给持久续跑接手。
223
222
  *
@@ -244,6 +243,7 @@ export class SubmissionLifecycle<
244
243
  private readonly queue = new TurnQueue();
245
244
  private readonly executions = new Map<string, Promise<TSubmission>>();
246
245
  private readonly activeBySubmission = new Map<string, TActive>();
246
+ private readonly terminalCommits = new Map<string, Promise<TSubmission>>();
247
247
 
248
248
  // #region 提交与恢复
249
249
 
@@ -322,10 +322,10 @@ export class SubmissionLifecycle<
322
322
  * 等当前执行片退出后,接着跑同一条非终态 Submission。
323
323
  *
324
324
  * @remarks
325
- * `_piPlannedContinuation` 在计划让步的唤醒里调用。先 await 当前执行片,
325
+ * `_piHarnessContinuation` 在计划让步或 Provider 等待的唤醒里调用。先 await 当前执行片,
326
326
  * 是为了避开 `executions` 的实例内去重 —— 否则这次唤醒会被合流到正在退场的那一片上。
327
327
  *
328
- * 走 `continuation` 而不是 `recovery`:这一轮什么都没丢,不需要重放里程碑。
328
+ * 走 `continuation` 而不是 `recovery`:这一轮什么都没丢,只需重新 drive 同一个持久 operation。
329
329
  */
330
330
  async continueAfterCurrent(submissionId: string): Promise<TSubmission> {
331
331
  const current = this.executions.get(submissionId);
@@ -382,16 +382,15 @@ export class SubmissionLifecycle<
382
382
  rejected: false,
383
383
  };
384
384
  }
385
- if (
386
- this.options.store.countPending() >=
387
- MAX_PENDING_SUBMISSIONS
385
+ if (!input.isSteerHandoff &&
386
+ this.options.store.countPending() >= MAX_PENDING_SUBMISSIONS
388
387
  ) {
389
388
  throw new SubmissionQueueFullError();
390
389
  }
391
390
  const submission = create();
392
391
  return {
393
392
  submission,
394
- admitted: submission.admissionRejected !== true,
393
+ admitted: !isTerminalSubmissionStatus(submission.status),
395
394
  rejected: submission.admissionRejected === true,
396
395
  };
397
396
  });
@@ -520,17 +519,27 @@ export class SubmissionLifecycle<
520
519
  *
521
520
  * 写入前重读最新记录并保留已有终态,避免较晚到达的结果覆盖先完成的结果。
522
521
  */
523
- async finish(
522
+ finish(
524
523
  submission: TSubmission,
525
524
  outcome: SubmissionOutcome,
526
525
  message?: string,
527
526
  ): Promise<TSubmission> {
527
+ const existing = this.terminalCommits.get(submission.submissionId);
528
+ if (existing) return existing;
528
529
  const latest = this.options.store.find(submission.submissionId);
529
530
  if (!latest) {
530
- throw new Error(`Unknown SpringBrand submission: ${submission.submissionId}`);
531
+ return Promise.reject(
532
+ new Error(`Unknown SpringBrand submission: ${submission.submissionId}`),
533
+ );
531
534
  }
532
- if (isTerminalSubmissionStatus(latest.status)) return latest;
533
- return this.options.commitTerminal(latest, outcome, message);
535
+ if (isTerminalSubmissionStatus(latest.status)) return Promise.resolve(latest);
536
+ const commit = this.options.commitTerminal(latest, outcome, message);
537
+ this.terminalCommits.set(submission.submissionId, commit);
538
+ void commit.then(
539
+ () => this.terminalCommits.delete(submission.submissionId),
540
+ () => this.terminalCommits.delete(submission.submissionId),
541
+ );
542
+ return commit;
534
543
  }
535
544
 
536
545
  /**
@@ -648,14 +657,16 @@ export class SubmissionLifecycle<
648
657
  if (!submission || isTerminalSubmissionStatus(submission.status)) {
649
658
  return { ok: false };
650
659
  }
651
- this.options.store.transaction(() => {
652
- this.options.store.updateAbortReason(submissionId, reason);
653
- this.options.appendAbortIntent(submission, reason);
654
- });
660
+ this.options.store.transaction(() =>
661
+ this.options.store.updateAbortReason(submissionId, reason)
662
+ );
655
663
  const active = this.activeBySubmission.get(submissionId);
656
- if (active) this.options.abortActive(active);
664
+ if (active) await this.options.abortActive(active);
665
+ else if (submission.status === "running") {
666
+ await this.options.abortDormant(submission);
667
+ }
657
668
  await this.finish(submission, "aborted", reason);
658
- if (!active) this.pump();
669
+ this.pump();
659
670
  return { ok: true };
660
671
  }
661
672
 
@@ -699,12 +710,14 @@ export class SubmissionLifecycle<
699
710
  this.options.store.transaction(() => {
700
711
  for (const submission of submissions) {
701
712
  this.options.store.updateAbortReason(submission.submissionId, reason);
702
- this.options.appendAbortIntent(submission, reason);
703
713
  }
704
714
  });
705
715
  for (const submission of submissions) {
706
716
  const active = this.activeBySubmission.get(submission.submissionId);
707
- if (active) this.options.abortActive(active);
717
+ if (active) await this.options.abortActive(active);
718
+ else if (submission.status === "running") {
719
+ await this.options.abortDormant(submission);
720
+ }
708
721
  await this.finish(submission, "aborted", reason);
709
722
  }
710
723
  this.pump();
@@ -1,4 +1,5 @@
1
1
  import type { SqlTaggedTemplate } from "agents/chat";
2
+ import type { LaneSnapshot } from "@earendil-works/pi-agent-core";
2
3
 
3
4
  // Submission 的统一定义见 ./index.ts。
4
5
  // #region 状态与行类型
@@ -9,11 +10,6 @@ export type SubmissionStatus =
9
10
  | "aborted"
10
11
  | "skipped"
11
12
  | "error";
12
- export type SubmissionRecoveryReason =
13
- | "no_meaningful_model_progress"
14
- | "server_tool_timeout"
15
- | "transient_model_error"
16
- | "context_overflow";
17
13
 
18
14
  const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
19
15
  "completed",
@@ -23,7 +19,7 @@ const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
23
19
  ]);
24
20
 
25
21
  // 判断 Submission 是否已经结束。
26
- // Runtime、SubmissionLifecycle 和 ApprovalLifecycle 在决定是否继续执行、等待或接受审批时调用。
22
+ // Runtime、SubmissionLifecycle 和 HarnessDecisionGate 在决定是否继续执行、等待或接受审批时调用。
27
23
  // 终态集合收口在一处防止各生命周期的判断漂移;增加状态时必须先确认它是否真的不可再继续。
28
24
  export function isTerminalSubmissionStatus(
29
25
  status: SubmissionStatus,
@@ -42,20 +38,21 @@ export interface StoredSubmission {
42
38
  completedAt: number | null;
43
39
  assemblyRevision: string;
44
40
  assemblyDescriptor: string;
41
+ runtimeKey: string;
45
42
  assistantMessageId: string;
46
43
  abortReason: string | null;
47
44
  queuedInputJson: string | null;
48
45
  queuedUiMessageJson: string | null;
49
46
  userMessageId: string | null;
50
47
  regenerateMessageId: string | null;
51
- recoveryErrorCount: number;
52
- recoveryReason: SubmissionRecoveryReason | null;
53
48
  runId: string | null;
54
49
  retryable?: boolean;
55
- /** 跨执行片累计的模型回合数,全局回合预算的唯一事实来源。 */
56
- modelTurns: number;
57
- /** 当前让步执行片的标识;没有未完成让步时为 null。 */
58
- continuationId: string | null;
50
+ }
51
+
52
+ /** Reuse durable stop intent when transferring work out of the source Submission. */
53
+ export function submissionAbortReason(submission: StoredSubmission | null): string | undefined {
54
+ return submission?.abortReason ||
55
+ (submission?.status === "aborted" ? submission.error || "Cancelled" : undefined);
59
56
  }
60
57
 
61
58
  export interface NewSubmission {
@@ -65,6 +62,7 @@ export interface NewSubmission {
65
62
  createdAt: number;
66
63
  assemblyRevision: string;
67
64
  assemblyDescriptor: string;
65
+ runtimeKey?: string;
68
66
  assistantMessageId: string;
69
67
  queuedInputJson?: string | null;
70
68
  queuedUiMessageJson?: string | null;
@@ -84,18 +82,15 @@ type SubmissionRow = {
84
82
  completed_at: number | null;
85
83
  assembly_revision: string;
86
84
  assembly_descriptor: string;
85
+ runtime_key: string;
87
86
  assistant_message_id: string;
88
87
  abort_reason: string | null;
89
88
  queued_input_json: string | null;
90
89
  queued_ui_message_json: string | null;
91
90
  user_message_id: string | null;
92
91
  regenerate_message_id: string | null;
93
- recovery_error_count: number;
94
- recovery_reason: string | null;
95
92
  run_id: string | null;
96
93
  admission_retryable: number | null;
97
- model_turns: number;
98
- continuation_id: string | null;
99
94
  };
100
95
 
101
96
  // #endregion
@@ -115,20 +110,17 @@ function mapRow(row: SubmissionRow): StoredSubmission {
115
110
  completedAt: row.completed_at,
116
111
  assemblyRevision: row.assembly_revision,
117
112
  assemblyDescriptor: row.assembly_descriptor,
113
+ runtimeKey: row.runtime_key,
118
114
  assistantMessageId: row.assistant_message_id,
119
115
  abortReason: row.abort_reason,
120
116
  queuedInputJson: row.queued_input_json,
121
117
  queuedUiMessageJson: row.queued_ui_message_json,
122
118
  userMessageId: row.user_message_id,
123
119
  regenerateMessageId: row.regenerate_message_id,
124
- recoveryErrorCount: row.recovery_error_count,
125
- recoveryReason: row.recovery_reason as SubmissionRecoveryReason | null,
126
120
  runId: row.run_id,
127
121
  retryable: row.admission_retryable === null
128
122
  ? undefined
129
123
  : row.admission_retryable === 1,
130
- modelTurns: row.model_turns ?? 0,
131
- continuationId: row.continuation_id,
132
124
  };
133
125
  }
134
126
 
@@ -138,7 +130,14 @@ export class SubmissionRepository {
138
130
  // 保存当前 Agent SQLite 的查询入口。
139
131
  // RuntimeDatabase 构造时调用,SubmissionLifecycle 和 Runtime 随后共享这个 Repository。
140
132
  // Submission 的准入、执行和恢复必须看同一 Agent 存储,不能混入其他 Durable Object 的 sql。
141
- constructor(private readonly sql: SqlTaggedTemplate) {}
133
+ constructor(
134
+ private readonly sql: SqlTaggedTemplate,
135
+ private readonly transact: <T>(run: () => T) => T,
136
+ ) {}
137
+
138
+ transaction<T>(run: () => T): T {
139
+ return this.transact(run);
140
+ }
142
141
 
143
142
  // 按 submissionId 读取一条完整 Submission,不存在时返回 null。
144
143
  // Runtime 和 SubmissionLifecycle 在准入、执行、取消、结束与恢复时调用,调用方必须处理 null。
@@ -147,12 +146,11 @@ export class SubmissionRepository {
147
146
  const row = this.sql<SubmissionRow>`
148
147
  SELECT submission_id, request_id, idempotency_key, status, accepted,
149
148
  error, created_at, completed_at, assembly_revision,
150
- assembly_descriptor, assistant_message_id, abort_reason,
149
+ assembly_descriptor, runtime_key, assistant_message_id, abort_reason,
151
150
  queued_input_json, queued_ui_message_json, user_message_id,
152
- regenerate_message_id, recovery_error_count, recovery_reason,
151
+ regenerate_message_id,
153
152
  run_id,
154
- admission_retryable,
155
- model_turns, continuation_id
153
+ admission_retryable
156
154
  FROM pi_submissions
157
155
  WHERE submission_id = ${id}
158
156
  `[0];
@@ -250,7 +248,7 @@ export class SubmissionRepository {
250
248
  this.sql`
251
249
  INSERT INTO pi_submissions (
252
250
  submission_id, request_id, idempotency_key, status, accepted,
253
- created_at, assembly_revision, assembly_descriptor,
251
+ created_at, assembly_revision, assembly_descriptor, runtime_key,
254
252
  assistant_message_id, queued_input_json, queued_ui_message_json,
255
253
  user_message_id, regenerate_message_id,
256
254
  run_id
@@ -263,6 +261,7 @@ export class SubmissionRepository {
263
261
  ${s.createdAt},
264
262
  ${s.assemblyRevision},
265
263
  ${s.assemblyDescriptor},
264
+ ${s.runtimeKey ?? ""},
266
265
  ${s.assistantMessageId},
267
266
  ${s.queuedInputJson ?? null},
268
267
  ${s.queuedUiMessageJson ?? null},
@@ -280,17 +279,19 @@ export class SubmissionRepository {
280
279
  createdAt: number;
281
280
  code: string;
282
281
  retryable: boolean;
282
+ userMessageId?: string;
283
+ assistantMessageId?: string;
283
284
  }): void {
284
285
  this.sql`
285
286
  INSERT INTO pi_submissions (
286
287
  submission_id, request_id, idempotency_key, status, accepted,
287
288
  error, created_at, completed_at, assembly_revision,
288
- assembly_descriptor, assistant_message_id, abort_reason,
289
- recovery_error_count, recovery_reason, run_id, admission_retryable
289
+ assembly_descriptor, runtime_key, assistant_message_id, abort_reason,
290
+ run_id, admission_retryable, user_message_id
290
291
  ) VALUES (
291
292
  ${s.submissionId}, ${s.requestId}, ${s.idempotencyKey}, 'skipped', 0,
292
- ${s.code}, ${s.createdAt}, ${s.createdAt}, '', '', '', NULL,
293
- 0, NULL, ${s.submissionId}, ${s.retryable ? 1 : 0}
293
+ ${s.code}, ${s.createdAt}, ${s.createdAt}, '', '', '', ${s.assistantMessageId ?? s.submissionId}, NULL,
294
+ ${s.submissionId}, ${s.retryable ? 1 : 0}, ${s.userMessageId ?? null}
294
295
  )
295
296
  `;
296
297
  }
@@ -309,24 +310,6 @@ export class SubmissionRepository {
309
310
  return updated.length === 1;
310
311
  }
311
312
 
312
- // 把一条已 pin 的 Submission 换到另一对 revision / descriptor 上。
313
- // Runtime 在「停在人机等待期间换了装配」之后调用,必须与那次装配迁移里程碑同事务。
314
- // 与 pinAssembly 的区别就是它要求已经 pin 过:首次 pin 走 pinAssembly,
315
- // 这里不接受空值,避免把一次漏写的准入 pin 伪装成一次迁移。
316
- repinAssembly(id: string, revision: string, descriptor: string): boolean {
317
- const updated = this.sql<{ submission_id: string }>`
318
- UPDATE pi_submissions
319
- SET assembly_revision = ${revision},
320
- assembly_descriptor = ${descriptor}
321
- WHERE submission_id = ${id}
322
- AND assembly_revision <> ''
323
- AND assembly_descriptor <> ''
324
- AND status IN ('pending', 'running')
325
- RETURNING submission_id
326
- `;
327
- return updated.length === 1;
328
- }
329
-
330
313
  // 只在当前状态属于 from 时把 Submission 切换到 to,否则返回 false。
331
314
  // SubmissionLifecycle 开始 Turn 时调用,调用方用返回值识别过期或重复的状态切换。
332
315
  // Cloudflare SQLite 查询是同步的,两条语句之间不会让其他事件交错;UPDATE 再次带旧状态条件,不能去掉这层幂等保护。
@@ -393,84 +376,44 @@ export class SubmissionRepository {
393
376
  `;
394
377
  }
395
378
 
396
- // chatRecovery 遇到 partial 会重置 attempt;Submission 计数保证同一 Turn 的可恢复模型错误仍绝对封顶。
397
- incrementRecoveryErrorCount(
398
- id: string,
399
- reason: SubmissionRecoveryReason,
400
- ): number {
401
- return this.sql<{ recovery_error_count: number }>`
402
- UPDATE pi_submissions
403
- SET recovery_error_count = recovery_error_count + 1,
404
- recovery_reason = ${reason}
405
- WHERE submission_id = ${id}
406
- AND status IN ('pending', 'running')
407
- RETURNING recovery_error_count
408
- `[0]?.recovery_error_count ?? 0;
409
- }
410
-
411
- claimContextOverflowRecovery(id: string): boolean {
412
- return Boolean(this.sql<{ claimed: number }>`
413
- UPDATE pi_submissions
414
- SET context_overflow_retried = 1
415
- WHERE submission_id = ${id}
416
- AND status IN ('pending', 'running')
417
- AND context_overflow_retried = 0
418
- RETURNING 1 AS claimed
419
- `[0]?.claimed);
420
- }
421
-
422
- /**
423
- * 记录一个让步执行片的累计模型回合数和续跑标识。
424
- *
425
- * @remarks
426
- * Runtime 在一片主动让出后、排出续跑调度之前调用。
427
- *
428
- * 两个值必须一起写:全局回合预算靠 `model_turns` 拦住不收敛的工具循环,
429
- * 续跑去重和看门狗靠 `continuation_id` 判断这一轮到底有没有推进。
430
- * 只写非终态记录,避免一次迟到的写入复活已经结束的 Submission。
431
- */
432
- recordYieldedSlice(
433
- id: string,
434
- modelTurns: number,
435
- continuationId: string,
436
- ): void {
437
- this.sql`
438
- UPDATE pi_submissions
439
- SET model_turns = ${modelTurns},
440
- continuation_id = ${continuationId}
441
- WHERE submission_id = ${id}
442
- AND status IN ('pending', 'running')
443
- `;
444
- }
445
-
446
- /** 记录累计模型回合数,不改动续跑标识。终态提交前的最后一次结算用它。 */
447
- recordModelTurns(id: string, modelTurns: number): void {
379
+ // 持久化 Session entry 到 Submission 的关联,使 Host 可以从 Pi 分支
380
+ // 回填 message、ToolResult 和 compaction 的用量与结算 Outbox。
381
+ linkSessionEntry(entryId: string, submissionId: string): void {
448
382
  this.sql`
449
- UPDATE pi_submissions
450
- SET model_turns = ${modelTurns}
451
- WHERE submission_id = ${id}
452
- AND status IN ('pending', 'running')
383
+ INSERT OR IGNORE INTO pi_session_entry_submission
384
+ (entry_id, submission_id)
385
+ VALUES (${entryId}, ${submissionId})
453
386
  `;
454
387
  }
455
388
 
456
- clearRecoveryReason(id: string): void {
457
- this.sql`
458
- UPDATE pi_submissions
459
- SET recovery_reason = NULL
460
- WHERE submission_id = ${id}
461
- AND status IN ('pending', 'running')
462
- `;
389
+ /** Recover ownership from the matching Pi operation, using only its active branch. */
390
+ associateSessionEntries(
391
+ submissionId: string,
392
+ snapshot: Pick<LaneSnapshot, "operation" | "lastResult" | "tipId" | "transcript">,
393
+ ): void {
394
+ const boundary = snapshot.operation?.id === submissionId
395
+ ? { fromTipId: snapshot.operation.fromTipId, tipId: snapshot.tipId }
396
+ : snapshot.lastResult?.operationId === submissionId
397
+ ? snapshot.lastResult
398
+ : undefined;
399
+ if (!boundary) return;
400
+ const start = boundary.fromTipId === null
401
+ ? 0
402
+ : snapshot.transcript.findIndex(({ id }) => id === boundary.fromTipId) + 1;
403
+ const end = boundary.tipId === null
404
+ ? snapshot.transcript.length
405
+ : snapshot.transcript.findIndex(({ id }) => id === boundary.tipId) + 1;
406
+ for (const entry of snapshot.transcript.slice(start, end || undefined)) {
407
+ this.linkSessionEntry(entry.id, submissionId);
408
+ }
463
409
  }
464
410
 
465
- // 返回所有标准会话消息到 Submission 的关联映射。
466
- // Transcript.storedMessages 读取 Pi Session 分支时调用,它用 Map 为每条消息补回 submissionId。
467
- // 关联表由 Pi Session 存储拥有,本方法只读不写;不能在 RuntimeDatabase.clearAll 中单独删它而破坏 Transcript 的所有权。
468
- listMessageSubmissionLinks(): Map<string, string> {
411
+ listSessionEntrySubmissionLinks(): Map<string, string> {
469
412
  const rows = this.sql<{
470
- message_id: string;
413
+ entry_id: string;
471
414
  submission_id: string;
472
- }>`SELECT message_id, submission_id FROM pi_session_message_submission`;
473
- return new Map(rows.map((r) => [r.message_id, r.submission_id] as [string, string]));
415
+ }>`SELECT entry_id, submission_id FROM pi_session_entry_submission`;
416
+ return new Map(rows.map((r) => [r.entry_id, r.submission_id] as [string, string]));
474
417
  }
475
418
  }
476
419
  // #endregion
@@ -0,0 +1,167 @@
1
+ import type { CancelQueuedResult, LaneSnapshot } from "@earendil-works/pi-agent-core";
2
+ import type { UIMessage } from "ai";
3
+ import type { ConversationHistory } from "../conversation/history";
4
+ import type { SubmitMessageOptions } from "./admission";
5
+ import type { SubmissionHandle } from "./lifecycle";
6
+ import { submissionAbortReason, type StoredSubmission, type SubmissionRepository } from "./repository";
7
+ import type { PiCanonicalUserInput } from "../conversation/message/contract";
8
+ import type { MessageUiRepository } from "../conversation/repository";
9
+ import type { PendingSteer, SteerRepository } from "./steer-repository";
10
+
11
+ interface PendingSteerDeliveryOptions {
12
+ steers: SteerRepository;
13
+ messageUi: MessageUiRepository;
14
+ transaction<T>(run: () => T): T;
15
+ enqueue(message: PiCanonicalUserInput): Promise<string>;
16
+ }
17
+
18
+ /** Reconciles Host steer receipts with Pi queue identities, without owning execution. */
19
+ export class PendingSteerDelivery {
20
+ constructor(private readonly options: PendingSteerDeliveryOptions) {}
21
+
22
+ async queue(steer: PendingSteer): Promise<void> {
23
+ const entryId = await this.options.enqueue(
24
+ JSON.parse(steer.canonicalJson) as PiCanonicalUserInput,
25
+ );
26
+ this.link(steer, entryId);
27
+ }
28
+
29
+ /** Called after Pi commits the original queued user entry. */
30
+ settle(entryId: string): boolean {
31
+ const steer = this.options.steers.findByHarnessEntryId(entryId);
32
+ if (!steer) return false;
33
+ this.options.transaction(() => {
34
+ this.persistSidecar(steer, entryId);
35
+ this.options.steers.deleteByMessageId(steer.messageId);
36
+ });
37
+ return true;
38
+ }
39
+
40
+ async reconcile(
41
+ submissionId: string,
42
+ snapshot: Pick<LaneSnapshot, "queues" | "transcript">,
43
+ ): Promise<void> {
44
+ const pending = this.options.steers.listForSubmission(submissionId);
45
+ const queuedIds = new Set(snapshot.queues.map(({ entryId }) => entryId));
46
+ const transcriptIds = new Set(snapshot.transcript.map(({ id }) => id));
47
+ const linkedIds = new Set(pending.flatMap(({ harnessEntryId }) =>
48
+ harnessEntryId ? [harnessEntryId] : []
49
+ ));
50
+ const unlinked = snapshot.queues.filter((entry) =>
51
+ entry.kind === "steer" && entry.type === "message" &&
52
+ !linkedIds.has(entry.entryId)
53
+ );
54
+ // Both durable queues preserve acceptance order; identical text is not identity.
55
+ for (const steer of pending) {
56
+ if (steer.harnessEntryId && transcriptIds.has(steer.harnessEntryId)) {
57
+ this.settle(steer.harnessEntryId);
58
+ } else if (steer.harnessEntryId && queuedIds.has(steer.harnessEntryId)) {
59
+ this.options.transaction(() =>
60
+ this.persistSidecar(steer, steer.harnessEntryId!)
61
+ );
62
+ } else {
63
+ const queued = !steer.harnessEntryId ? unlinked.shift() : undefined;
64
+ if (queued) this.link(steer, queued.entryId);
65
+ else await this.queue(steer);
66
+ }
67
+ }
68
+ }
69
+
70
+ private link(steer: PendingSteer, entryId: string): void {
71
+ this.options.transaction(() => {
72
+ if (steer.harnessEntryId && steer.harnessEntryId !== entryId) {
73
+ this.options.messageUi.delete(steer.harnessEntryId);
74
+ }
75
+ this.options.steers.linkHarnessEntry(steer.messageId, entryId);
76
+ this.persistSidecar(steer, entryId);
77
+ });
78
+ }
79
+
80
+ private persistSidecar(steer: PendingSteer, entryId: string): void {
81
+ if (steer.uiMessageJson) {
82
+ this.options.messageUi.upsert(entryId, steer.uiMessageJson);
83
+ }
84
+ }
85
+ }
86
+
87
+
88
+ /** Keep accepted steers visible on stop, or transfer unconsumed messages to new Submissions. */
89
+ export async function finishPendingSteers(options: {
90
+ submissionId: string;
91
+ aborted: boolean;
92
+ submissions: SubmissionRepository;
93
+ cancelHandoff(submissionId: string, reason: string): StoredSubmission;
94
+ steers: SteerRepository;
95
+ messageUi: MessageUiRepository;
96
+ transaction<T>(run: () => T): T;
97
+ conversation: Pick<ConversationHistory, "append">;
98
+ cancelQueued(entryId: string): Promise<CancelQueuedResult | undefined>;
99
+ submit(
100
+ message: PiCanonicalUserInput,
101
+ options: SubmitMessageOptions,
102
+ ): Promise<SubmissionHandle<StoredSubmission>>;
103
+ waitUntil(promise: Promise<unknown>): void;
104
+ }): Promise<void> {
105
+ for (const steer of options.steers.listForSubmission(options.submissionId)) {
106
+ const message = JSON.parse(steer.canonicalJson) as PiCanonicalUserInput;
107
+ const userMessage = steer.uiMessageJson
108
+ ? JSON.parse(steer.uiMessageJson) as UIMessage & { role: "user" }
109
+ : undefined;
110
+ const requestId = `${options.submissionId}:steer:${steer.steerId}`;
111
+ if (options.aborted) {
112
+ options.transaction(() => {
113
+ let handoff = options.submissions.findByRequestId(requestId);
114
+ if (handoff?.status === "pending") {
115
+ const reason = submissionAbortReason(options.submissions.find(options.submissionId)) ?? "Cancelled";
116
+ options.submissions.updateAbortReason(handoff.submissionId, reason);
117
+ handoff = options.cancelHandoff(handoff.submissionId, reason);
118
+ }
119
+ if (steer.harnessEntryId) options.messageUi.delete(steer.harnessEntryId);
120
+ options.conversation.append(steer.messageId, message, {
121
+ submissionId: handoff?.status === "aborted" || handoff?.status === "skipped"
122
+ ? handoff.submissionId
123
+ : options.submissionId,
124
+ ...(userMessage ? { userMessage } : {}),
125
+ });
126
+ options.steers.deleteByMessageId(steer.messageId);
127
+ });
128
+ continue;
129
+ }
130
+ if (steer.harnessEntryId) {
131
+ const cancelled = await options.cancelQueued(steer.harnessEntryId);
132
+ if (cancelled) {
133
+ if (!cancelled.ok) throw cancelled.error;
134
+ if (cancelled.value.kind === "already_consumed") {
135
+ options.transaction(() => options.steers.deleteByMessageId(steer.messageId));
136
+ continue;
137
+ }
138
+ options.transaction(() => options.messageUi.delete(steer.harnessEntryId!));
139
+ }
140
+ }
141
+ // A queued Submission moved to this Turn already owns messageId and is aborted.
142
+ // Handoff needs its own stable identity while preserving the user's message identity.
143
+ const submitted = await options.submit(message, {
144
+ requestId,
145
+ idempotencyKey: requestId,
146
+ userMessageId: steer.messageId,
147
+ steerHandoff: { submissionId: options.submissionId, steerId: steer.steerId },
148
+ ...(userMessage ? { userMessage } : {}),
149
+ });
150
+ options.waitUntil(submitted.completion);
151
+ options.transaction(() => {
152
+ let handoff = options.submissions.find(submitted.receipt.submissionId) ?? submitted.receipt;
153
+ const abortReason = submissionAbortReason(options.submissions.find(options.submissionId));
154
+ if (abortReason && handoff.status === "pending" && handoff.requestId === requestId) {
155
+ options.submissions.updateAbortReason(handoff.submissionId, abortReason);
156
+ handoff = options.cancelHandoff(handoff.submissionId, abortReason);
157
+ }
158
+ if (handoff.status === "skipped" || handoff.status === "aborted") {
159
+ options.conversation.append(steer.messageId, message, {
160
+ submissionId: handoff.submissionId,
161
+ ...(userMessage ? { userMessage } : {}),
162
+ });
163
+ }
164
+ options.steers.deleteByMessageId(steer.messageId);
165
+ });
166
+ }
167
+ }