@springbrand/agent-runtime 0.2.0-alpha.17 → 0.2.0-alpha.19

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.2.0-alpha.17",
3
+ "version": "0.2.0-alpha.19",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -412,6 +412,20 @@ function timedOut(error: unknown): boolean {
412
412
  return message.includes("timeout") || message.includes("timed out");
413
413
  }
414
414
 
415
+ function processAlreadyStopped(error: unknown): boolean {
416
+ if (
417
+ typeof error !== "object" ||
418
+ error === null ||
419
+ !("code" in error)
420
+ ) {
421
+ return false;
422
+ }
423
+ return (
424
+ error.code === "COMMAND_NOT_FOUND" ||
425
+ error.code === "PROCESS_NOT_FOUND"
426
+ );
427
+ }
428
+
415
429
  function mappedError(error: unknown): SandboxPortError {
416
430
  if (error instanceof SandboxPortError) return error;
417
431
  const message = errorMessage(error).toLowerCase();
@@ -664,11 +678,11 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
664
678
  id,
665
679
  PROCESS_SESSION_ID,
666
680
  );
667
- if (!process) {
668
- throw new SandboxPortError(
669
- "not_found",
670
- "background process was not found",
671
- );
681
+ if (
682
+ !process ||
683
+ (process.status !== "starting" && process.status !== "running")
684
+ ) {
685
+ return { stopped: false };
672
686
  }
673
687
  if (this.processExpired(id)) {
674
688
  await this.client
@@ -683,6 +697,7 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
683
697
  this.emit("sandbox.process.stopped", { success: true });
684
698
  return { stopped: true };
685
699
  } catch (error) {
700
+ if (processAlreadyStopped(error)) return { stopped: false };
686
701
  throw mappedError(error);
687
702
  }
688
703
  });
@@ -41,6 +41,10 @@ function textOf(message: AssistantMessage): string {
41
41
  .trim();
42
42
  }
43
43
 
