@springbrand/agent-runtime 0.1.3-alpha.2 → 0.1.3-alpha.4

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/db/agent-tool.repo.ts +27 -0
  3. package/src/db/index.ts +33 -0
  4. package/src/db/interaction.repo.ts +185 -0
  5. package/src/db/schema.ts +15 -0
  6. package/src/db/submission.repo.ts +29 -0
  7. package/src/index.ts +8 -17
  8. package/src/kernel/approval-lifecycle.ts +41 -6
  9. package/src/kernel/bindings.ts +37 -0
  10. package/src/kernel/interaction-lifecycle.ts +395 -0
  11. package/src/kernel/public-contracts.ts +2 -0
  12. package/src/kernel/recoverable-chat-agent.ts +10 -2
  13. package/src/kernel/runtime-assembly-view.ts +37 -0
  14. package/src/kernel/runtime-assembly.ts +41 -0
  15. package/src/kernel/runtime-config.ts +4 -0
  16. package/src/kernel/runtime-load.ts +102 -0
  17. package/src/kernel/state.ts +8 -1
  18. package/src/kernel/submission-lifecycle.ts +30 -0
  19. package/src/lib/telemetry-dev.ts +7 -4
  20. package/src/pi/runtime-adapter/assembly.ts +13 -1
  21. package/src/pi/runtime-adapter/execution.ts +109 -9
  22. package/src/pi/runtime-adapter/index.ts +10 -3
  23. package/src/pi/runtime-adapter/models.ts +162 -16
  24. package/src/pi/runtime-adapter/recovery.ts +188 -1
  25. package/src/pi/tool/base.ts +62 -9
  26. package/src/pi/tool/compiler.ts +34 -0
  27. package/src/pi/tool/gateway.ts +54 -0
  28. package/src/pi/tool/index.ts +1 -0
  29. package/src/pi/tool/mcp.ts +93 -64
  30. package/src/pi/turn/index.ts +20 -0
  31. package/src/pi/turn/interaction.ts +181 -0
  32. package/src/pi/turn/tool-recovery.ts +244 -1
  33. package/src/runtime-agent.ts +246 -113
  34. package/src/{plugins.ts → runtime-assembler.ts} +65 -282
  35. package/src/runtime.ts +454 -162
