@springbrand/agent-runtime 0.3.0-alpha.2 → 0.3.0-alpha.21

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 (131) hide show
  1. package/package.json +11 -9
  2. package/src/adapter/cloudflare/agent.ts +233 -179
  3. package/src/adapter/cloudflare/define-agent.ts +252 -104
  4. package/src/adapter/cloudflare/index.ts +18 -0
  5. package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
  6. package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
  7. package/src/adapter/cloudflare/schema.ts +21 -2
  8. package/src/adapter/cloudflare/subagent/definition.ts +25 -13
  9. package/src/adapter/cloudflare/subagent/runner.ts +43 -50
  10. package/src/adapter/cloudflare/subagent/tools.ts +4 -4
  11. package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
  12. package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
  13. package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
  14. package/src/config/activation.ts +13 -15
  15. package/src/config/agent-context.ts +1 -1
  16. package/src/config/assembler.ts +48 -31
  17. package/src/config/assembly-snapshot.ts +1 -1
  18. package/src/config/definition.ts +1 -1
  19. package/src/config/harness-runtime.ts +77 -49
  20. package/src/config/index.ts +1 -1
  21. package/src/config/model-guidance.ts +20 -0
  22. package/src/config/ports.ts +57 -7
  23. package/src/config/{pinning.ts → prepared-runtime.ts} +59 -123
  24. package/src/config/public-contracts.ts +2 -2
  25. package/src/config/runtime-config.ts +2 -1
  26. package/src/context/assembly.ts +1 -1
  27. package/src/context/extension/assembly.ts +2 -2
  28. package/src/context/memory/budget/gate.ts +19 -3
  29. package/src/conversation/harness-resume.ts +89 -238
  30. package/src/conversation/harness-stream.ts +87 -71
  31. package/src/conversation/history.ts +154 -152
  32. package/src/conversation/message/contract.ts +28 -4
  33. package/src/conversation/message/conversion.ts +46 -5
  34. package/src/conversation/message/projection.ts +61 -21
  35. package/src/conversation/session-entry.ts +29 -0
  36. package/src/conversation/state.ts +23 -2
  37. package/src/harness/governance.ts +206 -0
  38. package/src/harness/index.ts +3 -0
  39. package/src/harness/session-harness.ts +269 -445
  40. package/src/harness/submission-operation.ts +15 -40
  41. package/src/index.ts +18 -8
  42. package/src/lib/artifacts.ts +18 -0
  43. package/src/lib/execution-level.ts +13 -0
  44. package/src/lib/prompt.ts +9 -10
  45. package/src/lib/token-estimate.ts +8 -0
  46. package/src/model-protocol/openrouter.ts +41 -0
  47. package/src/models/index.ts +200 -35
  48. package/src/models/openrouter/messages.ts +13 -8
  49. package/src/models/openrouter/openai.ts +200 -0
  50. package/src/models/openrouter/protocol-response.ts +85 -0
  51. package/src/models/provider-streams.ts +45 -0
  52. package/src/session/cloudflare/migration.ts +382 -864
  53. package/src/session/cloudflare/storage.ts +13 -536
  54. package/src/session/index.ts +22 -35
  55. package/src/skills/springbrand-worker-website/index.ts +1 -1
  56. package/src/submission/admission.ts +20 -30
  57. package/src/submission/contracts.ts +42 -2
  58. package/src/submission/delivery.ts +1 -1
  59. package/src/submission/finalization.ts +36 -15
  60. package/src/submission/lifecycle.ts +28 -15
  61. package/src/submission/repository.ts +97 -49
  62. package/src/submission/steer-delivery.ts +32 -76
  63. package/src/submission/unattended.ts +40 -0
  64. package/src/telemetry/contract.ts +0 -1
  65. package/src/telemetry/coordinator.ts +1 -1
  66. package/src/telemetry/runtime-telemetry.ts +0 -4
  67. package/src/telemetry/subagent-lifecycle.ts +1 -1
  68. package/src/telemetry/turn-events.ts +35 -25
  69. package/src/tools/README.md +44 -0
  70. package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
  71. package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
  72. package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
  73. package/src/tools/assistant/ask-user.ts +319 -0
  74. package/src/tools/assistant/candidate.ts +15 -0
  75. package/src/tools/assistant/followups.ts +34 -0
  76. package/src/tools/assistant/index.ts +19 -0
  77. package/src/tools/assistant/memory.ts +73 -0
  78. package/src/tools/assistant/plan.ts +98 -0
  79. package/src/tools/{time.ts → assistant/time.ts} +1 -1
  80. package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
  81. package/src/tools/{core.ts → codemode/index.ts} +36 -92
  82. package/src/tools/codemode/workspace-connector.ts +198 -0
  83. package/src/tools/extension/index.ts +42 -0
  84. package/src/tools/index.ts +29 -26
  85. package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
  86. package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
  87. package/src/tools/runtime/activation.ts +40 -0
  88. package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
  89. package/src/tools/runtime/compiler.ts +246 -0
  90. package/src/tools/runtime/contracts.ts +140 -0
  91. package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
  92. package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
  93. package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
  94. package/src/tools/runtime/result.ts +22 -0
  95. package/src/tools/runtime/unattended-error.ts +4 -0
  96. package/src/tools/sandbox/index.ts +228 -0
  97. package/src/tools/schedule/index.ts +301 -0
  98. package/src/tools/skill/contracts.ts +20 -0
  99. package/src/tools/skill/index.ts +99 -0
  100. package/src/tools/skill/resources.ts +322 -0
  101. package/src/tools/skill/scripts.ts +168 -0
  102. package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
  103. package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
  104. package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
  105. package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
  106. package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
  107. package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
  108. package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
  109. package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
  110. package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
  111. package/src/tools/web/index.ts +2 -0
  112. package/src/tools/web/search/api.ts +1322 -0
  113. package/src/tools/web/search/index.ts +2 -0
  114. package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
  115. package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
  116. package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
  117. package/src/tools/base.ts +0 -534
  118. package/src/tools/compiler.ts +0 -327
  119. package/src/tools/schedule.ts +0 -261
  120. package/src/tools/skill.ts +0 -565
  121. package/src/tools/web-search/api.ts +0 -1318
  122. package/src/tools/web-search/index.ts +0 -2
  123. package/src/tools/workspace-state-connector.ts +0 -146
  124. package/src/tools/{approval → runtime/approval}/index.ts +0 -0
  125. package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
  126. package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
  127. package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
  128. package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
  129. package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
  130. package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
  131. /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