44
+ function structuredText(text: string): string {
45
+ return /^```(?:json)?\s*\n([\s\S]*?)\n```$/i.exec(text)?.[1] ?? text;
46
+ }
47
+
44
48
  function toolErrorText(result: unknown): string {
45
49
  if (
46
50
  typeof result === "object" &&
@@ -161,7 +165,7 @@ export async function runCloudflareSubAgent(
161
165
 
162
166
  let output: unknown;
163
167
  try {
164
- output = JSON.parse(text);
168
+ output = JSON.parse(structuredText(text));
165
169
  } catch {
166
170
  throw new Error("SubAgent output is not valid JSON");
167
171
  }
package/src/db/schema.ts CHANGED
@@ -46,6 +46,16 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
46
46
  if (!submissionColumns.has("admission_retryable")) {
47
47
  sql`ALTER TABLE pi_submissions ADD COLUMN admission_retryable INTEGER`;
48
48
  }
49
+ // 跨执行片累计的模型回合数。让步把一个 Turn 切成多片后,每片的回合计数都从 0 开始,
50
+ // 只有把总数落到持久层,全局预算才拦得住「一直让步、永不收敛」的工具循环。
51
+ if (!submissionColumns.has("model_turns")) {
52
+ sql`ALTER TABLE pi_submissions
53
+ ADD COLUMN model_turns INTEGER NOT NULL DEFAULT 0`;
54
+ }
55
+ // 当前让步片的标识。用来丢弃重复到期的续跑 alarm,并让看门狗判断这一轮有没有推进。
56
+ if (!submissionColumns.has("continuation_id")) {
57
+ sql`ALTER TABLE pi_submissions ADD COLUMN continuation_id TEXT`;
58
+ }
49
59
  sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_run_id
50
60
  ON pi_submissions(run_id) WHERE run_id IS NOT NULL`;
51
61
  if (!submissionColumns.has("queued_input_json")) {
@@ -53,6 +53,10 @@ export interface StoredSubmission {
53
53
  rateVersion: number | null;
54
54
  slotIdentity: string | null;
55
55
  retryable?: boolean;
56
+ /** 跨执行片累计的模型回合数,全局回合预算的唯一事实来源。 */
57
+ modelTurns: number;
58
+ /** 当前让步执行片的标识;没有未完成让步时为 null。 */
59
+ continuationId: string | null;
56
60
  }
57
61
 
58
62
  export interface NewSubmission {
@@ -97,6 +101,8 @@ type SubmissionRow = {
97
101
  rate_version: number | null;
98
102
  slot_identity: string | null;
99
103
  admission_retryable: number | null;
104
+ model_turns: number;
105
+ continuation_id: string | null;
100
106
  };
101
107
 
102
108
  // #endregion
@@ -131,6 +137,8 @@ function mapRow(row: SubmissionRow): StoredSubmission {
131
137
  retryable: row.admission_retryable === null
132
138
  ? undefined
133
139
  : row.admission_retryable === 1,
140
+ modelTurns: row.model_turns ?? 0,
141
+ continuationId: row.continuation_id,
134
142
  };
135
143
  }
136
144
 
@@ -153,7 +161,8 @@ export class SubmissionRepository {
153
161
  queued_input_json, queued_ui_message_json, user_message_id,
154
162
  regenerate_message_id, recovery_error_count, recovery_reason,
155
163
  run_id, account_id, rate_version, slot_identity,
156
- admission_retryable
164
+ admission_retryable,
165
+ model_turns, continuation_id
157
166
  FROM pi_submissions
158
167
  WHERE submission_id = ${id}
159
168
  `[0];
@@ -413,6 +422,40 @@ export class SubmissionRepository {
413
422
  `[0]?.recovery_error_count ?? 0;
414
423
  }
415
424
 
425
+ /**
426
+ * 记录一个让步执行片的累计模型回合数和续跑标识。
427
+ *
428
+ * @remarks
429
+ * Runtime 在一片主动让出后、排出续跑调度之前调用。
430
+ *
431
+ * 两个值必须一起写:全局回合预算靠 `model_turns` 拦住不收敛的工具循环,
432
+ * 续跑去重和看门狗靠 `continuation_id` 判断这一轮到底有没有推进。
433
+ * 只写非终态记录,避免一次迟到的写入复活已经结束的 Submission。
434
+ */
435
+ recordYieldedSlice(
436
+ id: string,
437
+ modelTurns: number,
438
+ continuationId: string,
439
+ ): void {
440
+ this.sql`
441
+ UPDATE pi_submissions
442
+ SET model_turns = ${modelTurns},
443
+ continuation_id = ${continuationId}
444
+ WHERE submission_id = ${id}
445
+ AND status IN ('pending', 'running')
446
+ `;
447
+ }
448
+
449
+ /** 记录累计模型回合数,不改动续跑标识。终态提交前的最后一次结算用它。 */
450
+ recordModelTurns(id: string, modelTurns: number): void {
451
+ this.sql`
452
+ UPDATE pi_submissions
453
+ SET model_turns = ${modelTurns}
454
+ WHERE submission_id = ${id}
455
+ AND status IN ('pending', 'running')
456
+ `;
457
+ }
458
+
416
459
  clearRecoveryReason(id: string): void {
417
460
  this.sql`
418
461
  UPDATE pi_submissions
@@ -353,10 +353,41 @@ export abstract class RecoverableChatAgent<
353
353
  * 清理延后执行,为断线重连的客户端保留终态流片段。
354
354
  */
355
355
  protected completeRecoverableStream(streamId: string): void {
356
- const completedRequestId = this.resumableStream.activeRequestId;
357
- this.resumableStream.complete(streamId);
356
+ this.closeRecoverableStream(streamId, (id) =>
357
+ this.resumableStream.complete(id),
358
+ );
359
+ }
360
+
361
+ /**
362
+ * 密封一个只跑完当前执行切片的可续传流,把这一轮交给下一个切片继续。
363
+ *
364
+ * @remarks
365
+ * 子类在一次计划让步(Turn 未结束、但本次执行已让出)后调用;调用方随后必须
366
+ * 排出续跑,且**不能**同时发 `done` 终态帧 —— 这一轮还没有权威结果。
367
+ *
368
+ * 与 `completeRecoverableStream` 的差别只在语义:那个断言「业务终态已提交」,
369
+ * 这个断言「本切片不再产出,另一个切片接手」。底层动作相同是有意的 ——
370
+ * 让步不密封的话,`ResumableStream.start()` 会直接覆盖 `activeStreamId`,
371
+ * 旧行永远停在 `streaming`,缓冲区要等 abandoned 保留期才回收。
372
+ */
373
+ protected sealRecoverableStreamSlice(streamId: string): void {
374
+ this.closeRecoverableStream(streamId, (id) =>
375
+ this.resumableStream.complete(id),
376
+ );
377
+ }
378
+
379
+ // 作用:收口一条可续传流的终止写入与内存续传状态。
380
+ // 调用:完成、失败和切片密封三条路径共用。
381
+ // 原因:清理顺序(先读 activeRequestId、再写状态、最后比对续传归属)三处必须一致,
382
+ // 分开维护过一次就会漏掉 pendingResumeConnections 或 continuation 归属其中之一。
383
+ private closeRecoverableStream(
384
+ streamId: string,
385
+ close: (streamId: string) => void,
386
+ ): void {
387
+ const closedRequestId = this.resumableStream.activeRequestId;
388
+ close(streamId);
358
389
  this.pendingResumeConnections.clear();
359
- if (completedRequestId === this.continuation.activeRequestId) {
390
+ if (closedRequestId === this.continuation.activeRequestId) {
360
391
  this.continuation.activeRequestId = null;
361
392
  this.continuation.activeConnectionId = null;
362
393
  }
@@ -372,14 +403,9 @@ export abstract class RecoverableChatAgent<
372
403
  * 流错误标志与业务失败分开,因为续传协议和提交生命周期的责任不同。
373
404
  */
374
405
  protected failRecoverableStream(streamId: string): void {
375
- const erroredRequestId = this.resumableStream.activeRequestId;
376
- this.resumableStream.markError(streamId);
377
- this.pendingResumeConnections.clear();
378
- if (erroredRequestId === this.continuation.activeRequestId) {
379
- this.continuation.activeRequestId = null;
380
- this.continuation.activeConnectionId = null;
381
- }
382
- void this.ensureStreamCleanupScheduled();
406
+ this.closeRecoverableStream(streamId, (id) =>
407
+ this.resumableStream.markError(id),
408
+ );
383
409
  }
384
410
 
385
411
  /**
@@ -131,6 +131,25 @@ export class SubmissionQueueFullError extends Error {
131
131
  */
132
132
  export type SubmissionOutcome = "succeeded" | "failed" | "aborted";
133
133
 
134
+ /**
135
+ * 说明一次执行是怎么被启动的。
136
+ *
137
+ * @remarks
138
+ * `SubmissionLifecycle` 把它交给 `execute`,Runtime 据此选择执行前置动作。
139
+ *
140
+ * `recovery` 和 `continuation` 必须分开:前者的内存状态已经丢了、transcript 可能停在半路,
141
+ * 要走完整的里程碑重放;后者是本轮主动让出执行片,transcript 完好、什么都不用重建。
142
+ * 合成一个布尔量意味着最常跑的那条路径每次都去趟最脆弱的恢复代码,
143
+ * 而且重放成本会随让步次数增长。
144
+ */
145
+ export type SubmissionExecutionMode =
146
+ /** 首次执行一条新接收的提交。 */
147
+ | "fresh"
148
+ /** 中断后按持久事实重建并继续。 */
149
+ | "recovery"
150
+ /** 上一执行片主动让出后,接着跑同一条提交。 */
151
+ | "continuation";
152
+
134
153
  /**
135
154
  * 描述接收一次提交所需的标识和延迟创建步骤。
136
155
  *
@@ -183,10 +202,13 @@ interface SubmissionLifecycleOptions<
183
202
  // 调用:`admit` 只在新提交成功写入后调用。
184
203
  // 原因:重复提交只加入旧工作,不应清除现有终态。
185
204
  clearTerminal(): Promise<void>;
186
- // 作用:执行或恢复一条已持久化的提交。
205
+ // 作用:执行、恢复或续跑一条已持久化的提交。
187
206
  // 调用:`start` 在 `TurnQueue` 轮到该提交时调用。
188
- // 原因:生命周期只管调度,具体 Pi Turn 执行仍由 Runtime 负责。
189
- execute(submissionId: string, recovery: boolean): Promise<TSubmission>;
207
+ // 原因:生命周期只管调度,具体 Pi Turn 执行仍由 Runtime 负责;模式决定 Runtime 的前置动作。
208
+ execute(
209
+ submissionId: string,
210
+ mode: SubmissionExecutionMode,
211
+ ): Promise<TSubmission>;
190
212
  // 作用:记录可恢复的取消意图。
191
213
  // 调用:`cancel` 在同一存储事务内与取消原因一起写入。
192
214
  // 原因:持久化里程碑可以让恢复路径看到已经发生的取消。
@@ -300,22 +322,30 @@ export class SubmissionLifecycle<
300
322
  * 它复用 `start` 的实例内去重和串行队列,避免重复唤醒产生两个执行器。
301
323
  */
302
324
  recover(submissionId: string): Promise<TSubmission> {
303
- return this.start(submissionId, true);
325
+ return this.start(submissionId, "recovery");
304
326
  }
305
327
 
306
- /** 等当前切片退出后恢复同一条非终态 Submission,避免 planned wake 被实例内去重吞掉。 */
307
- async recoverAfterCurrent(submissionId: string): Promise<TSubmission> {
328
+ /**
329
+ * 等当前执行片退出后,接着跑同一条非终态 Submission。
330
+ *
331
+ * @remarks
332
+ * `_piPlannedContinuation` 在计划让步的唤醒里调用。先 await 当前执行片,
333
+ * 是为了避开 `executions` 的实例内去重 —— 否则这次唤醒会被合流到正在退场的那一片上。
334
+ *
335
+ * 走 `continuation` 而不是 `recovery`:这一轮什么都没丢,不需要重放里程碑。
336
+ */
337
+ async continueAfterCurrent(submissionId: string): Promise<TSubmission> {
308
338
  const current = this.executions.get(submissionId);
309
339
  if (current) {
310
340
  const outcome = await current;
311
341
  if (isTerminalSubmissionStatus(outcome.status)) return outcome;
312
342
  }
313
- return this.start(submissionId, true);
343
+ return this.start(submissionId, "continuation");
314
344
  }
315
345
 
316
346
  recoverHead(): Promise<TSubmission | null> {
317
347
  const running = this.options.store.findRunning();
318
- if (running) return this.start(running.submissionId, true);
348
+ if (running) return this.start(running.submissionId, "recovery");
319
349
  const pending = this.options.store.findNextPending();
320
350
  return pending
321
351
  ? this.start(pending.submissionId)
@@ -410,14 +440,14 @@ export class SubmissionLifecycle<
410
440
  // 原因:`TurnQueue` 防止不同 Pi Turn 重叠,`executions` 则防止同一标识重复入队。
411
441
  private start(
412
442
  submissionId: string,
413
- recovery = false,
443
+ mode: SubmissionExecutionMode = "fresh",
414
444
  ): Promise<TSubmission> {
415
445
  const existing = this.executions.get(submissionId);
416
446
  if (existing) return existing;
417
447
  let shouldPump = false;
418
448
  const started = this.queue
419
449
  .enqueue(submissionId, () =>
420
- this.options.execute(submissionId, recovery),
450
+ this.options.execute(submissionId, mode),
421
451
  )
422
452
  .then((outcome) => {
423
453
  // TODO(待确认): 当前类没有调用 `queue.reset()`,按现有调用链不会产生 `stale` 结果。
@@ -583,6 +613,19 @@ export class SubmissionLifecycle<
583
613
  await this.executions.get(submissionId)?.catch(() => undefined);
584
614
  }
585
615
 
616
+ /**
617
+ * 判断某条提交此刻是否有执行器在跑。
618
+ *
619
+ * @remarks
620
+ * 续跑看门狗在决定要不要接手前调用。
621
+ *
622
+ * 只反映本实例的内存状态 —— 这正是它要问的:跨实例的丢失由持久事实兜底,
623
+ * 而「有执行器在跑」只有本实例知道,误判会派发出重复的执行片。
624
+ */
625
+ isActive(submissionId: string): boolean {
626
+ return this.activeBySubmission.has(submissionId);
627
+ }
628
+
586
629
  /**
587
630
  * 返回当前唯一活动的执行器。
588
631
  *
@@ -15,6 +15,8 @@ import { USER_STOP_REASON } from "../../kernel/receipts";
15
15
 
16
16
  const MAX_OUTPUT_BYTES = 256 * 1024;
17
17
  const MAX_OUTPUT_PREVIEW = 16 * 1024;
18
+ const PROVIDER_CREDIT_ERROR =
19
+ "The AI service is temporarily unavailable. Please try again later.";
18
20
 
19
21
  export interface PiToolApprovalView {
20
22
  readonly id: string;
@@ -101,6 +103,16 @@ function errorText(result: unknown): string {
101
103
  }
102
104
  }
103
105
 
106
+ /** Keep provider account details out of browser-facing error messages. */
107
+ export function publicAssistantError(message?: string): string | undefined {
108
+ if (!message) return undefined;
109
+ return /"limit_source"\s*:\s*"openrouter_credits"/.test(message) ||
110
+ (message.includes("Insufficient credits") &&
111
+ message.includes("openrouter.ai/settings/credits"))
112
+ ? PROVIDER_CREDIT_ERROR
113
+ : message;
114
+ }
115
+
104
116
  function toolCallAt(
105
117
  update: AssistantMessageEvent,
106
118
  ): Partial<ToolCall> | undefined {
@@ -318,7 +330,12 @@ export class PiChunkEncoder {
318
330
  case "toolcall_end":
319
331
  return this.encodeToolInput(update);
320
332
  case "error":
321
- return [{ type: "error", errorText: update.error.errorMessage ?? update.reason }];
333
+ return [{
334
+ type: "error",
335
+ errorText: publicAssistantError(
336
+ update.error.errorMessage ?? update.reason,
337
+ ) ?? "SpringBrand turn failed",
338
+ }];
322
339
  default:
323
340
  return [];
324
341
  }
@@ -421,6 +438,7 @@ export class PiChunkEncoder {
421
438
  return chunks;
422
439
  }
423
440
  this.finished = true;
441
+ const publicError = publicAssistantError(message.errorMessage);
424
442
  if (this.startedAt !== undefined) {
425
443
  const turnStatus = message.stopReason === "aborted"
426
444
  ? "aborted"
@@ -434,10 +452,10 @@ export class PiChunkEncoder {
434
452
  completedAt: message.timestamp,
435
453
  turnDurationMs: Math.max(0, message.timestamp - this.startedAt),
436
454
  turnStatus,
437
- ...(message.errorMessage &&
455
+ ...(publicError &&
438
456
  (message.stopReason !== "aborted" ||
439
457
  message.errorMessage !== USER_STOP_REASON)
440
- ? { error: message.errorMessage }
458
+ ? { error: publicError }
441
459
  : {}),
442
460
  },
443
461
  });
@@ -449,7 +467,7 @@ export class PiChunkEncoder {
449
467
  if (message.stopReason === "error") {
450
468
  chunks.push({
451
469
  type: "error",
452
- errorText: message.errorMessage ?? "SpringBrand turn failed",
470
+ errorText: publicError ?? "SpringBrand turn failed",
453
471
  });
454
472
  return chunks;
455
473
  }
@@ -53,8 +53,27 @@ import { toolRegistryFromPiCandidates } from "../../tool-registry";
53
53
 
54
54
  // #region Single-run Pi bridge
55
55
 
56
+ // 一片执行最多跑多少个模型回合。这是 CPU 预算边界,不是任务边界:DO 的
57
+ // CPU 上限按 invocation 计,一条长回合跑在一次 invocation 里会把预算吃满并被驱逐,
58
+ // 让步换来的是一次全新 invocation 和全新预算。
56
59
  const MAX_MODEL_TURNS_PER_SLICE = 30;
57
60
 
61
+ // 一条 Submission 跨所有执行片最多跑多少个模型回合。这是任务边界:
62
+ // 没有它,一个不收敛的工具循环会无限让步、无限唤醒、无限烧 token。
63
+ export const MAX_MODEL_TURNS_PER_SUBMISSION = 300;
64
+
65
+ // 预算耗尽时注入的收尾指令。跟着它一起把工具表清空,模型只能出文本,
66
+ // 于是这一轮以一条正常的终止助手消息结束 —— 而不是硬判失败或继续让步。
67
+ // 走 user 角色而非 assistant:多个 provider 拒绝以模型回合结尾的请求。
68
+ const WRAP_UP_MESSAGE = "You have reached this run's model-turn budget. Stop " +
69
+ "calling tools now. Reply with a final message that states what you " +
70
+ "completed, what you did not finish, and the exact next step you would " +
71
+ "take. Do not start new work.";
72
+
73
+ // 收尾开始后最多再放行几个模型回合。工具已经清空,正常一轮就结束了;
74
+ // 留出余量是给 steering 消息,但不能因此把本片的 CPU 上限一起取消。
75
+ const MAX_WRAP_UP_TURNS = 2;
76
+
58
77
  export class RetryableModelError extends Error {}
59
78
 
60
79
  function normalizePlanToolCalls(message: AgentMessage): void {
@@ -136,6 +155,8 @@ interface PiTurnAdapterOptions {
136
155
  // 直接复用 Pi 的回调类型可避免这里维护另一套上下文转换约定。
137
156
  readonly transformContext: NonNullable<AgentOptions["transformContext"]>;
138
157
  readonly workspace?: SpillWorkspace;
158
+ // 之前的执行片已经用掉的模型回合数。首次执行传 0。
159
+ readonly modelTurnsConsumed: number;
139
160
  }
140
161
 
141
162
  function projectToolResultsForModel(
@@ -231,8 +252,13 @@ class PiTurnAdapter {
231
252
  signal?.throwIfAborted();
232
253
  const canonicalMessages = await this.opts.canonicalMessages();
233
254
  const tools = this.compile(opts.tools);
234
- let modelTurns = 0;
255
+ // `modelTurns` 是跨执行片的累计数,`sliceTurns` 只数本片 —— 前者管任务预算,
256
+ // 后者管 CPU 预算,两个边界互不替代。
257
+ let modelTurns = this.opts.modelTurnsConsumed;
258
+ let sliceTurns = 0;
235
259
  let reachedModelTurnLimit = false;
260
+ let wrappingUp = false;
261
+ let wrapUpTurns = 0;
236
262
  const pi = new PiCore({
237
263
  convertToLlm,
238
264
  streamFn: withProviderRetry(
@@ -253,9 +279,45 @@ class PiTurnAdapter {
253
279
  ) as AgentMessage[];
254
280
  },
255
281
  afterToolCall: this.governance.afterToolCall,
282
+ // Pi 在每个 turn_end 之后先调 prepareNextTurn、再调 shouldStopAfterTurn,
283
+ // 所以回合计数记在这里,停止判据只读不写。
284
+ // 用 `WithContext` 变体:只有它拿得到当前上下文,也就拿得到工具表和消息。
285
+ prepareNextTurnWithContext: ({ context }) => {
286
+ modelTurns += 1;
287
+ sliceTurns += 1;
288
+ if (wrappingUp) {
289
+ wrapUpTurns += 1;
290
+ return undefined;
291
+ }
292
+ if (modelTurns < MAX_MODEL_TURNS_PER_SUBMISSION - 1) return undefined;
293
+ // 预算见底:清空工具表并注入收尾指令,逼出一条真正的终止助手消息。
294
+ // 直接判失败的话用户拿不到任何交代;继续让步则等于没有预算。
295
+ // 这条指令不进 newMessages,因此不会写进 transcript —— 它是控制指令,不是历史。
296
+ wrappingUp = true;
297
+ return {
298
+ context: {
299
+ ...context,
300
+ tools: [],
301
+ messages: [
302
+ ...context.messages,
303
+ {
304
+ role: "user" as const,
305
+ content: [{ type: "text" as const, text: WRAP_UP_MESSAGE }],
306
+ timestamp: Date.now(),
307
+ },
308
+ ],
309
+ },
310
+ };
311
+ },
256
312
  shouldStopAfterTurn: () => {
257
- reachedModelTurnLimit = ++modelTurns >= MAX_MODEL_TURNS_PER_SLICE;
258
- return signal?.aborted === true || reachedModelTurnLimit;
313
+ if (signal?.aborted === true) return true;
314
+ // 收尾那一轮必须跑完,否则让步会把唯一的终态机会顶掉。但「必须跑完」
315
+ // 不等于「不再有上限」:Pi 在这之后还会取 steering 和 follow-up 消息,
316
+ // 一直有新消息进来就会一直发模型请求,而且全在同一个 invocation 里 ——
317
+ // 那正是 MAX_MODEL_TURNS_PER_SLICE 要挡的 CPU 驱逐。
318
+ if (wrappingUp) return wrapUpTurns >= MAX_WRAP_UP_TURNS;
319
+ reachedModelTurnLimit = sliceTurns >= MAX_MODEL_TURNS_PER_SLICE;
320
+ return reachedModelTurnLimit;
259
321
  },
260
322
  initialState: {
261
323
  model: this.opts.pi.model,
@@ -286,8 +348,8 @@ class PiTurnAdapter {
286
348
  }
287
349
  await running;
288
350
  return reachedModelTurnLimit
289
- ? { kind: "yielded" }
290
- : { kind: "terminal" };
351
+ ? { kind: "yielded", modelTurns }
352
+ : { kind: "terminal", modelTurns };
291
353
  } finally {
292
354
  signal?.removeEventListener("abort", abort);
293
355
  unsubscribe();
@@ -393,6 +455,8 @@ export interface CreatePreparedPiTurnOptions {
393
455
  readonly startedAt: number;
394
456
  readonly continuation: boolean;
395
457
  readonly assistantOrdinal: number;
458
+ /** 之前的执行片已用掉的模型回合数;省略按 0 处理。 */
459
+ readonly modelTurnsConsumed?: number;
396
460
  };
397
461
  /**
398
462
  * 在 Pi 启动前读取最新 canonical transcript。
@@ -474,8 +538,10 @@ export interface PiPreparedTurnRunOptions {
474
538
  }
475
539
 
476
540
  export type PiTurnRunResult =
477
- | { readonly kind: "terminal" }
478
- | { readonly kind: "yielded" };
541
+ /** Turn 已产生权威结果,或已经收尾。 */
542
+ | { readonly kind: "terminal"; readonly modelTurns: number }
543
+ /** 本片让出执行,Turn 未结束;调用方必须排出续跑。 */
544
+ | { readonly kind: "yielded"; readonly modelTurns: number };
479
545
 
480
546
  // #endregion
481
547
 
@@ -724,6 +790,7 @@ export class PreparedPiTurnAdapter {
724
790
  this.interrupted ? undefined : options.durability.settleTool(call),
725
791
  transformContext: options.transformContext,
726
792
  workspace: state.snapshot.bindings.workspace,
793
+ modelTurnsConsumed: options.submission.modelTurnsConsumed ?? 0,
727
794
  });
728
795
  this.systemPrompt = descriptor.systemPrompt;
729
796
  this.options = options;
@@ -835,7 +902,7 @@ export class PreparedPiTurnAdapter {
835
902
  );
836
903
  if (this.terminalIntent) {
837
904
  await this.options.onTerminal(this.terminalIntent);
838
- return { kind: "terminal" };
905
+ return { kind: "terminal", modelTurns: result.modelTurns };
839
906
  }
840
907
  return result;
841
908
  }
@@ -17,6 +17,7 @@ import {
17
17
  applyPiToolResult,
18
18
  captureUIUserSidecar,
19
19
  piAssistantToUIMessage,
20
+ publicAssistantError,
20
21
  type PiToolApprovalView,
21
22
  type UIUserSidecar,
22
23
  } from "../message";
@@ -655,7 +656,9 @@ export class PiRuntimeTranscript {
655
656
  completedAt - submission.createdAt,
656
657
  ),
657
658
  turnStatus: "error",
658
- ...(submission.error ? { error: submission.error } : {}),
659
+ ...(submission.error
660
+ ? { error: publicAssistantError(submission.error) }
661
+ : {}),
659
662
  },
660
663
  });
661
664
  seenAssistantSubmissions.add(submissionId);
@@ -761,7 +764,9 @@ export class PiRuntimeTranscript {
761
764
  ),
762
765
  }),
763
766
  turnStatus: submission?.status,
764
- ...(submission?.error ? { error: submission.error } : {}),
767
+ ...(submission?.error
768
+ ? { error: publicAssistantError(submission.error) }
769
+ : {}),
765
770
  },
766
771
  approvals: this.approvalViews(entry.submissionId),
767
772
  });
@@ -96,7 +96,7 @@ function candidate<T extends TSchema>(
96
96
  options: Partial<
97
97
  Pick<
98
98
  PiToolCandidate,
99
- "alwaysRequiresApproval" | "owner" | "requiredExecutionLevel" | "summary"
99
+ "alwaysRequiresApproval" | "direct" | "owner" | "requiredExecutionLevel" | "summary"
100
100
  >
101
101
  > = {},
102
102
  ): PiToolCandidate {
@@ -108,6 +108,7 @@ function candidate<T extends TSchema>(
108
108
  ...(options.alwaysRequiresApproval
109
109
  ? { alwaysRequiresApproval: true }
110
110
  : {}),
111
+ ...(options.direct ? { direct: true } : {}),
111
112
  ...(options.summary ? { summary: options.summary } : {}),
112
113
  };
113
114
  }
@@ -137,6 +138,7 @@ export function schedulePiToolCandidates(
137
138
  },
138
139
  {
139
140
  alwaysRequiresApproval: true,
141
+ direct: true,
140
142
  summary: "Create a scheduled task",
141
143
  },
142
144
  ),
package/src/runtime.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  SubmissionLifecycle,
23
23
  MAX_PENDING_SUBMISSIONS,
24
24
  SubmissionQueueFullError,
25
+ type SubmissionExecutionMode,
25
26
  type SubmissionInput,
26
27
  type SubmissionStore,
27
28
  type SubmissionHandle,
@@ -128,6 +129,10 @@ import {
128
129
 
129
130
  const SCHEDULED_STABLE_TIMEOUT_MS = 30_000;
130
131
  const TURN_EVENT_RETRY_SECONDS = 10;
132
+
133
+ // 计划续跑丢失多久后由看门狗接手。必须显著长于一个正常执行片的时长,
134
+ // 否则会在健康的长执行片上误判并派发出重复的一片。
135
+ const CONTINUATION_WATCHDOG_SECONDS = 300;
131
136
  // 一次 Host admission 调用最多被视为「在途」多久。停在 pending 的准入行永久保留
132
137
  // —— 它固定了同一 identity 重投时必须复用的 runId,不能靠删行来让容量回收 ——
133
138
  // 但一次远端失败或崩溃留下的行超过这个窗口后就不再占用 pending 容量,否则
@@ -169,6 +174,10 @@ interface StoredSubmission extends SubmissionReceipt {
169
174
  accountId: string | null;
170
175
  rateVersion: number | null;
171
176
  slotIdentity: string | null;
177
+ /** 跨执行片累计的模型回合数,全局回合预算据此判断。 */
178
+ modelTurns: number;
179
+ /** 当前让步执行片的标识;没有未完成让步时为 null。 */
180
+ continuationId: string | null;
172
181
  }
173
182
 
174
183
  interface SubmitMessageOptions {
@@ -196,6 +205,35 @@ interface PlannedContinuationData {
196
205
  readonly continuationId: string;
197
206
  }
198
207
 
208
+ /**
209
+ * 说明一次恢复准备的结局。
210
+ *
211
+ * @remarks
212
+ * `prepareRecoveredTurn` 返回它,`executeNonTerminalSubmission` 据此决定重起 Turn、
213
+ * 保持停靠还是落终态。
214
+ *
215
+ * `parked` 与 `unresumable` 必须分开:前者有人(审批、客户端交互)或有已排的调度会来解开它,
216
+ * 后者没有任何东西会再唤醒 —— 把两者混成一个「不继续」,非终态记录就会永久停在 running。
217
+ */
218
+ type RecoveredTurnPreparation =
219
+ /** Transcript 完好,可以重起 Pi Turn。 */
220
+ | { readonly kind: "resume" }
221
+ /** 正在等人或等已排的续跑;保持非终态是正确的。 */
222
+ | { readonly kind: "parked" }
223
+ /** 准备过程自己已经写好了终态。 */
224
+ | { readonly kind: "settled" }
225
+ /** 续不下去,调用方必须落一条用户看得见的失败终态。 */
226
+ | { readonly kind: "unresumable"; readonly reason: string };
227
+
228
+ // 作用:为一个没人会来解开的 `wait` 生成用户能看懂的终态原因。
229
+ // 调用:`prepareRecoveredTurn` 判定 wait 不是 approval/interaction 时调用。
230
+ // 原因:终态文本会直接呈现给用户,必须说明发生了什么以及能不能重试。
231
+ function unresumableWaitMessage(reason: string | undefined): string {
232
+ return reason === "uncertain-tool"
233
+ ? "SpringBrand stopped this turn: a non-idempotent Tool was interrupted and its result cannot be confirmed, so it was not retried. Re-send the request if you want to try again."
234
+ : "SpringBrand could not resume this turn after an interruption.";
235
+ }
236
+
199
237
  // 作用:把任意异常整理成可以持久化或发给客户端的文字。
200
238
  // 调用:Turn 执行、恢复和业务投影捕获 `unknown` 异常时调用。
201
239
  // 原因:错误边界不能假定抛出值一定是 `Error`。
@@ -494,8 +532,8 @@ export abstract class AgentRuntimeKernel<
494
532
  this.submissions = new SubmissionLifecycle({
495
533
  store: submissionStore,
496
534
  clearTerminal: () => clearChatTerminal(this.ctx.storage),
497
- execute: (submissionId, recovery) =>
498
- this.executeSubmission(submissionId, recovery),
535
+ execute: (submissionId, mode) =>
536
+ this.executeSubmission(submissionId, mode),
499
537
  appendAbortIntent: (submission, reason) =>
500
538
  this.appendTerminalIntent(submission, "aborted", reason),
501
539
  commitTerminal: (submission, outcome, message) =>
@@ -853,6 +891,7 @@ export abstract class AgentRuntimeKernel<
853
891
  startedAt,
854
892
  continuation: output.continuation ?? false,
855
893
  assistantOrdinal: output.assistantOrdinal ?? 0,
894
+ modelTurnsConsumed: submission.modelTurns,
856
895
  },
857
896
  canonicalMessages: () => this.transcript.canonicalMessages(),
858
897
  durability: {
@@ -2422,21 +2461,26 @@ export abstract class AgentRuntimeKernel<
2422
2461
  // 作用:按持久事实把一个中断 Turn 推进到可继续或已收尾状态。
2423
2462
  // 调用:`executeNonTerminalSubmission` 以 recovery 模式重起 Pi Turn 前调用。
2424
2463
  // 原因:用有界循环执行状态机效果,既允许 Tool 重试产生新事实,也防止错误规则无限自旋。
2464
+ //
2465
+ // 返回值必须区分「在等人」和「续不下去」:以前两者都压成 false,调用方一律原样返回
2466
+ // 非终态记录,于是「续不下去」这一类会让 Submission 永久停在 running。
2425
2467
  private async prepareRecoveredTurn(
2426
2468
  submission: StoredSubmission,
2427
2469
  adapter: PreparedPiTurnAdapter,
2428
- ): Promise<boolean> {
2470
+ ): Promise<RecoveredTurnPreparation> {
2429
2471
  let decision = await this.materializeRecoveredToolResults(submission);
2430
2472
  for (let step = 0; step < 32; step += 1) {
2431
2473
  const latest = this.readSubmission(submission.submissionId);
2432
- if (!latest || isTerminalSubmissionStatus(latest.status)) return false;
2474
+ if (!latest || isTerminalSubmissionStatus(latest.status)) {
2475
+ return { kind: "settled" };
2476
+ }
2433
2477
  if (latest.abortReason) {
2434
2478
  await this.submissions.finish(
2435
2479
  latest,
2436
2480
  "aborted",
2437
2481
  latest.abortReason,
2438
2482
  );
2439
- return false;
2483
+ return { kind: "settled" };
2440
2484
  }
2441
2485
  this.db.transaction(() => {
2442
2486
  this.applyPiRecoveryMutations(
@@ -2446,7 +2490,19 @@ export abstract class AgentRuntimeKernel<
2446
2490
  });
2447
2491
  switch (decision.effect.kind) {
2448
2492
  case "wait":
2449
- return false;
2493
+ // 这里必须逐个 reason 判,不能按「不是 approval 就算续不下去」一刀切:
2494
+ // - approval / interaction:等人,用户迟早会点。
2495
+ // - complete:终态里程碑已提交,行状态同事务写入,上层的终态检查会收掉。
2496
+ // - undefined:续跑里程碑已暂存待派发,`dispatchPendingContinuations`
2497
+ // 会来接手 —— 判它失败等于杀掉一轮本来有人管的 Turn。
2498
+ // - uncertain-tool:**没有任何人会来解开**。非幂等工具结果不确定,恢复
2499
+ // 拒绝重放,`onStart` 也显式跳过 —— 停在这里就是永久挂死。
2500
+ return decision.effect.reason === "uncertain-tool"
2501
+ ? {
2502
+ kind: "unresumable",
2503
+ reason: unresumableWaitMessage(decision.effect.reason),
2504
+ }
2505
+ : { kind: "parked" };
2450
2506
  case "retry-tool": {
2451
2507
  try {
2452
2508
  if (
@@ -2456,7 +2512,11 @@ export abstract class AgentRuntimeKernel<
2456
2512
  input: decision.effect.input,
2457
2513
  })
2458
2514
  ) {
2459
- return false;
2515
+ return {
2516
+ kind: "unresumable",
2517
+ reason:
2518
+ `SpringBrand could not resume this turn: Tool "${decision.effect.toolName}" is no longer available.`,
2519
+ };
2460
2520
  }
2461
2521
  } catch {
2462
2522
  // The governed Tool persisted its bounded error ToolResult.
@@ -2471,17 +2531,25 @@ export abstract class AgentRuntimeKernel<
2471
2531
  submission,
2472
2532
  decision.effect.approvalExecutionId,
2473
2533
  );
2474
- return false;
2534
+ return { kind: "parked" };
2475
2535
  case "finish":
2476
2536
  await this.submissions.finish(
2477
2537
  submission,
2478
2538
  decision.effect.outcome,
2479
2539
  decision.effect.message,
2480
2540
  );
2481
- return false;
2541
+ return { kind: "settled" };
2482
2542
  case "resume-turn": {
2483
2543
  const last = (await this.transcript.canonicalMessages()).at(-1);
2484
- return last?.role === "user" || last?.role === "toolResult";
2544
+ // Pi agentLoopContinue 拒绝从 assistant 消息继续。悬空的尾部 assistant
2545
+ // 意味着这条 transcript 续不下去了 —— 判断出来就必须收尾,不能默默退场。
2546
+ return last?.role === "user" || last?.role === "toolResult"
2547
+ ? { kind: "resume" }
2548
+ : {
2549
+ kind: "unresumable",
2550
+ reason:
2551
+ "SpringBrand could not resume this turn: the transcript ends on an assistant message.",
2552
+ };
2485
2553
  }
2486
2554
  }
2487
2555
  }
@@ -2611,7 +2679,7 @@ export abstract class AgentRuntimeKernel<
2611
2679
  // 原因:把失败帧、可续传流和 pre-stream 清理放在最外层,避免内部分支遗留半开传输状态。
2612
2680
  private async executeSubmission(
2613
2681
  submissionId: string,
2614
- recovery = false,
2682
+ mode: SubmissionExecutionMode = "fresh",
2615
2683
  ): Promise<StoredSubmission> {
2616
2684
  const submission = this.readSubmission(submissionId);
2617
2685
  if (!submission) {
@@ -2627,7 +2695,9 @@ export abstract class AgentRuntimeKernel<
2627
2695
  submission.abortReason,
2628
2696
  );
2629
2697
  }
2630
- if (recovery) {
2698
+ if (mode === "recovery") {
2699
+ // 只有真中断才记恢复事实。计划让步也走这里的话,每个执行片都会伪造一条
2700
+ // `runtime_restart`,并且抹掉真实的 stall 原因 —— 真崩溃和主动让出在观测上就分不开了。
2631
2701
  this.telemetry.capture("turnRecovering", {
2632
2702
  submissionId,
2633
2703
  reason: submission.recoveryReason ?? "runtime_restart",
@@ -2635,16 +2705,16 @@ export abstract class AgentRuntimeKernel<
2635
2705
  });
2636
2706
  this.db.submissions.clearRecoveryReason(submissionId);
2637
2707
  await this.broadcastApprovals();
2638
- await this.ensureRuntimeReady();
2639
2708
  }
2709
+ if (mode !== "fresh") await this.ensureRuntimeReady();
2640
2710
  try {
2641
2711
  return await this.executeNonTerminalSubmission(
2642
2712
  submission,
2643
- recovery,
2713
+ mode,
2644
2714
  );
2645
2715
  } catch (error) {
2646
2716
  if (
2647
- recovery &&
2717
+ mode === "recovery" &&
2648
2718
  (error instanceof ChatStreamStalledError ||
2649
2719
  error instanceof RetryableModelError)
2650
2720
  ) {
@@ -2665,7 +2735,7 @@ export abstract class AgentRuntimeKernel<
2665
2735
  failed.status === "error"
2666
2736
  ? failed.error ?? "SpringBrand turn failed"
2667
2737
  : undefined,
2668
- recovery,
2738
+ mode !== "fresh",
2669
2739
  );
2670
2740
  return failed;
2671
2741
  } finally {
@@ -2683,8 +2753,11 @@ export abstract class AgentRuntimeKernel<
2683
2753
  // 原因:恢复补全、状态迁移、fiber、流式记录与终态投影必须保持固定顺序,否则唤醒后会重复执行或丢失回答。
2684
2754
  private async executeNonTerminalSubmission(
2685
2755
  submission: StoredSubmission,
2686
- recovery: boolean,
2756
+ mode: SubmissionExecutionMode,
2687
2757
  ): Promise<StoredSubmission> {
2758
+ // 续跑和恢复一样要给客户端打 continuation 标记(否则前端会另起一个空累加器,
2759
+ // 把已经渲染出来的半条回答整条换掉),但只有恢复需要重建持久事实。
2760
+ const continuation = mode !== "fresh";
2688
2761
  const submissionId = submission.submissionId;
2689
2762
  if (submission.abortReason) {
2690
2763
  return this.submissions.finish(
@@ -2695,11 +2768,14 @@ export abstract class AgentRuntimeKernel<
2695
2768
  }
2696
2769
  submission = await this.pinSubmissionAssembly(submission);
2697
2770
  await this.assertPinnedSubmission(submission);
2698
- if (submission.status === "pending" && !recovery) {
2771
+ if (submission.status === "pending" && mode === "fresh") {
2699
2772
  submission = await this.activateQueuedSubmission(submission);
2700
2773
  }
2701
2774
  const recoveryAdapter = this.createSubmissionExecutionAdapter(submission);
2702
- if (recovery) {
2775
+ // 让步续跑跳过整套里程碑重放:这一片是主动让出的,transcript 完好、没有半路的
2776
+ // Tool 要重建。走恢复通道不只是白跑,重放成本还随让步次数增长 —— 第 N 片要
2777
+ // 重放前 N-1 片积累的全部里程碑。
2778
+ if (mode === "recovery") {
2703
2779
  const deactivatePreparation = this.submissions.activate(submission, {
2704
2780
  submissionId,
2705
2781
  requestId: submission.requestId,
@@ -2708,24 +2784,37 @@ export abstract class AgentRuntimeKernel<
2708
2784
  continuation: true,
2709
2785
  agent: recoveryAdapter,
2710
2786
  });
2711
- let ready: boolean;
2787
+ let preparation: RecoveredTurnPreparation;
2712
2788
  try {
2713
- ready = await this.prepareRecoveredTurn(
2789
+ preparation = await this.prepareRecoveredTurn(
2714
2790
  submission,
2715
2791
  recoveryAdapter,
2716
2792
  );
2717
2793
  } finally {
2718
2794
  deactivatePreparation();
2719
2795
  }
2720
- if (!ready) {
2796
+ if (preparation.kind !== "resume") {
2721
2797
  const latest = this.readSubmission(submissionId)!;
2722
- return latest.abortReason
2723
- ? this.submissions.finish(
2724
- latest,
2725
- "aborted",
2726
- latest.abortReason,
2727
- )
2728
- : latest;
2798
+ if (latest.abortReason) {
2799
+ return this.submissions.finish(
2800
+ latest,
2801
+ "aborted",
2802
+ latest.abortReason,
2803
+ );
2804
+ }
2805
+ if (isTerminalSubmissionStatus(latest.status)) return latest;
2806
+ // 非终态还想退场,只有「在等人」这一种是合法的。其余一律收成失败终态:
2807
+ // 停在 running 会让 `pump()` 的 `findRunning()` 永远有值,这个 Session
2808
+ // 之后所有消息都排不进去,而且没有任何东西会再唤醒它。
2809
+ if (preparation.kind === "parked") return latest;
2810
+ // `settled` 走到这里说明它没真的写成终态 —— 契约破了也不能放它挂着。
2811
+ return this.submissions.finish(
2812
+ latest,
2813
+ "failed",
2814
+ preparation.kind === "unresumable"
2815
+ ? preparation.reason
2816
+ : unresumableWaitMessage(undefined),
2817
+ );
2729
2818
  }
2730
2819
  } else {
2731
2820
  await this.materializeRecoveredToolResults(submission);
@@ -2767,7 +2856,7 @@ export abstract class AgentRuntimeKernel<
2767
2856
  let turn!: ActiveTurn;
2768
2857
  const adapter = this.createSubmissionExecutionAdapter(submission, {
2769
2858
  startedAt,
2770
- continuation: recovery,
2859
+ continuation,
2771
2860
  assistantOrdinal,
2772
2861
  onRecord: (record) =>
2773
2862
  this.migratedSubmissions.has(submissionId)
@@ -2796,7 +2885,7 @@ export abstract class AgentRuntimeKernel<
2796
2885
  requestId: submission.requestId,
2797
2886
  messageId: submission.assistantMessageId,
2798
2887
  startedAt,
2799
- continuation: recovery,
2888
+ continuation,
2800
2889
  agent: adapter,
2801
2890
  };
2802
2891
  let streamId: string | undefined;
@@ -2809,7 +2898,7 @@ export abstract class AgentRuntimeKernel<
2809
2898
  {
2810
2899
  requestId: submission.requestId,
2811
2900
  messageId: submission.assistantMessageId,
2812
- continuation: recovery,
2901
+ continuation,
2813
2902
  messages: await this.transcript.snapshotMessages(),
2814
2903
  recoveryData: {
2815
2904
  submissionId,
@@ -2844,6 +2933,19 @@ export abstract class AgentRuntimeKernel<
2844
2933
  if (!streamId) {
2845
2934
  throw new Error("Yielded SpringBrand Turn has no recoverable stream");
2846
2935
  }
2936
+ // 回合数和续跑标识必须先落库、再排调度:调度一旦到期就会读这两个值,
2937
+ // 反过来写会让续跑看到上一片的状态。
2938
+ this.db.transaction(() =>
2939
+ this.db.submissions.recordYieldedSlice(
2940
+ submissionId,
2941
+ runResult!.modelTurns,
2942
+ streamId!,
2943
+ )
2944
+ );
2945
+ // 让步必须密封本切片的流,但不能发 done —— 这一轮还没有权威结果。
2946
+ // 不密封的话下一个切片的 start() 会直接顶掉 activeStreamId,旧行永远
2947
+ // 停在 streaming,元数据和 chunk 要等 abandoned 保留期才回收。
2948
+ this.sealRecoverableStreamSlice(streamId);
2847
2949
  await this.schedule(
2848
2950
  0,
2849
2951
  "_piPlannedContinuation",
@@ -2854,8 +2956,21 @@ export abstract class AgentRuntimeKernel<
2854
2956
  },
2855
2957
  { idempotent: true },
2856
2958
  );
2959
+ await this.scheduleContinuationWatchdog(
2960
+ submissionId,
2961
+ submission.requestId,
2962
+ streamId,
2963
+ );
2857
2964
  return latest;
2858
2965
  }
2966
+ if (runResult) {
2967
+ this.db.transaction(() =>
2968
+ this.db.submissions.recordModelTurns(
2969
+ submissionId,
2970
+ runResult!.modelTurns,
2971
+ )
2972
+ );
2973
+ }
2859
2974
  const intent = terminalIntent ?? {
2860
2975
  outcome: "failed" as const,
2861
2976
  message:
@@ -2929,7 +3044,11 @@ export abstract class AgentRuntimeKernel<
2929
3044
  recoveryOutcome !== "disabled" &&
2930
3045
  !this.readSubmission(submissionId)?.abortReason
2931
3046
  ) {
2932
- if (recovery && recoveryOutcome === "scheduled") throw error;
3047
+ // 只有恢复通道的调用方(`_chatRecoveryRetry`)靠这次重抛判定 "scheduled"
3048
+ // 让步续跑由 alarm 驱动,重抛只会把那次 alarm 变成一次失败。
3049
+ if (mode === "recovery" && recoveryOutcome === "scheduled") {
3050
+ throw error;
3051
+ }
2933
3052
  return this.readSubmission(submissionId)!;
2934
3053
  }
2935
3054
  } else if (!stoppedDuringRecovery) {
@@ -4024,19 +4143,104 @@ export abstract class AgentRuntimeKernel<
4024
4143
  }
4025
4144
  }
4026
4145
 
4027
- async _piPlannedContinuation(
4028
- data?: PlannedContinuationData,
4146
+ // 作用:为一个让步执行片排一次兜底唤醒。
4147
+ // 调用:`executeNonTerminalSubmission` 排出计划续跑之后立即调用。
4148
+ // 原因:计划续跑只有一次机会 —— schedule 抛错、alarm 回调抛错、实例中途被换掉,
4149
+ // 这一轮就再也没人唤醒了。看门狗是唯一能兜住未知丢失路径的东西;
4150
+ // 它按续跑标识判断有没有推进,所以正常情况下醒来即空转返回。
4151
+ private async scheduleContinuationWatchdog(
4152
+ submissionId: string,
4153
+ requestId: string,
4154
+ continuationId: string,
4029
4155
  ): Promise<void> {
4030
- if (!data?.submissionId || !data.requestId) return;
4156
+ try {
4157
+ await this.schedule(
4158
+ CONTINUATION_WATCHDOG_SECONDS,
4159
+ "_piContinuationWatchdog",
4160
+ { submissionId, requestId, continuationId },
4161
+ { idempotent: true },
4162
+ );
4163
+ } catch (error) {
4164
+ // 看门狗排不上不该反过来杀掉这一轮:计划续跑本身已经排好了。
4165
+ console.warn(
4166
+ "[pi-continuation-watchdog:degraded]",
4167
+ json({ submissionId, error: errorText(error) }),
4168
+ );
4169
+ }
4170
+ }
4171
+
4172
+ // 判断一条让步提交是否仍停在 `continuationId` 这一片上。
4173
+ // 计划续跑和看门狗都用它决定该不该接手,避免和更新的执行片重复派发。
4174
+ //
4175
+ // `allowUnrecorded` 给看门狗用:让步的落库万一没生效(记录已不在
4176
+ // pending/running),`continuationId` 会是 null,此时严格比对会把续跑和看门狗
4177
+ // 一起丢掉 —— 那正是这次要根除的静默挂死。计划续跑不放宽,它的职责就是去重。
4178
+ private stalledOnContinuation(
4179
+ data: PlannedContinuationData,
4180
+ { allowUnrecorded = false }: { allowUnrecorded?: boolean } = {},
4181
+ ): StoredSubmission | null {
4031
4182
  const submission = this.readSubmission(data.submissionId);
4032
4183
  if (
4033
4184
  !submission ||
4034
4185
  submission.requestId !== data.requestId ||
4035
4186
  isTerminalSubmissionStatus(submission.status)
4036
4187
  ) {
4037
- return;
4188
+ return null;
4189
+ }
4190
+ const onThisSlice = submission.continuationId === data.continuationId ||
4191
+ (allowUnrecorded && submission.continuationId === null);
4192
+ return onThisSlice ? submission : null;
4193
+ }
4194
+
4195
+ async _piPlannedContinuation(
4196
+ data?: PlannedContinuationData,
4197
+ ): Promise<void> {
4198
+ if (!data?.submissionId || !data.requestId || !data.continuationId) return;
4199
+ // 校验续跑标识:两条 alarm 同时到期时,晚到的那条对应的已经是上一片,
4200
+ // 放它进去会白跑一整个执行片。
4201
+ const submission = this.stalledOnContinuation(data);
4202
+ if (!submission) return;
4203
+ await this.submissions.continueAfterCurrent(submission.submissionId);
4204
+ }
4205
+
4206
+ /**
4207
+ * 在计划续跑丢失时接手一条让步提交。
4208
+ *
4209
+ * @remarks
4210
+ * Agents SDK 的调度器在 `scheduleContinuationWatchdog` 排的延时任务到期时按方法名调用。
4211
+ *
4212
+ * 正常情况下这一片早就推进了(`continuationId` 已换或已终态),此时直接返回。
4213
+ *
4214
+ * 走 `recover` 而不是 `continueAfterCurrent`,有两个都不能省的理由:
4215
+ * 一是续跑会丢通常就是实例中途没了,那属于崩溃,必须重放里程碑、补齐半路的
4216
+ * Tool,续跑模式恰恰跳过这些;二是这一轮可能正停在审批或客户端交互上等人
4217
+ * (被驱逐后内存里没有执行器,看起来就像「没人在跑」),只有恢复准备认得出
4218
+ * 这是合法停靠并原样放过 —— 续跑模式会直接在它上面重起一轮。
4219
+ *
4220
+ * `recover` 复用 `executions` 的实例内去重,因此和一片刚进序幕、还没登记执行器的
4221
+ * 竞态也一并合流,不会多跑一片。
4222
+ */
4223
+ async _piContinuationWatchdog(
4224
+ data?: PlannedContinuationData,
4225
+ ): Promise<void> {
4226
+ if (!data?.submissionId || !data.requestId || !data.continuationId) return;
4227
+ const submission = this.stalledOnContinuation(data, {
4228
+ allowUnrecorded: true,
4229
+ });
4230
+ if (!submission) return;
4231
+ try {
4232
+ await this.ensureRuntimeReady();
4233
+ await this.submissions.recover(submission.submissionId);
4234
+ } catch (error) {
4235
+ const latest = this.readSubmission(data.submissionId);
4236
+ if (latest && !isTerminalSubmissionStatus(latest.status)) {
4237
+ await this.submissions.finish(
4238
+ latest,
4239
+ "failed",
4240
+ `SpringBrand could not continue this turn: ${errorText(error)}`,
4241
+ );
4242
+ }
4038
4243
  }
4039
- await this.submissions.recoverAfterCurrent(submission.submissionId);
4040
4244
  }
4041
4245
 
4042
4246
  // 作用:Agent Tool 子运行开始后重算一次活动投影。