@@ -0,0 +1,395 @@
1
+ import {
2
+ isTerminalSubmissionStatus,
3
+ type RuntimeDatabase,
4
+ type StoredToolInteraction,
5
+ type SubmissionStatus,
6
+ } from "../db";
7
+ import type {
8
+ PiDurableMutation,
9
+ PiRecoveryCommand,
10
+ PiRecoveryDecision,
11
+ PiRuntimeAdapter,
12
+ PiToolInteraction,
13
+ PiToolInteractionCancelReason,
14
+ } from "../pi/runtime-adapter";
15
+ import type { ToolResultMessage } from "@earendil-works/pi-ai";
16
+
17
+ // #region 类型约定
18
+
19
+ /**
20
+ * 提供 interaction 流程定位一次持久化提交所需的最小信息。
21
+ *
22
+ * @remarks
23
+ * 与 `ApprovalSubmission` 同形。这里只要求 park/续跑真正需要的字段,
24
+ * 避免 interaction 流程依赖完整提交模型。
25
+ */
26
+ export interface InteractionSubmission {
27
+ readonly submissionId: string;
28
+ readonly requestId: string;
29
+ readonly assemblyRevision: string;
30
+ readonly status: SubmissionStatus;
31
+ }
32
+
33
+ /**
34
+ * 表示已经绑定到持久化提交的完整 interaction 记录。
35
+ */
36
+ export interface InteractionRecord extends PiToolInteraction {
37
+ readonly submissionId: string;
38
+ }
39
+
40
+ /**
41
+ * 描述一次响应或取消要写入的 ToolResult 载荷。
42
+ *
43
+ * `content` / `details` 由 Tool 自己的 `settle` 映射产出 —— 本流程不解释响应体语义。
44
+ * 取消不带载荷,由纯函数层用固定文案兜底。
45
+ */
46
+ export interface InteractionSettlementPayload {
47
+ readonly content: ToolResultMessage["content"];
48
+ readonly details: unknown;
49
+ }
50
+
51
+ // 宿主用这些依赖把 interaction 状态机接入数据库、Pi 恢复计算和 Turn 续跑。
52
+ interface InteractionLifecycleOptions<
53
+ TSubmission extends InteractionSubmission,
54
+ > {
55
+ readonly db: RuntimeDatabase;
56
+ readonly pi: PiRuntimeAdapter;
57
+ readonly findSubmission: (submissionId: string) => TSubmission | null;
58
+ readonly applyRecoveryMutations: (
59
+ submission: TSubmission,
60
+ mutations: readonly PiDurableMutation[],
61
+ ) => boolean;
62
+ readonly materializeRecoveredToolResults: (
63
+ submission: TSubmission,
64
+ ) => Promise<PiRecoveryDecision>;
65
+ /** 没有内存等待器时(DO 睡过一觉)把原 Turn 重新拉起来。 */
66
+ readonly resumeSubmission: (submissionId: string) => Promise<void>;
67
+ /**
68
+ * pending 集合发生变化后通知宿主重算活动状态投影。
69
+ *
70
+ * park 和结算都会触发。宿主的 needs-input 是 pending 行的纯函数,
71
+ * 没有这个回调,park 的那一刻不会有人去重算 —— 侧栏要等下一次广播才翻牌。
72
+ */
73
+ readonly onInteractionsChanged?: () => Promise<void>;
74
+ }
75
+
76
+ // #endregion
77
+
78
+ // #region interaction 流程 API
79
+
80
+ /**
81
+ * 管理「结果由客户端提供」的 Tool 从 park、等待、响应到续跑的完整过程。
82
+ *
83
+ * @remarks
84
+ * 与 `ApprovalLifecycle` 结构同源但显著更小 —— 不需要 `allow_level` 的 Host 协调、
85
+ * 不需要批准后 `retryTool`、不需要执行档位矩阵。两者的重复是**当前有意接受的**:
86
+ * 审批那条路已经跑在线上,等 interaction 跑稳后再考虑抽公共核,届时改的是这两个类,
87
+ * 不是恢复状态机。
88
+ *
89
+ * 续跑不走 interaction 专属的调度键:响应会同时写下一条权威 `tool-result` 里程碑,
90
+ * 因此复用 `planPiToolRecovery` 中既有的 `tool:<id>:settled` 分支。
91
+ */
92
+ export class InteractionLifecycle<
93
+ TSubmission extends InteractionSubmission,
94
+ > {
95
+ private readonly waiters = new Map<string, () => void>();
96
+
97
+ constructor(
98
+ private readonly options: InteractionLifecycleOptions<TSubmission>,
99
+ ) {}
100
+
101
+ /**
102
+ * 用工具调用标识查找唯一一条仍待响应的 interaction。
103
+ *
104
+ * @remarks
105
+ * `Runtime.respondToolInteraction` 调用它 —— 前端只有 `toolCallId`。
106
+ * 调用方必须把 `null` 当作没有安全匹配,不应猜测要处理哪条记录。
107
+ */
108
+ findPending(toolCallId: string): InteractionRecord | null {
109
+ const stored = this.options.db.interactions
110
+ .findPendingByToolCallId(toolCallId);
111
+ return stored ? toRecord(stored) : null;
112
+ }
113
+
114
+ /**
115
+ * 按 interactionId 读取一条完整记录,不存在时返回 null。
116
+ */
117
+ read(interactionId: string): InteractionRecord | null {
118
+ const stored = this.options.db.interactions.find(interactionId);
119
+ return stored ? toRecord(stored) : null;
120
+ }
121
+
122
+ /**
123
+ * 持久化一条 interaction,并暂停当前工具执行直到客户端投递响应。
124
+ *
125
+ * @remarks
126
+ * 执行适配器在 Tool 声明了 `interaction` 时调用它。
127
+ * 记录与恢复里程碑先在事务中落盘,再建立内存等待,保证重启仍能恢复 park 状态。
128
+ */
129
+ async request(
130
+ submission: TSubmission,
131
+ interaction: PiToolInteraction,
132
+ signal?: AbortSignal,
133
+ ): Promise<void> {
134
+ const pending = this.options.db.transaction(() =>
135
+ this.ensurePending(submission, interaction)
136
+ );
137
+ // 必须在 wait 之前通知:wait 会一直挂到客户端投递,之后再通知就晚了一个回合。
138
+ await this.options.onInteractionsChanged?.();
139
+ return this.wait(pending, signal);
140
+ }
141
+
142
+ /**
143
+ * 应用客户端的首次响应,结算对应 Tool 并唤醒原 Turn。
144
+ *
145
+ * @remarks
146
+ * `Runtime.respondToolInteraction` 在校验并映射出 ToolResult 后调用它。
147
+ * 不存在、已结束或提交已终态都返回 `{ ok: false }`,不抛 —— 口径对齐 `ApprovalLifecycle.decide`。
148
+ */
149
+ async respond(
150
+ interactionId: string,
151
+ response: unknown,
152
+ payload: InteractionSettlementPayload,
153
+ ): Promise<{ ok: boolean }> {
154
+ return this.settle(interactionId, {
155
+ kind: "respond",
156
+ response,
157
+ result: payload,
158
+ });
159
+ }
160
+
161
+ /**
162
+ * 在用户没有作答的情况下结束一条 interaction 并让 Turn 继续。
163
+ *
164
+ * @remarks
165
+ * park 期间收到用户消息时调用(`user_replied_freeform`),提交进终态时也调用。
166
+ * 取消同样产出稳定 ToolResult,不是 Tool 失败 —— 否则模型会收到 tool_error 并倾向重试。
167
+ */
168
+ async cancel(
169
+ interactionId: string,
170
+ reason: PiToolInteractionCancelReason,
171
+ ): Promise<{ ok: boolean }> {
172
+ return this.settle(interactionId, { kind: "cancel", reason });
173
+ }
174
+
175
+ /**
176
+ * 把某次提交下仍待响应的全部 interaction 一次性取消。
177
+ *
178
+ * @remarks
179
+ * `dispatchMessage` 发现 park 期间来了用户消息时、以及提交进终态时调用。
180
+ * 返回是否真的取消过记录,调用方据此决定要不要广播。
181
+ */
182
+ async cancelPendingForSubmission(
183
+ submissionId: string,
184
+ reason: PiToolInteractionCancelReason,
185
+ ): Promise<boolean> {
186
+ const pending = this.options.db.interactions
187
+ .listPendingForSubmission(submissionId);
188
+ if (pending.length === 0) return false;
189
+ let cancelled = false;
190
+ for (const interactionId of pending) {
191
+ const result = await this.cancel(interactionId, reason);
192
+ cancelled ||= result.ok;
193
+ }
194
+ return cancelled;
195
+ }
196
+
197
+ /**
198
+ * 在同步终态事务里把某次提交下仍待响应的 interaction 全部取消。
199
+ *
200
+ * @remarks
201
+ * `Runtime.commitTerminalOutcome` 在提交终态的 `transaction` 内调用,因此必须是同步的。
202
+ * 这里只推进状态和里程碑,**不** materialize、**不** 续跑 —— Turn 正在结束,
203
+ * 调用方负责在同一事务内调用 `materializeRecoveredToolResultsSync`,口径对齐审批的 `rejectPending`。
204
+ *
205
+ * 返回是否真的取消过记录;没有 pending 时返回 false,调用方可据此跳过 materialize。
206
+ */
207
+ cancelPendingForSubmissionSync(
208
+ submission: TSubmission,
209
+ reason: PiToolInteractionCancelReason = "submission_terminal",
210
+ ): boolean {
211
+ const pending = this.options.db.interactions
212
+ .listPendingForSubmission(submission.submissionId);
213
+ if (pending.length === 0) return false;
214
+ for (const interactionId of pending) {
215
+ const decision = this.decideRecovery(submission, {
216
+ kind: "interaction-settlement",
217
+ interactionId,
218
+ settlement: { kind: "cancel", reason },
219
+ });
220
+ this.options.applyRecoveryMutations(submission, decision.mutations);
221
+ }
222
+ return true;
223
+ }
224
+
225
+ /**
226
+ * 判断某次提交当前是否停在等待客户端响应上。
227
+ *
228
+ * @remarks
229
+ * `dispatchMessage` 用它决定要不要把 delivery 提升成 steer —— 否则 enqueue 的消息
230
+ * 要等这个 Turn 结束,而这个 Turn 正在等一个不会来的答案,死锁。
231
+ */
232
+ hasPendingForSubmission(submissionId: string): boolean {
233
+ return this.options.db.interactions
234
+ .listPendingForSubmission(submissionId).length > 0;
235
+ }
236
+
237
+ // #endregion
238
+
239
+ // #region 内部
240
+
241
+ // 作用:把响应或取消统一走同一条持久化 + 唤醒路径。
242
+ // 调用:`respond` 和 `cancel`。
243
+ // 原因:两种结局的差别只在 ToolResult 从哪来,状态推进和续跑必须完全一致。
244
+ private async settle(
245
+ interactionId: string,
246
+ settlement:
247
+ | {
248
+ kind: "respond";
249
+ response: unknown;
250
+ result: InteractionSettlementPayload;
251
+ }
252
+ | { kind: "cancel"; reason: PiToolInteractionCancelReason },
253
+ ): Promise<{ ok: boolean }> {
254
+ const stored = this.read(interactionId);
255
+ if (!stored) return { ok: false };
256
+ const submission = this.options.findSubmission(stored.submissionId);
257
+ if (!submission || isTerminalSubmissionStatus(submission.status)) {
258
+ return { ok: false };
259
+ }
260
+ if (stored.status !== "pending") return { ok: false };
261
+
262
+ const decision = this.decideRecovery(submission, {
263
+ kind: "interaction-settlement",
264
+ interactionId,
265
+ settlement,
266
+ });
267
+ if (!decision.applied) return { ok: false };
268
+ this.options.db.transaction(() => {
269
+ this.options.applyRecoveryMutations(submission, decision.mutations);
270
+ });
271
+ const persisted = this.read(interactionId);
272
+ if (!persisted || persisted.status === "pending") {
273
+ return { ok: false };
274
+ }
275
+
276
+ // 把刚写下的权威 tool-result 落成 settlement 行 —— park 住的执行链醒来后
277
+ // 只认 findToolSettlement,不读里程碑。这一步必须早于唤醒。
278
+ await this.options.materializeRecoveredToolResults(submission);
279
+ // 先重算活动状态再唤醒:唤醒会把 Turn 一路跑下去,晚通知就会让「等待回复」
280
+ // 在续跑期间多挂一段时间。
281
+ await this.options.onInteractionsChanged?.();
282
+
283
+ const waiter = this.waiters.get(interactionId);
284
+ if (waiter) {
285
+ waiter();
286
+ return { ok: true };
287
+ }
288
+ // 没有内存等待器 = DO 在 park 期间睡过一觉,原 Turn 的执行栈已经没了。
289
+ // 走持久恢复把它重新拉起来,里程碑里的 tool-result 会被当成已结算结果复用。
290
+ await this.options.resumeSubmission(submission.submissionId);
291
+ return { ok: true };
292
+ }
293
+
294
+ // 作用:读取一个 Submission 的全部恢复里程碑正文。
295
+ // 原因:持久化历史必须作为恢复决策的唯一输入。
296
+ private milestoneBodies(submissionId: string): string[] {
297
+ return this.options.db.milestones.listBodies(submissionId);
298
+ }
299
+
300
+ // 作用:请求 Pi 根据当前里程碑给出下一步。
301
+ // 原因:所有路径共用同一决策入口,避免各自改写恢复状态。
302
+ private decideRecovery(
303
+ submission: TSubmission,
304
+ command: PiRecoveryCommand = { kind: "inspect" },
305
+ ): PiRecoveryDecision {
306
+ return this.options.pi.decideRecovery({
307
+ milestoneBodies: this.milestoneBodies(submission.submissionId),
308
+ identity: {
309
+ turnId: submission.submissionId,
310
+ assemblyRevision: submission.assemblyRevision,
311
+ },
312
+ command,
313
+ now: Date.now(),
314
+ });
315
+ }
316
+
317
+ // 作用:复用或创建一条 pending interaction。
318
+ // 调用:`request` 在准入事务内调用。
319
+ // 原因:已存在记录的字段冲突必须报错,不能把响应投给另一次调用。
320
+ private ensurePending(
321
+ submission: TSubmission,
322
+ interaction: PiToolInteraction,
323
+ ): InteractionRecord {
324
+ const existing = this.read(interaction.interactionId);
325
+ if (existing) {
326
+ if (
327
+ existing.requestId !== submission.requestId ||
328
+ existing.toolCallId !== interaction.toolCallId ||
329
+ existing.toolName !== interaction.toolName ||
330
+ existing.inputJson !== interaction.inputJson
331
+ ) {
332
+ throw new Error(
333
+ `Conflicting durable Tool interaction: ${interaction.toolCallId}`,
334
+ );
335
+ }
336
+ return existing;
337
+ }
338
+ // 表行和恢复里程碑都由 record-interaction 这一条命令产出,
339
+ // 不在这里直接 insert —— 两份状态分叉正是恢复最难查的一类 bug。
340
+ const decision = this.decideRecovery(submission, {
341
+ kind: "record-interaction",
342
+ interaction,
343
+ });
344
+ this.options.applyRecoveryMutations(submission, decision.mutations);
345
+ return { ...interaction, submissionId: submission.submissionId };
346
+ }
347
+
348
+ // 作用:等待当前进程里的客户端响应。
349
+ // 原因:内存 Promise 只负责唤醒当前请求,数据库仍负责持久状态以支持重启。
350
+ private wait(
351
+ interaction: InteractionRecord,
352
+ signal?: AbortSignal,
353
+ ): Promise<void> {
354
+ if (interaction.status !== "pending") return Promise.resolve();
355
+ return new Promise((resolve, reject) => {
356
+ const abort = () => {
357
+ this.waiters.delete(interaction.interactionId);
358
+ reject(signal?.reason ?? new Error("Tool interaction aborted"));
359
+ };
360
+ if (signal?.aborted) {
361
+ abort();
362
+ return;
363
+ }
364
+ signal?.addEventListener("abort", abort, { once: true });
365
+ this.waiters.set(interaction.interactionId, () => {
366
+ signal?.removeEventListener("abort", abort);
367
+ this.waiters.delete(interaction.interactionId);
368
+ resolve();
369
+ });
370
+ });
371
+ }
372
+
373
+ // #endregion
374
+ }
375
+
376
+ // 把持久行转成流程内部使用的记录。
377
+ // find / findPending 查到记录后调用,上层因而只看 Pi 的 interaction 形状。
378
+ function toRecord(stored: StoredToolInteraction): InteractionRecord {
379
+ return {
380
+ interactionId: stored.interactionId,
381
+ submissionId: stored.submissionId,
382
+ requestId: stored.requestId,
383
+ toolCallId: stored.toolCallId,
384
+ toolName: stored.toolName,
385
+ inputJson: stored.inputJson,
386
+ status: stored.status,
387
+ createdAt: stored.createdAt,
388
+ ...(stored.respondedAt === null
389
+ ? {}
390
+ : { respondedAt: stored.respondedAt }),
391
+ ...(stored.responseJson === null
392
+ ? {}
393
+ : { responseJson: stored.responseJson }),
394
+ };
395
+ }
@@ -11,6 +11,8 @@
11
11
  * @packageDocumentation