@@ -1,5 +1,4 @@
1
1
  import type { UIMessage } from "ai";
2
- import { validateRuntimeAssemblyPin, type RuntimeAssemblyPin } from "../config/pinning";
3
2
  import type {
4
3
  RuntimeSubmissionAdmissionHook,
5
4
  RuntimeSubmissionAdmissionResult,
@@ -12,10 +11,12 @@ import {
12
11
  SubmissionQueueFullError,
13
12
  type SubmissionInput,
14
13
  } from "./lifecycle";
15
- import { isTerminalSubmissionStatus, submissionAbortReason, type StoredSubmission, type SubmissionRepository } from "./repository";
14
+ import { isTerminalSubmissionStatus, submissionAbortReason, type StoredSubmission, type SubmissionOrigin, type SubmissionRepository } from "./repository";
15
+ import type { RuntimeUnattendedExecutionGrant } from "./contracts";
16
+ import type { ExecutionLevel } from "../lib/execution-level";
16
17
 
17
18
  // Pending records retain identity indefinitely; only recent attempts reserve capacity.
18
- const ADMISSION_ATTEMPT_WINDOW_MS = 60_000;
19
+ export const ADMISSION_ATTEMPT_WINDOW_MS = 60_000;
19
20
 
20
21
  export interface SubmitMessageOptions {
21
22
  requestId: string;
@@ -23,6 +24,12 @@ export interface SubmitMessageOptions {
23
24
  userMessageId?: string;
24
25
  userMessage?: UIMessage & { role: "user" };
25
26
  regenerate?: boolean;
27
+ /** `scheduled` marks a Run injection that locks ordinary input until it ends. */
28
+ origin?: SubmissionOrigin;
29
+ /** Maximum execution level allowed by the Schedule Definition. */
30
+ scheduledMaxExecutionLevel?: ExecutionLevel;
31
+ /** Trusted Host grant; accepted only for a scheduled Submission. */
32
+ unattendedExecutionGrant?: RuntimeUnattendedExecutionGrant;
26
33
  /** Internal transfer of a durable accepted steer; never a public message option. */
27
34
  steerHandoff?: { submissionId: string; steerId: string };
28
35
  }
@@ -169,8 +176,6 @@ export class SubmissionAdmission {
169
176
  requestId,
170
177
  idempotencyKey: idempotencyKey ?? null,
171
178
  createdAt: userMessage.timestamp,
172
- assemblyRevision: "",
173
- assemblyDescriptor: "",
174
179
  runtimeKey: runtime.runtimeKey,
175
180
  assistantMessageId: crypto.randomUUID(),
176
181
  queuedInputJson: JSON.stringify(userMessage),
@@ -182,6 +187,9 @@ export class SubmissionAdmission {
182
187
  ? options.userMessageId ?? null
183
188
  : null,
184
189
  runId,
190
+ origin: options.origin,
191
+ scheduledMaxExecutionLevel: options.scheduledMaxExecutionLevel,
192
+ unattendedExecutionGrant: options.unattendedExecutionGrant,
185
193
  });
186
194
  const admitted = this.options.submissions.find(runId)!;
187
195
  this.options.onAdmitted(admitted, admission?.accepted === true);
@@ -199,38 +207,20 @@ export class SubmissionAdmission {
199
207
  };
200
208
  }
201
209
 
202
- /** Validate before loading capabilities, then reuse or atomically persist the first-run pin. */
203
- async pin(
210
+ /** Resolve the saved Agent identity against its current Config before every execution slice. */
211
+ async prepareCurrentRuntime(
204
212
  submission: StoredSubmission,
205
- preparePin: () => Promise<RuntimeAssemblyPin>,
206
- restoreRuntime?: (runtimeKey: string) => Promise<void>,
213
+ restoreRuntime: (runtimeKey: string) => Promise<void>,
207
214
  ): Promise<StoredSubmission> {
208
215
  const current = this.options.submissions.find(submission.submissionId);
209
216
  if (!current) throw new Error(`Unknown SpringBrand submission: ${submission.submissionId}`);
210
- const hasPin = await validateRuntimeAssemblyPin(current.runtimeKey, {
211
- revision: current.assemblyRevision,
212
- descriptor: current.assemblyDescriptor,
213
- });
214
- await restoreRuntime?.(current.runtimeKey);
217
+ if (!current.runtimeKey) throw new Error("Cannot resume a Submission without its Agent Config ID");
218
+ await restoreRuntime(current.runtimeKey);
215
219
  const restored = this.options.submissions.find(current.submissionId);
216
220
  if (!restored || isTerminalSubmissionStatus(restored.status)) {
217
- throw new Error("Submission ended before Runtime assembly was pinned");
218
- }
219
- if (hasPin) return restored;
220
- const pin = await preparePin();
221
- if (!await validateRuntimeAssemblyPin(current.runtimeKey, pin)) {
222
- throw new Error("Submission assembly pin is incomplete");
221
+ throw new Error("Submission ended while loading current Agent Config");
223
222
  }
224
- this.options.submissions.pinAssembly(current.submissionId, pin.revision, pin.descriptor);
225
- const pinned = this.options.submissions.find(current.submissionId);
226
- if (!pinned?.assemblyRevision || !pinned.assemblyDescriptor || isTerminalSubmissionStatus(pinned.status)) {
227
- throw new Error("Submission ended before Runtime assembly was pinned");
228
- }
229
- await validateRuntimeAssemblyPin(pinned.runtimeKey, {
230
- revision: pinned.assemblyRevision,
231
- descriptor: pinned.assemblyDescriptor,
232
- });
233
- return pinned;
223
+ return restored;
234
224
  }
235
225
 
236
226
  /** The caller shares this synchronous write with its admission/timeout transaction. */
@@ -13,6 +13,8 @@ export const APPROVAL_DECISIONS = [
13
13
  "allow_level",
14
14
  ] as const satisfies readonly ApprovalDecision["decision"][];
15
15
 
16
+ export type { RuntimeUnattendedExecutionGrant } from "./unattended";
17
+
16
18
  /**
17
19
  * 记录一次待审批 Tool 执行的身份、摘要和原始输入。
18
20
  *
@@ -58,12 +60,35 @@ export type ScheduleJob =
58
60
  *
59
61
  * 触发条件与工作放在同一份规格里,使更新时可以原地替换两者,不用改变任务标识。
60
62
  */
63
+ /**
64
+ * 一条按任务时区解释的当地时间规则。
65
+ *
66
+ * @remarks
67
+ * `once` 在单个本地日历日触发;`daily` 每个本地日触发;`weekly` 在选中的每个本地星期触发
68
+ * (0 = 周日 … 6 = 周六)。所有星期共用同一个本地钟点。
69
+ *
70
+ * 规则只描述当地墙上时间;缺失/重复钟点、起止日期和夏令时语义由
71
+ * `adapter/cloudflare/scheduling` 的公共计算边界处理。
72
+ */
73
+ export type LocalScheduleRule =
74
+ | { kind: "once"; date: string; time: string }
75
+ | { kind: "daily"; time: string }
76
+ | { kind: "weekly"; weekdays: number[]; time: string };
77
+
61
78
  export interface ScheduleSpec {
62
79
  trigger:
63
80
  | { kind: "once-at"; at: string }
64
81
  | { kind: "once-in"; seconds: number }
65
82
  | { kind: "cron"; cron: string }
66
- | { kind: "interval"; seconds: number };
83
+ | { kind: "interval"; seconds: number }
84
+ | {
85
+ kind: "local";
86
+ rule: LocalScheduleRule;
87
+ /** 含当天的最早生效本地日期 `YYYY-MM-DD`。 */
88
+ startDate?: string;
89
+ /** 含当天的最后生效本地日期 `YYYY-MM-DD`。 */
90
+ endDate?: string;
91
+ };
67
92
  job: ScheduleJob;
68
93
  label?: string;
69
94
  /**
@@ -115,7 +140,9 @@ export type MessageDelivery = "steer" | "enqueue";
115
140
  export type MessageDispatchErrorCode =
116
141
  | "queue_full"
117
142
  | "not_steerable"
118
- | "invalid_message";
143
+ | "invalid_message"
144
+ /** A scheduled Run owns this Session's input until it reaches a terminal state. */
145
+ | "input_locked";
119
146
 
120
147
  export type MessageDispatchReceipt =
121
148
  | {
@@ -139,3 +166,16 @@ export type MessageDispatchReceipt =
139
166
  code: MessageDispatchErrorCode;
140
167
  message: string;
141
168
  };
169
+
170
+ /** Complete, credential-free task version prepared by a trusted Host for human confirmation. */
171
+ export interface RuntimeSchedulePreparation {
172
+ candidateId: string;
173
+ candidateVersion: string;
174
+ id?: string;
175
+ prompt: string;
176
+ label: string;
177
+ trigger: ScheduleSpec["trigger"];
178
+ timezone: string;
179
+ nextRunAt?: number;
180
+ authorization: { version: string; missing: string[]; scope: string };
181
+ }
@@ -2,7 +2,7 @@ import type { UIMessage } from "ai";
2
2
  import type { PiCanonicalUserInput } from "../conversation/message/contract";
3
3
  import type { RuntimeTelemetryCoordinator } from "../telemetry/coordinator";
4
4
  import type { RuntimeTurnEvents } from "../telemetry/turn-events";
5
- import type { HarnessHumanDecisions } from "../tools/human-decisions";
5
+ import type { HarnessHumanDecisions } from "../tools/runtime/human-decisions";
6
6
  import type { SubmitMessageOptions } from "./admission";
7
7
  import {
8
8
  projectSubmissionReceipt,
@@ -1,10 +1,10 @@
1
1
  import type { AssistantMessage } from "@earendil-works/pi-ai";
2
- import type { CancelQueuedResult } from "@earendil-works/pi-agent-core";
2
+ import type { CancelQueuedResult } from "./steer-delivery";
3
3
  import type { ConversationHistory } from "../conversation/history";
4
4
  import type { MessageUiRepository } from "../conversation/repository";
5
5
  import type { RuntimeTelemetryCoordinator } from "../telemetry/coordinator";
6
6
  import type { RuntimeTurnEvents } from "../telemetry/turn-events";
7
- import type { HarnessHumanDecisions } from "../tools/human-decisions";
7
+ import type { HarnessHumanDecisions } from "../tools/runtime/human-decisions";
8
8
  import { USER_STOP_REASON } from "./contracts";
9
9
  import type { SubmissionOutcome } from "./lifecycle";
10
10
  import { isTerminalSubmissionStatus, type StoredSubmission, type SubmissionRepository } from "./repository";
@@ -13,6 +13,8 @@ import type { SteerRepository } from "./steer-repository";
13
13
 
14
14
  /** Commits the Submission outcome and human/Host facts after accepted steering is preserved. */
15
15
  export class SubmissionFinalizer {
16
+ /** User-stop markers appended after their terminal commit; every `finish()` of that Turn awaits it. */
17
+ private readonly stopMarkers = new Map<string, Promise<unknown>>();
16
18
  constructor(private readonly options: {
17
19
  submissions: SubmissionRepository;
18
20
  steers: SteerRepository;
@@ -34,7 +36,12 @@ export class SubmissionFinalizer {
34
36
  message?: string,
35
37
  ): Promise<StoredSubmission> {
36
38
  const initial = this.read(submissionId);
37
- if (isTerminalSubmissionStatus(initial.status)) return initial;
39
+ if (isTerminalSubmissionStatus(initial.status)) {
40
+ // A stop RPC and the ending harness finish the same Turn; whichever loses
41
+ // still waits for the stop marker, so its history broadcast carries it.
42
+ await this.stopMarkers.get(submissionId);
43
+ return initial;
44
+ }
38
45
  const aborted = Boolean(initial.abortReason) || outcome === "aborted";
39
46
  if (aborted) await this.options.releaseAbortedHarness();
40
47
  await finishPendingSteers({
@@ -53,7 +60,13 @@ export class SubmissionFinalizer {
53
60
  const output = (await this.options.conversation.storedMessages())
54
61
  .filter((entry) => entry.submissionId === submissionId && entry.message.role === "assistant")
55
62
  .at(-1)?.message;
56
- return this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
63
+ const completed = this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
64
+ const marker = this.stopMarkers.get(submissionId);
65
+ if (marker) {
66
+ await marker;
67
+ this.stopMarkers.delete(submissionId);
68
+ }
69
+ return completed;
57
70
  }
58
71
 
59
72
  /** Shares atomic terminal facts with an admitted handoff cancelled before execution. */
@@ -63,7 +76,8 @@ export class SubmissionFinalizer {
63
76
  message?: string,
64
77
  output?: AssistantMessage,
65
78
  ): StoredSubmission {
66
- return this.options.transaction(() => {
79
+ let stoppedByUser = false;
80
+ const completed = this.options.transaction(() => {
67
81
  // Host admission and history reads can await while an explicit stop wins.
68
82
  const latest = this.read(submissionId);
69
83
  if (isTerminalSubmissionStatus(latest.status)) return latest;
@@ -79,16 +93,9 @@ export class SubmissionFinalizer {
79
93
  const status = effectiveOutcome === "succeeded" ? "completed" : effectiveOutcome === "aborted" ? "aborted" : "error";
80
94
  this.options.submissions.updateTerminal(submissionId, status, reason, Date.now());
81
95
  const completed = this.read(submissionId);
82
- if (effectiveOutcome === "aborted" && reason === USER_STOP_REASON) {
83
- if (completed.completedAt === null) {
84
- throw new Error(
85
- `Aborted SpringBrand submission has no completion time: ${submissionId}`,
86
- );
87
- }
88
- this.options.conversation.appendTurnAbortedMarker(
89
- submissionId,
90
- completed.completedAt,
91
- );
96
+ stoppedByUser = effectiveOutcome === "aborted" && reason === USER_STOP_REASON;
97
+ if (stoppedByUser && completed.completedAt === null) {
98
+ throw new Error(`Aborted SpringBrand submission has no completion time: ${submissionId}`);
92
99
  }
93
100
  this.options.events.submissionTerminal(completed);
94
101
  this.options.telemetry.capture("turnFinished", {
@@ -101,6 +108,20 @@ export class SubmissionFinalizer {
101
108
  });
102
109
  return completed;
103
110
  });
111
+ if (stoppedByUser) this.appendStopMarker(submissionId, completed.completedAt!);
112
+ return completed;
113
+ }
114
+
115
+ /**
116
+ * The browser projects `interruptedByUser` from this marker and the next model request sees it as user context.
117
+ * Pi history writes are async, so the marker follows the synchronous terminal commit.
118
+ */
119
+ private appendStopMarker(submissionId: string, completedAt: number): void {
120
+ const marker = this.options.conversation.appendTurnAbortedMarker(submissionId, completedAt).catch((error: unknown) => {
121
+ console.warn("[runtime-stop-marker:failed]", submissionId, error instanceof Error ? error.message : String(error));
122
+ });
123
+ this.stopMarkers.set(submissionId, marker);
124
+ this.options.waitUntil(marker);
104
125
  }
105
126
 
106
127
  private read(submissionId: string): StoredSubmission {
@@ -130,9 +130,9 @@ export type SubmissionOutcome = "succeeded" | "failed" | "aborted";
130
130
  * @remarks
131
131
  * `SubmissionLifecycle` 把它交给 `execute`,Runtime 据此选择执行前置动作。
132
132
  *
133
- * `recovery` 和 `continuation` 必须分开:前者的进程内配置已经丢失,需重新装配原 runtime key 再打开 Session;
134
- * 后者是本轮主动让出执行片,持久 operation 和已固定配置保持完好。
135
- * 区分两者可避免主动让出时重复加载原 runtime key。
133
+ * `recovery` 和 `continuation` 都以持久化的 Agent 身份重新读取当前配置,
134
+ * 然后继续同一个持久 operation;前者处理进程恢复,后者处理本轮主动让出执行片。
135
+ * 两者都不沿用旧执行片的固定配置。
136
136
  */
137
137
  export type SubmissionExecutionMode =
138
138
  /** 首次执行一条新接收的提交。 */
@@ -244,6 +244,7 @@ export class SubmissionLifecycle<
244
244
  private readonly executions = new Map<string, Promise<TSubmission>>();
245
245
  private readonly activeBySubmission = new Map<string, TActive>();
246
246
  private readonly terminalCommits = new Map<string, Promise<TSubmission>>();
247
+ private preparingAdmissions = 0;
247
248
 
248
249
  // #region 提交与恢复
249
250
 
@@ -262,6 +263,13 @@ export class SubmissionLifecycle<
262
263
  >,
263
264
  ) {}
264
265
 
266
+ /** Serialize an explicit Runtime change with this Chat's execution queue. */
267
+ async runConfigControl<T>(run: () => Promise<T>): Promise<T> {
268
+ const result = await this.queue.enqueue(`runtime-config:${crypto.randomUUID()}`, run);
269
+ if (result.status === "stale") throw new Error("Runtime configuration control was reset");
270
+ return result.value;
271
+ }
272
+
265
273
  /**
266
274
  * 接收新提交,或让重复请求加入已有提交。
267
275
  *
@@ -275,17 +283,22 @@ export class SubmissionLifecycle<
275
283
  ): Promise<SubmissionHandle<TSubmission>> {
276
284
  const duplicate = this.findDuplicate(input);
277
285
  if (duplicate) return this.join(duplicate);
278
- const admitted = await this.admit(
279
- input,
280
- await input.prepareAdmission(),
281
- );
282
- if (admitted.admitted) this.pump();
283
- return {
284
- receipt: admitted.submission,
285
- completion: admitted.rejected
286
- ? Promise.resolve(admitted.submission)
287
- : this.waitForTerminal(admitted.submission.submissionId),
288
- };
286
+ this.preparingAdmissions += 1;
287
+ try {
288
+ const admitted = await this.admit(
289
+ input,
290
+ await input.prepareAdmission(),
291
+ );
292
+ if (admitted.admitted) this.pump();
293
+ return {
294
+ receipt: admitted.submission,
295
+ completion: admitted.rejected
296
+ ? Promise.resolve(admitted.submission)
297
+ : this.waitForTerminal(admitted.submission.submissionId),
298
+ };
299
+ } finally {
300
+ this.preparingAdmissions -= 1;
301
+ }
289
302
  }
290
303
 
291
304
  /**
@@ -551,7 +564,7 @@ export class SubmissionLifecycle<
551
564
  * 读取持久层而不是只看 `executions`,才能覆盖重启后尚未恢复的提交。
552
565
  */
553
566
  isBusy(): boolean {
554
- return this.options.store.countUnfinished() > 0;
567
+ return this.preparingAdmissions > 0 || this.options.store.countUnfinished() > 0;
555
568
  }
556
569
 
557
570
  // 作用:在有限时间内轮询 Runtime 是否已无活动提交。
@@ -1,5 +1,7 @@
1
1
  import type { SqlTaggedTemplate } from "agents/chat";
2
- import type { LaneSnapshot } from "@earendil-works/pi-agent-core";
2
+
3
+ import type { ExecutionLevel } from "../lib/execution-level";
4
+ import type { RuntimeUnattendedExecutionGrant } from "./unattended";
3
5
 
4
6
  // Submission 的统一定义见 ./index.ts。
5
7
  // #region 状态与行类型
@@ -11,6 +13,14 @@ export type SubmissionStatus =
11
13
  | "skipped"
12
14
  | "error";
13
15
 
16
+ /**
17
+ * 谁发起了这条 Submission。
18
+ *
19
+ * `scheduled` 是定时 Run 的注入:它在本 Session 未结束期间独占输入,
20
+ * 普通 `user` Submission 必须被拒绝,而不是排队等它结束。
21
+ */
22
+ export type SubmissionOrigin = "user" | "scheduled";
23
+
14
24
  const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
15
25
  "completed",
16
26
  "aborted",
@@ -36,8 +46,6 @@ export interface StoredSubmission {
36
46
  error: string | null;
37
47
  createdAt: number;
38
48
  completedAt: number | null;
39
- assemblyRevision: string;
40
- assemblyDescriptor: string;
41
49
  runtimeKey: string;
42
50
  assistantMessageId: string;
43
51
  abortReason: string | null;
@@ -46,9 +54,24 @@ export interface StoredSubmission {
46
54
  userMessageId: string | null;
47
55
  regenerateMessageId: string | null;
48
56
  runId: string | null;
57
+ origin: SubmissionOrigin;
58
+ scheduledMaxExecutionLevel: ExecutionLevel | null;
59
+ unattendedExecutionGrant: RuntimeUnattendedExecutionGrant | null;
60
+ unattendedFailure: string | null;
49
61
  retryable?: boolean;
50
62
  }
51
63
 
64
+ /** Saved key and whether its Submission can still execute. */
65
+ export interface StoredRuntimeKey {
66
+ readonly runtimeKey: string;
67
+ readonly unfinished: boolean;
68
+ }
69
+
70
+ export interface RuntimeKeyRewrite {
71
+ readonly from: string;
72
+ readonly to: string;
73
+ }
74
+
52
75
  /** Reuse durable stop intent when transferring work out of the source Submission. */
53
76
  export function submissionAbortReason(submission: StoredSubmission | null): string | undefined {
54
77
  return submission?.abortReason ||
@@ -60,8 +83,6 @@ export interface NewSubmission {
60
83
  requestId: string;
61
84
  idempotencyKey: string | null;
62
85
  createdAt: number;
63
- assemblyRevision: string;
64
- assemblyDescriptor: string;
65
86
  runtimeKey?: string;
66
87
  assistantMessageId: string;
67
88
  queuedInputJson?: string | null;
@@ -69,6 +90,9 @@ export interface NewSubmission {
69
90
  userMessageId?: string | null;
70
91
  regenerateMessageId?: string | null;
71
92
  runId?: string | null;
93
+ origin?: SubmissionOrigin;
94
+ scheduledMaxExecutionLevel?: ExecutionLevel;
95
+ unattendedExecutionGrant?: RuntimeUnattendedExecutionGrant;
72
96
  }
73
97
 
74
98
  type SubmissionRow = {
@@ -80,8 +104,6 @@ type SubmissionRow = {
80
104
  error: string | null;
81
105
  created_at: number;
82
106
  completed_at: number | null;
83
- assembly_revision: string;
84
- assembly_descriptor: string;
85
107
  runtime_key: string;
86
108
  assistant_message_id: string;
87
109
  abort_reason: string | null;
@@ -90,7 +112,11 @@ type SubmissionRow = {
90
112
  user_message_id: string | null;
91
113
  regenerate_message_id: string | null;
92
114
  run_id: string | null;
115
+ origin: string | null;
116
+ scheduled_max_execution_level: string | null;
93
117
  admission_retryable: number | null;
118
+ unattended_execution_grant_json: string | null;
119
+ unattended_failure: string | null;
94
120
  };
95
121
 
96
122
  // #endregion
@@ -108,8 +134,6 @@ function mapRow(row: SubmissionRow): StoredSubmission {
108
134
  error: row.error,
109
135
  createdAt: row.created_at,
110
136
  completedAt: row.completed_at,
111
- assemblyRevision: row.assembly_revision,
112
- assemblyDescriptor: row.assembly_descriptor,
113
137
  runtimeKey: row.runtime_key,
114
138
  assistantMessageId: row.assistant_message_id,
115
139
  abortReason: row.abort_reason,
@@ -118,6 +142,12 @@ function mapRow(row: SubmissionRow): StoredSubmission {
118
142
  userMessageId: row.user_message_id,
119
143
  regenerateMessageId: row.regenerate_message_id,
120
144
  runId: row.run_id,
145
+ origin: row.origin === "scheduled" ? "scheduled" : "user",
146
+ scheduledMaxExecutionLevel: row.scheduled_max_execution_level as ExecutionLevel | null,
147
+ unattendedExecutionGrant: row.unattended_execution_grant_json
148
+ ? JSON.parse(row.unattended_execution_grant_json) as RuntimeUnattendedExecutionGrant
149
+ : null,
150
+ unattendedFailure: row.unattended_failure,
121
151
  retryable: row.admission_retryable === null
122
152
  ? undefined
123
153
  : row.admission_retryable === 1,
@@ -139,18 +169,44 @@ export class SubmissionRepository {
139
169
  return this.transact(run);
140
170
  }
141
171
 
172
+ /** Return saved identities for a one-time Host-owned key migration. */
173
+ listRuntimeKeys(): StoredRuntimeKey[] {
174
+ return this.sql<{ runtime_key: string; unfinished: number }>`
175
+ SELECT runtime_key,
176
+ MAX(CASE WHEN status IN ('pending', 'running') THEN 1 ELSE 0 END) AS unfinished
177
+ FROM pi_submissions
178
+ WHERE runtime_key <> ''
179
+ GROUP BY runtime_key
180
+ ORDER BY runtime_key
181
+ `.map((row) => ({ runtimeKey: row.runtime_key, unfinished: row.unfinished === 1 }));
182
+ }
183
+
184
+ /** Rewrite each saved identity atomically after the Host validates active work. */
185
+ rewriteRuntimeKeys(rewrites: readonly RuntimeKeyRewrite[]): void {
186
+ this.transact(() => {
187
+ for (const { from, to } of rewrites) {
188
+ if (from === to) continue;
189
+ this.sql`UPDATE pi_submissions SET runtime_key = ${to} WHERE runtime_key = ${from}`;
190
+ }
191
+ });
192
+ }
193
+
142
194
  // 按 submissionId 读取一条完整 Submission,不存在时返回 null。
143
195
  // Runtime 和 SubmissionLifecycle 在准入、执行、取消、结束与恢复时调用,调用方必须处理 null。
144
196
  // 所有列在这里一次投影并统一映射,不能让不同调用方各自拼出不完整的 Submission。
145
197
  find(id: string): StoredSubmission | null {
146
198
  const row = this.sql<SubmissionRow>`
147
199
  SELECT submission_id, request_id, idempotency_key, status, accepted,
148
- error, created_at, completed_at, assembly_revision,
149
- assembly_descriptor, runtime_key, assistant_message_id, abort_reason,
200
+ error, created_at, completed_at,
201
+ runtime_key, assistant_message_id, abort_reason,
150
202
  queued_input_json, queued_ui_message_json, user_message_id,
151
203
  regenerate_message_id,
152
204
  run_id,
153
- admission_retryable
205
+ origin,
206
+ scheduled_max_execution_level,
207
+ admission_retryable,
208
+ unattended_execution_grant_json,
209
+ unattended_failure
154
210
  FROM pi_submissions
155
211
  WHERE submission_id = ${id}
156
212
  `[0];
@@ -248,10 +304,10 @@ export class SubmissionRepository {
248
304
  this.sql`
249
305
  INSERT INTO pi_submissions (
250
306
  submission_id, request_id, idempotency_key, status, accepted,
251
- created_at, assembly_revision, assembly_descriptor, runtime_key,
307
+ created_at, runtime_key,
252
308
  assistant_message_id, queued_input_json, queued_ui_message_json,
253
309
  user_message_id, regenerate_message_id,
254
- run_id
310
+ run_id, origin, scheduled_max_execution_level, unattended_execution_grant_json
255
311
  ) VALUES (
256
312
  ${s.submissionId},
257
313
  ${s.requestId},
@@ -259,19 +315,37 @@ export class SubmissionRepository {
259
315
  'pending',
260
316
  1,
261
317
  ${s.createdAt},
262
- ${s.assemblyRevision},
263
- ${s.assemblyDescriptor},
264
318
  ${s.runtimeKey ?? ""},
265
319
  ${s.assistantMessageId},
266
320
  ${s.queuedInputJson ?? null},
267
321
  ${s.queuedUiMessageJson ?? null},
268
322
  ${s.userMessageId ?? null},
269
323
  ${s.regenerateMessageId ?? null},
270
- ${s.runId ?? s.submissionId}
324
+ ${s.runId ?? s.submissionId},
325
+ ${s.origin ?? "user"},
326
+ ${s.scheduledMaxExecutionLevel ?? null},
327
+ ${s.unattendedExecutionGrant
328
+ ? JSON.stringify(s.unattendedExecutionGrant)
329
+ : null}
271
330
  )
272
331
  `;
273
332
  }
274
333
 
334
+ /**
335
+ * 是否存在一条未结束的定时 Submission。
336
+ *
337
+ * 定时 Run 未结束时它独占本 Session 的输入:普通消息在准入前被拒绝,
338
+ * 不排队等待。Run 进入终态后查询自然为空,普通追问恢复。
339
+ */
340
+ hasUnfinishedScheduled(): boolean {
341
+ return (
342
+ this.sql<{ count: number }>`
343
+ SELECT COUNT(*) AS count FROM pi_submissions
344
+ WHERE status IN ('pending', 'running') AND origin = 'scheduled'
345
+ `[0]?.count ?? 0
346
+ ) > 0;
347
+ }
348
+
275
349
  insertRejected(s: {
276
350
  submissionId: string;
277
351
  requestId: string;
@@ -285,29 +359,24 @@ export class SubmissionRepository {
285
359
  this.sql`
286
360
  INSERT INTO pi_submissions (
287
361
  submission_id, request_id, idempotency_key, status, accepted,
288
- error, created_at, completed_at, assembly_revision,
289
- assembly_descriptor, runtime_key, assistant_message_id, abort_reason,
362
+ error, created_at, completed_at,
363
+ runtime_key, assistant_message_id, abort_reason,
290
364
  run_id, admission_retryable, user_message_id
291
365
  ) VALUES (
292
366
  ${s.submissionId}, ${s.requestId}, ${s.idempotencyKey}, 'skipped', 0,
293
- ${s.code}, ${s.createdAt}, ${s.createdAt}, '', '', '', ${s.assistantMessageId ?? s.submissionId}, NULL,
367
+ ${s.code}, ${s.createdAt}, ${s.createdAt}, '', ${s.assistantMessageId ?? s.submissionId}, NULL,
294
368
  ${s.submissionId}, ${s.retryable ? 1 : 0}, ${s.userMessageId ?? null}
295
369
  )
296
370
  `;
297
371
  }
298
372
 
299
- pinAssembly(id: string, revision: string, descriptor: string): boolean {
300
- const updated = this.sql<{ submission_id: string }>`
373
+ recordUnattendedFailure(id: string, reason: string): void {
374
+ this.sql`
301
375
  UPDATE pi_submissions
302
- SET assembly_revision = ${revision},
303
- assembly_descriptor = ${descriptor}
376
+ SET unattended_failure = COALESCE(unattended_failure, ${reason})
304
377
  WHERE submission_id = ${id}
305
- AND assembly_revision = ''
306
- AND assembly_descriptor = ''
307
378
  AND status IN ('pending', 'running')
308
- RETURNING submission_id
309
379
  `;
310
- return updated.length === 1;
311
380
  }
312
381
 
313
382
  // 只在当前状态属于 from 时把 Submission 切换到 to,否则返回 false。
@@ -386,27 +455,6 @@ export class SubmissionRepository {
386
455
  `;
387
456
  }
388
457
 
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
- }
409
- }
410
458
 
411
459
  listSessionEntrySubmissionLinks(): Map<string, string> {
412
460
  const rows = this.sql<{