12
12
  */
13
13
  export * from "./profile";
14
+ export * from "./runtime-assembly-view";
15
+ export * from "./runtime-config";
14
16
  export * from "../pi/message";
15
17
  export * from "./receipts";
16
18
  export * from "./state";
@@ -465,13 +465,20 @@ export abstract class RecoverableChatAgent<
465
465
  * @remarks
466
466
  * 子类在发送增量记录、成功终态或失败终态时调用;`done` 表示轮次终态,`error` 只在终态失败时设置。
467
467
  *
468
+ * `continuation` 必须与本轮 `runRecoverableChatFiber` 的取值一致。重放帧由
469
+ * `ResumableStream` 从 `is_continuation` 列自动补上这个标记,实时帧只能由调用方给出:
470
+ * 少了它,客户端会为续跑另建一个空的累加器,把已经渲染出来的那半条回答整条换掉。
471
+ *
468
472
  * 广播显式排除正在续传握手的连接,避免它们在历史流重放完成前同时收到实时帧。
469
473
  */
470
474
  protected sendChatResponse(
471
475
  requestId: string,
472
476
  body: string,
473
477
  done: boolean,
474
- error = false,
478
+ options: {
479
+ readonly error?: boolean;
480
+ readonly continuation?: boolean;
481
+ } = {},
475
482
  ): void {
476
483
  this.broadcast(
477
484
  json({
@@ -479,7 +486,8 @@ export abstract class RecoverableChatAgent<
479
486
  id: requestId,
480
487
  body,
481
488
  done,
482
- ...(error ? { error: true } : {}),
489
+ ...(options.error ? { error: true } : {}),
490
+ ...(options.continuation ? { continuation: true } : {}),
483
491
  }),
484
492
  [...this.pendingResumeConnections],
485
493
  );
@@ -0,0 +1,37 @@
1
+ import type { RuntimeDegradation } from "./degradation";
2
+ import type {
3
+ RuntimeMemoryProfile,
4
+ RuntimeMcpServer,
5
+ ThinkingEffort,
6
+ } from "./profile";
7
+ import type { ExecutionLevel } from "../lib/execution-level";
8
+
9
+ export interface RuntimeAssemblySkillBinding {
10
+ readonly name: string;
11
+ readonly description: string;
12
+ readonly script: {
13
+ readonly network: "none" | "full";
14
+ readonly workspace: "none" | "read" | "read-write";
15
+ readonly tools: readonly string[];
16
+ };
17
+ }
18
+
19
+ export interface RuntimeAssemblyExtension {
20
+ readonly name: string;
21
+ readonly version: string;
22
+ }
23
+
24
+ export interface RuntimeAssemblyView {
25
+ readonly revision: string | null;
26
+ readonly model: string;
27
+ readonly thinking: ThinkingEffort;
28
+ readonly executionLevel: ExecutionLevel;
29
+ readonly systemPrompt?: string;
30
+ readonly memory: RuntimeMemoryProfile;
31
+ readonly toolDeny: readonly string[];
32
+ readonly skills: readonly RuntimeAssemblySkillBinding[];
33
+ readonly extensions: readonly RuntimeAssemblyExtension[];
34
+ readonly subagents: readonly string[];
35
+ readonly mcpServers: readonly RuntimeMcpServer[];
36
+ readonly degradations: readonly RuntimeDegradation[];
37
+ }
@@ -0,0 +1,41 @@
1
+ import type { RuntimeSnapshot } from "../runtime-assembler";
2
+ import type { PreparedPiRuntime } from "../pi/runtime-adapter";
3
+ import type { RuntimeAssemblyView } from "./runtime-assembly-view";
4
+ export type * from "./runtime-assembly-view";
5
+
6
+ /**
7
+ * Serializable view of the Runtime Snapshot currently installed on a Session facet.
8
+ *
9
+ * @remarks
10
+ * UI debug panels and Host tooling call `getRuntimeAssembly` to read what the
11
+ * Agent actually assembled, not what upstream configuration merely declared.
12
+ */
13
+ export function projectRuntimeAssembly(
14
+ snapshot: RuntimeSnapshot,
15
+ revision: string | null,
16
+ prepared: Pick<PreparedPiRuntime, "degradations" | "extensions">,
17
+ ): RuntimeAssemblyView {
18
+ const { profile, bindings } = snapshot;
19
+ return {
20
+ revision,
21
+ model: profile.model,
22
+ thinking: profile.thinking,
23
+ executionLevel: profile.executionLevel,
24
+ ...(profile.systemPrompt ? { systemPrompt: profile.systemPrompt } : {}),
25
+ memory: { ...profile.memory },
26
+ toolDeny: [...(profile.denyPolicy?.deny ?? [])],
27
+ skills: bindings.skills.sources.map((binding) => ({
28
+ name: binding.name,
29
+ description: binding.description,
30
+ script: {
31
+ network: binding.script.network,
32
+ workspace: binding.script.workspace,
33
+ tools: [...binding.script.tools],
34
+ },
35
+ })),
36
+ extensions: prepared.extensions.map((extension) => ({ ...extension })),
37
+ subagents: [...profile.enabledSubagents],
38
+ mcpServers: profile.mcpServers.map((server) => ({ ...server })),
39
+ degradations: prepared.degradations.map((degradation) => ({ ...degradation })),
40
+ };
41
+ }
@@ -0,0 +1,4 @@
1
+ export interface RuntimeConfigUpdateResult<Change> {
2
+ readonly change: Change;
3
+ readonly runtime: "unchanged" | "reloaded" | "reload-failed";
4
+ }
@@ -0,0 +1,102 @@
1
+ import type { RuntimeLoadPhase, RuntimeLoadState } from "./state";
2
+
3
+ /**
4
+ * 装载状态机与宿主之间的全部接触面。
5
+ *
6
+ * @remarks
7
+ * 只有这三项:读上一次发布的状态、发布新状态、回答旧 Runtime 是否仍可服务。
8
+ * 追踪器碰不到 Kernel 的 Snapshot、数据库或 Submission,因此它的行为可以脱离
9
+ * Durable Object 单独验证。
10
+ */
11
+ export interface RuntimeLoadPort {
12
+ read(): RuntimeLoadState | undefined;
13
+ publish(next: RuntimeLoadState): void;
14
+ /** 旧 Runtime 是否仍在服务;强制重载失败时它可能仍为 true。 */
15
+ isAvailable(): boolean;
16
+ }
17
+
18
+ /**
19
+ * 维护一次 Runtime 装载尝试的公开状态机。
20
+ *
21
+ * @remarks
22
+ * 宿主在装载的各个边界调用 `begin` / `advance` / `complete` / `fail`,
23
+ * 由本类统一发布 `RuntimeLoadState` 的形状与时间戳。
24
+ *
25
+ * `startedAt` 表示一次尝试的开始时刻:`begin` 重置它,其余转移沿用它,
26
+ * 使前端可以按同一基准显示耗时。
27
+ */
28
+ export class RuntimeLoadTracker {
29
+ constructor(
30
+ private readonly port: RuntimeLoadPort,
31
+ private readonly now: () => number = Date.now,
32
+ ) {}
33
+
34
+ // 作用:把状态清回未装载。
35
+ // 调用:Durable Object 启动时调用。
36
+ // 原因:实例刚被唤醒时没有任何在途尝试,旧记录里的 ready 不能继续对外承诺。
37
+ reset(): void {
38
+ this.port.publish({ status: "idle", available: false });
39
+ }
40
+
41
+ // 作用:开始一轮新的装载尝试。
42
+ // 调用:宿主在调用 Definition config.read 前调用。
43
+ // 原因:config 阶段可能包含身份、D1 和 Resource 解析,必须在首个慢请求前可见。
44
+ begin(): void {
45
+ const now = this.now();
46
+ this.port.publish({
47
+ status: "loading",
48
+ phase: "config",
49
+ available: this.port.isAvailable(),
50
+ startedAt: now,
51
+ updatedAt: now,
52
+ });
53
+ }
54
+
55
+ // 作用:推进当前装载尝试的阶段。
56
+ // 调用:宿主在进入 Assembly、MCP 和 Pi 边界时调用。
57
+ // 原因:保持一个稳定的粗粒度协议,不向前端泄漏具体能力准备和并发细节。
58
+ advance(phase: RuntimeLoadPhase): void {
59
+ const now = this.now();
60
+ this.port.publish({
61
+ status: "loading",
62
+ phase,
63
+ available: this.port.isAvailable(),
64
+ startedAt: this.startedAt(now),
65
+ updatedAt: now,
66
+ });
67
+ }
68
+
69
+ // 作用:把成功提交的装载尝试标记为可用。
70
+ // 调用:宿主在配置初始化和 Runtime key 提交完成后调用。
71
+ // 原因:只有完整原子提交后才能向客户端承诺 ready。
72
+ complete(): void {
73
+ const now = this.now();
74
+ this.port.publish({
75
+ status: "ready",
76
+ available: true,
77
+ startedAt: this.startedAt(now),
78
+ completedAt: now,
79
+ });
80
+ }
81
+
82
+ // 作用:记录装载失败,同时保留旧 Runtime 是否仍可用的信息。
83
+ // 调用:宿主收口 Config 读取或 Runtime 装配异常时调用。
84
+ // 原因:前端需要状态但不应接收可能包含存储细节的底层错误文本。
85
+ fail(): void {
86
+ const now = this.now();
87
+ const current = this.port.read();
88
+ this.port.publish({
89
+ status: "error",
90
+ phase: current?.status === "loading" ? current.phase : "config",
91
+ available: this.port.isAvailable(),
92
+ startedAt: this.startedAt(now),
93
+ failedAt: now,
94
+ });
95
+ }
96
+
97
+ // 同一轮尝试内沿用原开始时刻;不在装载中说明这是一次孤立转移,只能以当下为准。
98
+ private startedAt(now: number): number {
99
+ const current = this.port.read();
100
+ return current?.status === "loading" ? current.startedAt : now;
101
+ }
102
+ }
@@ -1,11 +1,18 @@
1
1
  import type { ApprovalReceipt } from "./receipts";
2
2
 
3
- export type RuntimeLoadPhase = "config" | "plugins" | "mcp" | "pi";
3
+ export type RuntimeLoadPhase = "config" | "assembly" | "mcp" | "pi";
4
4
 
5
5
  export type RuntimeActivity = "idle" | "working" | "needs-input";
6
6
 
7
7
  export interface RuntimeActivityProjection {
8
8
  activity: RuntimeActivity;
9
+ /**
10
+ * 是否仍有 Agent Tool 子运行在执行。
11
+ *
12
+ * `activity` 三态互斥,表达不了「等人回应的同时后台还在跑」。Host 的列表需要
13
+ * 这两件事各自成立,所以它是一个独立维度,而不是第四种活动值。
14
+ */
15
+ backgroundWork: boolean;
9
16
  revision: number;
10
17
  }
11
18
 
@@ -202,6 +202,13 @@ interface SubmissionLifecycleOptions<
202
202
  // 调用:`cancel` 命中活动执行器,或 `activate` 发现早到取消时调用。
203
203
  // 原因:生命周期不依赖具体执行器类型,只通过这个回调中断。
204
204
  abortActive(active: TActive): void;
205
+ /**
206
+ * 中断活动执行器,但让这条提交留给持久续跑接手。
207
+ *
208
+ * `interrupt` 调用。与 `abortActive` 的差别在结局而不在动作:中断不写终态,
209
+ * 也不接受这条执行器退场路上产生的任何工具结算。
210
+ */
211
+ interruptActive(active: TActive): void;
205
212
  }
206
213
 
207
214
  // #endregion
@@ -528,6 +535,29 @@ export class SubmissionLifecycle<
528
535
  };
529
536
  }
530
537
 
538
+ /**
539
+ * 中断一条提交的活动执行器,但不写取消原因,也不写终态。
540
+ *
541
+ * @remarks
542
+ * 宿主在 park 期间换掉装配后调用:内存里那条执行器的系统提示和工具表锁在它
543
+ * 自己被创建的那一刻,续跑不可能带上新能力,必须让位给持久续跑路径重建一条。
544
+ *
545
+ * 返回的 Promise 在旧执行器真正退场后才完成,调用方必须等它 —— 否则决策会
546
+ * 看到还没散场的内存等待器,又把这一轮唤回旧装配上。没有活动执行器时是空操作。
547
+ */
548
+ async interrupt(
549
+ submissionId: string,
550
+ afterAbort?: () => void,
551
+ ): Promise<void> {
552
+ const active = this.activeBySubmission.get(submissionId);
553
+ if (!active) return;
554
+ // 先中断再拆等待器:反过来的话,park 住的工具会先醒来、发现自己没有结算,
555
+ // 然后真的把工具跑一遍 —— 而这一轮本来就该让位。
556
+ this.options.interruptActive(active);
557
+ afterAbort?.();
558
+ await this.executions.get(submissionId)?.catch(() => undefined);
559
+ }
560
+
531
561
  /**
532
562
  * 返回当前唯一活动的执行器。
533
563
  *