@springbrand/agent-runtime 0.3.0-alpha.4 → 0.3.0-alpha.5

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.3.0-alpha.4",
3
+ "version": "0.3.0-alpha.5",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -33,8 +33,10 @@ import {
33
33
  type SubmissionReceipt,
34
34
  type SubmissionExecutionMode,
35
35
  type SubmissionHandle,
36
+ type RuntimeUnattendedExecutionGrant,
36
37
  } from "../../submission";
37
38
  import type { ExecutionLevel } from "../../lib/execution-level";
39
+ import { pinnedUnattendedExecutionGrant } from "../../submission/unattended";
38
40
  import {
39
41
  HarnessRuntimeConfig,
40
42
  activateRuntimeCandidate,
@@ -98,6 +100,10 @@ import { RuntimeDatabase } from "./database";
98
100
  // #region Runtime 内部数据与基础工具
99
101
 
100
102
  const SCHEDULED_STABLE_TIMEOUT_MS = 30_000;
103
+ /** 定时 Run 未结束时拒绝普通输入的统一原因,服务端与客户端状态共用同一事实。 */
104
+ const SCHEDULED_INPUT_LOCKED_MESSAGE =
105
+ "This scheduled run is still in progress. Wait for it to finish before sending a message.";
106
+
101
107
  interface ActiveTurn {
102
108
  submissionId: string;
103
109
  requestId: string;
@@ -390,7 +396,7 @@ export abstract class AgentRuntimeKernel<
390
396
  }
391
397
  await this.broadcastApprovals();
392
398
  const running = this.db.submissions.findRunning() as StoredSubmission | null;
393
- if (running) {
399
+ if (running && !running.unattendedFailure) {
394
400
  if (
395
401
  this.db.approvals.listPendingForSubmission(running.submissionId).length > 0 ||
396
402
  this.db.interactions.listPendingForSubmission(running.submissionId).length > 0
@@ -785,6 +791,8 @@ export abstract class AgentRuntimeKernel<
785
791
  submissionId,
786
792
  requestId: submission.requestId,
787
793
  executionLevel: activeRuntime.snapshot.profile.executionLevel,
794
+ origin: submission.origin,
795
+ unattendedExecutionGrant: submission.unattendedExecutionGrant,
788
796
  },
789
797
  candidates: harnessInput.candidates,
790
798
  telemetry: this.telemetry,
@@ -812,6 +820,12 @@ export abstract class AgentRuntimeKernel<
812
820
  }
813
821
  },
814
822
  gateTool: harnessInput.platform.gateTool,
823
+ authorizeUnattendedExecution:
824
+ harnessInput.platform.authorizeUnattendedExecution,
825
+ onUnattendedFailure: (reason) => {
826
+ this.db.submissions.recordUnattendedFailure(submissionId, reason);
827
+ },
828
+ unattendedFailure: () => this.db.submissions.find(submissionId)?.unattendedFailure,
815
829
  });
816
830
  runtime = await openSessionHarness({
817
831
  session: openCloudflareSession(this.ctx.storage),
@@ -824,6 +838,7 @@ export abstract class AgentRuntimeKernel<
824
838
  workspace: harnessInput.workspace,
825
839
  authorize: gate.authorize.bind(gate),
826
840
  resolveReplay: gate.resolveReplay.bind(gate),
841
+ resolveConfirmation: gate.resolveConfirmation.bind(gate),
827
842
  }),
828
843
  modelToolResultExemptions: harnessInput.candidates
829
844
  .filter(({ outputBudget }) => outputBudget?.kind === "structure")
@@ -838,12 +853,13 @@ export abstract class AgentRuntimeKernel<
838
853
  try {
839
854
  runtime.harness.hooks.on("before_tool", gate.beforeTool);
840
855
  runtime.harness.hooks.on("before_drive", async (event, context) => {
841
- if (this.db.submissions.find(submissionId)?.abortReason) {
856
+ const current = this.db.submissions.find(submissionId);
857
+ if (current?.abortReason || current?.unattendedFailure) {
842
858
  await runtime.requestAbort(event.runId, context);
843
859
  }
844
860
  });
845
861
 
846
- if (submission.abortReason && runtime.openOperation) {
862
+ if ((submission.abortReason || submission.unattendedFailure) && runtime.openOperation) {
847
863
  await runtime.requestAbort(
848
864
  runtime.openOperation.operationId,
849
865
  BACKGROUND_CONTEXT,
@@ -860,11 +876,15 @@ export abstract class AgentRuntimeKernel<
860
876
  );
861
877
  if (preparation.kind === "settled") {
862
878
  const record = preparation.outcome;
879
+ const unattendedFailure = this.db.submissions.find(submissionId)
880
+ ?.unattendedFailure;
863
881
  const terminal = await this.submissions.finish(
864
882
  submission,
865
- record.status === "completed" || record.status === "declined"
883
+ unattendedFailure
884
+ ? "failed"
885
+ : record.status === "completed" || record.status === "declined"
866
886
  ? "succeeded" : record.status === "aborted" ? "aborted" : "failed",
867
- record.error?.message,
887
+ unattendedFailure ?? record.error?.message,
868
888
  );
869
889
  this.broadcast(json({
870
890
  type: MessageType.CF_AGENT_CHAT_MESSAGES,
@@ -989,7 +1009,11 @@ export abstract class AgentRuntimeKernel<
989
1009
  return this.db.submissions.find(submissionId)!;
990
1010
  }
991
1011
  const record = outcome.outcome;
992
- const status = record.status === "completed" || record.status === "declined"
1012
+ const unattendedFailure = this.db.submissions.find(submissionId)
1013
+ ?.unattendedFailure;
1014
+ const status = unattendedFailure
1015
+ ? "failed"
1016
+ : record.status === "completed" || record.status === "declined"
993
1017
  ? "succeeded"
994
1018
  : record.status === "aborted"
995
1019
  ? "aborted"
@@ -998,7 +1022,7 @@ export abstract class AgentRuntimeKernel<
998
1022
  const terminal = await this.submissions.finish(
999
1023
  submission,
1000
1024
  status,
1001
- record.error?.message,
1025
+ unattendedFailure ?? record.error?.message,
1002
1026
  );
1003
1027
  const projectedOutcome = projectSubmissionOutcome(record, terminal);
1004
1028
  await stream.flushTerminal(projectedOutcome);
@@ -1323,11 +1347,41 @@ export abstract class AgentRuntimeKernel<
1323
1347
  this.transcript.importSnapshot(snapshot);
1324
1348
  }
1325
1349
 
1350
+ /**
1351
+ * 本 Session 是否有一条未结束的定时 Submission。
1352
+ *
1353
+ * 定时 Run 未结束时独占输入:普通消息在准入前被拒绝,不排队等它结束。
1354
+ * 定时注入本身走 `submitScheduledPrompt`,不经过这道闸。
1355
+ */
1356
+ private get scheduledInputLocked(): boolean {
1357
+ return this.db.submissions.hasUnfinishedScheduled();
1358
+ }
1359
+
1360
+ private rejectedOrdinaryInputReceipt(): SubmissionReceipt {
1361
+ return {
1362
+ submissionId: crypto.randomUUID(),
1363
+ status: "skipped",
1364
+ accepted: false,
1365
+ error: SCHEDULED_INPUT_LOCKED_MESSAGE,
1366
+ retryable: false,
1367
+ createdAt: Date.now(),
1368
+ };
1369
+ }
1370
+
1371
+ private rejectInputLocked(): MessageDispatchReceipt {
1372
+ return {
1373
+ kind: "rejected",
1374
+ code: "input_locked",
1375
+ message: SCHEDULED_INPUT_LOCKED_MESSAGE,
1376
+ };
1377
+ }
1378
+
1326
1379
  /** Admit text and return immediately; completion continues in the Durable Object. */
1327
1380
  async submitPrompt(
1328
1381
  text: string,
1329
1382
  options?: { idempotencyKey?: string },
1330
1383
  ): Promise<SubmissionReceipt> {
1384
+ if (this.scheduledInputLocked) return this.rejectedOrdinaryInputReceipt();
1331
1385
  const submitted = await this.submitMessage({
1332
1386
  role: "user",
1333
1387
  content: [{ type: "text", text }],
@@ -1357,17 +1411,22 @@ export abstract class AgentRuntimeKernel<
1357
1411
  }
1358
1412
 
1359
1413
  async dispatchMessage(message: UIMessage, delivery: MessageDelivery): Promise<MessageDispatchReceipt> {
1414
+ if (this.scheduledInputLocked) return this.rejectInputLocked();
1360
1415
  return this.delivery.dispatch(message, delivery);
1361
1416
  }
1362
1417
 
1363
1418
  async steerQueuedSubmission(submissionId: string): Promise<MessageDispatchReceipt> {
1419
+ if (this.scheduledInputLocked) return this.rejectInputLocked();
1364
1420
  return this.delivery.steerQueued(submissionId);
1365
1421
  }
1366
1422
 
1367
1423
  /** Admit scheduled work after a bounded stable wait, otherwise return skipped. */
1368
1424
  async submitScheduledPrompt(
1369
1425
  prompt: string,
1370
- options?: { idempotencyKey?: string },
1426
+ options?: {
1427
+ idempotencyKey?: string;
1428
+ unattendedExecutionGrant?: RuntimeUnattendedExecutionGrant;
1429
+ },
1371
1430
  ): Promise<SubmissionReceipt> {
1372
1431
  const requestId = crypto.randomUUID();
1373
1432
  const submitted = await this.submitMessageWhenStable(
@@ -1379,6 +1438,10 @@ export abstract class AgentRuntimeKernel<
1379
1438
  {
1380
1439
  requestId,
1381
1440
  idempotencyKey: options?.idempotencyKey,
1441
+ origin: "scheduled",
1442
+ unattendedExecutionGrant: options?.unattendedExecutionGrant
1443
+ ? pinnedUnattendedExecutionGrant(options.unattendedExecutionGrant)
1444
+ : undefined,
1382
1445
  },
1383
1446
  SCHEDULED_STABLE_TIMEOUT_MS,
1384
1447
  );
@@ -1463,7 +1526,7 @@ export abstract class AgentRuntimeKernel<
1463
1526
  summary: approval.summary,
1464
1527
  executionLevel: approval.executionLevel,
1465
1528
  requiredExecutionLevel: approval.requiredExecutionLevel,
1466
- inputJson: approval.inputJson,
1529
+ inputJson: approval.confirmationJson ?? approval.inputJson,
1467
1530
  requestId: approval.requestId,
1468
1531
  };
1469
1532
  }
@@ -1573,6 +1636,7 @@ export abstract class AgentRuntimeKernel<
1573
1636
  hasPendingInteraction: (id) => this.humanDecisions.isWaitingForInteraction(id),
1574
1637
  hasPendingSteer: (id) => this.db.steers.listForSubmission(id).length > 0,
1575
1638
  hasRunningAgentTools: this.db.agentTools.hasRunning(),
1639
+ hasUnfinishedScheduled: this.db.submissions.hasUnfinishedScheduled(),
1576
1640
  previousState: this.state,
1577
1641
  });
1578
1642
  if (changed) this.setState(state);
@@ -262,7 +262,10 @@ export interface RuntimeAgentControls {
262
262
  */
263
263
  submitScheduledPrompt(
264
264
  prompt: string,
265
- opts?: { idempotencyKey?: string },
265
+ opts?: {
266
+ idempotencyKey?: string;
267
+ unattendedExecutionGrant?: import("../../submission").RuntimeUnattendedExecutionGrant;
268
+ },
266
269
  ): Promise<SubmissionReceipt>;
267
270
  /**
268
271
  * 按提交编号查询当前处理状态。
@@ -23,8 +23,26 @@ export {
23
23
  export {
24
24
  isValidTimeZone,
25
25
  nextCronOccurrence,
26
+ wallTimeInstants,
26
27
  zoneOffsetMs,
27
28
  } from "./scheduling/zoned-cron";
29
+ export {
30
+ DEFAULT_LATENESS_WINDOW_MS,
31
+ isWithinLatenessWindow,
32
+ nextScheduledOccurrence,
33
+ resolveScheduleOccurrences,
34
+ toScheduleRuleSpec,
35
+ validateScheduleRuleSpec,
36
+ type LocalScheduleTrigger,
37
+ type ScheduledOccurrence,
38
+ type ScheduleOccurrence,
39
+ type ScheduleOccurrenceResolution,
40
+ type ScheduleRuleError,
41
+ type ScheduleRuleErrorCode,
42
+ type ScheduleRuleSpec,
43
+ type ScheduleSkipReason,
44
+ type SkippedOccurrence,
45
+ } from "./scheduling/schedule-rule";
28
46
 
29
47
  export {
30
48
  CloudflareSandboxAdapter,
@@ -0,0 +1,322 @@
1
+ import type {
2
+ LocalScheduleRule,
3
+ ScheduleSpec,
4
+ } from "../../../submission/contracts";
5
+ import { isValidTimeZone, wallTimeInstants, zoneOffsetMs } from "./zoned-cron";
6
+
7
+ const DAY_MS = 86_400_000;
8
+ const HOUR_MS = 3_600_000;
9
+ const MINUTE_MS = 60_000;
10
+ /** 扫描上限:每日/每周规则一定在 7 天内出现,这里只兜底防止坏输入空转。 */
11
+ const MAX_SCAN_DAYS = 366 * 5;
12
+
13
+ /**
14
+ * 宿主允许在计划时点之后多久仍可启动该轮次。宿主可覆盖,
15
+ * 通用模块只把它当作可参数化的迟到窗口,不承载套餐或任务策略。
16
+ */
17
+ export const DEFAULT_LATENESS_WINDOW_MS = 5 * MINUTE_MS;
18
+
19
+ export type ScheduleSkipReason = "dst_missing_local_time";
20
+
21
+ /** 一个真实可执行的本地时间轮次。 */
22
+ export interface ScheduledOccurrence {
23
+ kind: "scheduled";
24
+ /** 跨重算、重启都稳定的轮次身份,供宿主做幂等键。 */
25
+ occurrenceId: string;
26
+ /** 计划执行的 UTC 瞬时。 */
27
+ scheduledAt: number;
28
+ /** 任务时区下的本地日期 `YYYY-MM-DD`。 */
29
+ localDate: string;
30
+ /** 任务时区下的本地钟点 `HH:mm`。 */
31
+ localTime: string;
32
+ /** 含边界的最迟可启动瞬时 = `scheduledAt + latenessWindowMs`。 */
33
+ latestStartAt: number;
34
+ }
35
+
36
+ /** 因夏令时缺失钟点而被跳过的轮次;没有可执行瞬时,只留身份与原因。 */
37
+ export interface SkippedOccurrence {
38
+ kind: "skipped";
39
+ occurrenceId: string;
40
+ localDate: string;
41
+ localTime: string;
42
+ reason: ScheduleSkipReason;
43
+ }
44
+
45
+ export type ScheduleOccurrence = ScheduledOccurrence | SkippedOccurrence;
46
+
47
+ /** 一条按任务时区解释的当地时间规则及其生效窗口。 */
48
+ export interface ScheduleRuleSpec {
49
+ rule: LocalScheduleRule;
50
+ /** IANA 时区名,如 `Asia/Shanghai`;绝不退化成固定 UTC offset。 */
51
+ timeZone: string;
52
+ /** 含当天的最早生效本地日期 `YYYY-MM-DD`。 */
53
+ startDate?: string;
54
+ /** 含当天的最后生效本地日期 `YYYY-MM-DD`。 */
55
+ endDate?: string;
56
+ /** 覆盖默认迟到窗口。 */
57
+ latenessWindowMs?: number;
58
+ }
59
+
60
+ /** `ScheduleSpec.trigger` 里 `local` 变体的形状。 */
61
+ export type LocalScheduleTrigger = Extract<
62
+ ScheduleSpec["trigger"],
63
+ { kind: "local" }
64
+ >;
65
+
66
+ /**
67
+ * 把合同的 `local` 触发条件投影成时间规则计算规格。
68
+ *
69
+ * 宿主与路由共用同一映射,避免各自拼一份 `startDate`/`endDate`,改合同字段时漏改。
70
+ */
71
+ export function toScheduleRuleSpec(
72
+ trigger: LocalScheduleTrigger,
73
+ timeZone: string,
74
+ ): ScheduleRuleSpec {
75
+ return {
76
+ rule: trigger.rule,
77
+ timeZone,
78
+ ...(trigger.startDate ? { startDate: trigger.startDate } : {}),
79
+ ...(trigger.endDate ? { endDate: trigger.endDate } : {}),
80
+ };
81
+ }
82
+
83
+ export type ScheduleRuleErrorCode =
84
+ | "invalid_rule"
85
+ | "invalid_time_zone"
86
+ | "invalid_date"
87
+ | "invalid_time"
88
+ | "empty_weekdays"
89
+ | "invalid_weekday"
90
+ | "invalid_window"
91
+ | "invalid_limit"
92
+ | "no_future_occurrence";
93
+
94
+ export interface ScheduleRuleError {
95
+ code: ScheduleRuleErrorCode;
96
+ message: string;
97
+ }
98
+
99
+ export type ScheduleOccurrenceResolution =
100
+ | { ok: true; occurrences: ScheduleOccurrence[] }
101
+ | { ok: false; error: ScheduleRuleError };
102
+
103
+ interface LocalClock {
104
+ hour: number;
105
+ minute: number;
106
+ }
107
+
108
+ /** 校验规则形状与时区,不判断是否存在未来时点。 */
109
+ export function validateScheduleRuleSpec(
110
+ spec: ScheduleRuleSpec,
111
+ ): ScheduleRuleError | null {
112
+ if (typeof spec.timeZone !== "string" || !isValidTimeZone(spec.timeZone)) {
113
+ return { code: "invalid_time_zone", message: `未知时区:${spec.timeZone}` };
114
+ }
115
+ const rule = spec.rule as unknown;
116
+ if (!isRecord(rule) ||
117
+ (rule.kind !== "once" && rule.kind !== "daily" && rule.kind !== "weekly")) {
118
+ return { code: "invalid_rule", message: "不支持的当地时间规则" };
119
+ }
120
+ const time = typeof rule.time === "string" ? parseTime(rule.time) : null;
121
+ if (!time) {
122
+ return { code: "invalid_time", message: `非法钟点:${String(rule.time ?? "")}` };
123
+ }
124
+ if (rule.kind === "once" &&
125
+ (typeof rule.date !== "string" || parseDate(rule.date) === null)) {
126
+ return { code: "invalid_date", message: `非法日期:${String(rule.date ?? "")}` };
127
+ }
128
+ if (rule.kind === "weekly") {
129
+ if (!Array.isArray(rule.weekdays) || rule.weekdays.length === 0) {
130
+ if (Array.isArray(rule.weekdays)) {
131
+ return { code: "empty_weekdays", message: "每周规则至少需要一个星期" };
132
+ }
133
+ return { code: "invalid_weekday", message: "星期必须是数字数组" };
134
+ }
135
+ if (
136
+ rule.weekdays.some(
137
+ (weekday) => !Number.isInteger(weekday) || weekday < 0 || weekday > 6,
138
+ )
139
+ ) {
140
+ return {
141
+ code: "invalid_weekday",
142
+ message: `非法星期:${rule.weekdays.join(",")}`,
143
+ };
144
+ }
145
+ }
146
+ const start = optionalDate(spec.startDate);
147
+ if (spec.startDate !== undefined && start === null) {
148
+ return { code: "invalid_date", message: `非法开始日期:${spec.startDate}` };
149
+ }
150
+ const end = optionalDate(spec.endDate);
151
+ if (spec.endDate !== undefined && end === null) {
152
+ return { code: "invalid_date", message: `非法结束日期:${spec.endDate}` };
153
+ }
154
+ if (start !== null && end !== null && end < start) {
155
+ return { code: "invalid_window", message: "结束日期不能早于开始日期" };
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * 从 `after`(UTC 瞬时,不含)起枚举轮次,含被跳过的缺失钟点。
162
+ *
163
+ * 预览、下一轮计算和恢复枚举都走这里,保证三者语义一致;输出为空时返回可识别的
164
+ * `no_future_occurrence`,而不是伪造一个执行时间。
165
+ */
166
+ export function resolveScheduleOccurrences(
167
+ spec: ScheduleRuleSpec,
168
+ options: { after: number; limit: number },
169
+ ): ScheduleOccurrenceResolution {
170
+ const error = validateScheduleRuleSpec(spec);
171
+ if (error) return { ok: false, error };
172
+ if (!Number.isSafeInteger(options.limit) || options.limit <= 0) {
173
+ return {
174
+ ok: false,
175
+ error: { code: "invalid_limit", message: "轮次数量必须是正整数" },
176
+ };
177
+ }
178
+ const occurrences = enumerate(spec, options.after, options.limit);
179
+ if (occurrences.length === 0) {
180
+ return {
181
+ ok: false,
182
+ error: {
183
+ code: "no_future_occurrence",
184
+ message: "该规则在给定时间之后没有有效执行时点",
185
+ },
186
+ };
187
+ }
188
+ return { ok: true, occurrences };
189
+ }
190
+
191
+ /** 下一个可执行瞬时;缺失钟点被跳过,不伪造、不顺延补跑。 */
192
+ export function nextScheduledOccurrence(
193
+ spec: ScheduleRuleSpec,
194
+ after: number,
195
+ ): ScheduledOccurrence | null {
196
+ if (validateScheduleRuleSpec(spec) !== null) return null;
197
+ for (let limit = 1; limit <= 512; limit *= 2) {
198
+ const occurrences = enumerate(spec, after, limit);
199
+ const scheduled = occurrences.find(
200
+ (occurrence): occurrence is ScheduledOccurrence =>
201
+ occurrence.kind === "scheduled",
202
+ );
203
+ if (scheduled) return scheduled;
204
+ if (occurrences.length < limit) return null;
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /** 含边界判断某个已计划轮次是否仍在迟到窗口内。 */
210
+ export function isWithinLatenessWindow(
211
+ occurrence: ScheduledOccurrence,
212
+ now: number,
213
+ ): boolean {
214
+ return now >= occurrence.scheduledAt && now <= occurrence.latestStartAt;
215
+ }
216
+
217
+ function enumerate(
218
+ spec: ScheduleRuleSpec,
219
+ after: number,
220
+ limit: number,
221
+ ): ScheduleOccurrence[] {
222
+ const time = parseTime(spec.rule.time);
223
+ if (!time) return [];
224
+ const windowMs = spec.latenessWindowMs ?? DEFAULT_LATENESS_WINDOW_MS;
225
+ const wallAfter = after + zoneOffsetMs(after, spec.timeZone);
226
+ const dayOfAfter = Math.floor(wallAfter / DAY_MS) * DAY_MS;
227
+ const startDay = optionalDate(spec.startDate) ?? dayOfAfter;
228
+ const endDay = optionalDate(spec.endDate);
229
+ const occurrences: ScheduleOccurrence[] = [];
230
+
231
+ const onceDay = spec.rule.kind === "once" ? parseDate(spec.rule.date) : null;
232
+ // 一次性规则直接跳到目标日期,不能让重复规则的扫描保险上限拒绝远期任务。
233
+ let day = Math.max(startDay, dayOfAfter, onceDay ?? Number.NEGATIVE_INFINITY);
234
+ const maxScanDays = onceDay === null ? MAX_SCAN_DAYS : 0;
235
+ for (let scanned = 0; scanned <= maxScanDays; scanned += 1, day += DAY_MS) {
236
+ if (endDay !== null && day > endDay) break;
237
+ if (!matchesRule(spec.rule, day)) continue;
238
+
239
+ const wall = day + time.hour * HOUR_MS + time.minute * MINUTE_MS;
240
+ const localDate = formatDate(day);
241
+ const localTime = formatTime(time);
242
+ const occurrenceId = `${localDate}T${localTime}`;
243
+ const instants = wallTimeInstants(wall, spec.timeZone);
244
+ if (instants.length === 0) {
245
+ // 拨快抹掉的钟点:没有可执行瞬时,只在游标之后才作为跳过轮次报告。
246
+ if (wall <= wallAfter) continue;
247
+ occurrences.push({
248
+ kind: "skipped",
249
+ occurrenceId,
250
+ localDate,
251
+ localTime,
252
+ reason: "dst_missing_local_time",
253
+ });
254
+ } else {
255
+ const scheduledAt = instants[0];
256
+ if (scheduledAt <= after) continue;
257
+ occurrences.push({
258
+ kind: "scheduled",
259
+ occurrenceId,
260
+ scheduledAt,
261
+ localDate,
262
+ localTime,
263
+ latestStartAt: scheduledAt + windowMs,
264
+ });
265
+ }
266
+ if (occurrences.length >= limit) break;
267
+ }
268
+ return occurrences;
269
+ }
270
+
271
+ function matchesRule(rule: LocalScheduleRule, day: number): boolean {
272
+ switch (rule.kind) {
273
+ case "once":
274
+ return day === parseDate(rule.date);
275
+ case "daily":
276
+ return true;
277
+ case "weekly":
278
+ return rule.weekdays.includes(new Date(day).getUTCDay());
279
+ }
280
+ }
281
+
282
+ function parseTime(value: string): LocalClock | null {
283
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
284
+ if (!match) return null;
285
+ return { hour: Number(match[1]), minute: Number(match[2]) };
286
+ }
287
+
288
+ function formatTime({ hour, minute }: LocalClock): string {
289
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
290
+ }
291
+
292
+ /** `YYYY-MM-DD` → UTC 零点毫秒;非法日历日期返回 null。 */
293
+ function parseDate(value: string): number | null {
294
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
295
+ if (!match) return null;
296
+ const year = Number(match[1]);
297
+ const month = Number(match[2]);
298
+ const day = Number(match[3]);
299
+ const ms = Date.UTC(year, month - 1, day);
300
+ const date = new Date(ms);
301
+ if (
302
+ date.getUTCFullYear() !== year ||
303
+ date.getUTCMonth() !== month - 1 ||
304
+ date.getUTCDate() !== day
305
+ ) {
306
+ return null;
307
+ }
308
+ return ms;
309
+ }
310
+
311
+ function optionalDate(value: string | undefined): number | null {
312
+ if (value === undefined) return null;
313
+ return parseDate(value);
314
+ }
315
+
316
+ function formatDate(day: number): string {
317
+ return new Date(day).toISOString().slice(0, 10);
318
+ }
319
+
320
+ function isRecord(value: unknown): value is Record<string, unknown> {
321
+ return typeof value === "object" && value !== null && !Array.isArray(value);
322
+ }
@@ -55,7 +55,8 @@ export function nextCronOccurrence(
55
55
  for (const second of parsed.seconds) {
56
56
  const wall = day + hour * 3_600_000 + minute * 60_000 + second * 1_000;
57
57
  if (wall <= wallAfter) continue;
58
- const resolved = wallTimeCandidates(wall, timeZone, after)[0];
58
+ const resolved = wallTimeInstants(wall, timeZone)
59
+ .find((instant) => instant > after);
59
60
  if (resolved !== undefined) return resolved;
60
61
  }
61
62
  }
@@ -73,18 +74,22 @@ export function isValidTimeZone(timeZone: string): boolean {
73
74
  }
74
75
  }
75
76
 
76
- function wallTimeCandidates(wallUtc: number, timeZone: string, after: number): number[] {
77
+ /**
78
+ * 所有本地墙上时间恰好等于 `wallUtc` 的 UTC 瞬时,升序。
79
+ *
80
+ * `wallUtc` 是"把本地钟点当作 UTC"的伪瞬时(见 {@link zoneOffsetMs})。
81
+ * 返回空数组 = 该钟点在当地被夏令时拨快抹掉;返回两个 = 拨慢导致同一钟点出现两次。
82
+ * 这是 `nextCronOccurrence` 与本地规则计算共用的唯一钟点解析边界。
83
+ */
84
+ export function wallTimeInstants(wallUtc: number, timeZone: string): number[] {
77
85
  const offsets = new Set([
78
- zoneOffsetMs(after, timeZone),
79
86
  zoneOffsetMs(wallUtc - 2 * 86_400_000, timeZone),
80
87
  zoneOffsetMs(wallUtc, timeZone),
81
88
  zoneOffsetMs(wallUtc + 2 * 86_400_000, timeZone),
82
89
  ]);
83
90
  return [...offsets]
84
91
  .map((offset) => wallUtc - offset)
85
- .filter((instant) =>
86
- instant > after && instant + zoneOffsetMs(instant, timeZone) === wallUtc
87
- )
92
+ .filter((instant) => instant + zoneOffsetMs(instant, timeZone) === wallUtc)
88
93
  .sort((left, right) => left - right);
89
94
  }
90
95
 
@@ -51,6 +51,15 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
51
51
  if (!submissionColumns.has("regenerate_message_id")) {
52
52
  sql`ALTER TABLE pi_submissions ADD COLUMN regenerate_message_id TEXT`;
53
53
  }
54
+ if (!submissionColumns.has("origin")) {
55
+ sql`ALTER TABLE pi_submissions ADD COLUMN origin TEXT NOT NULL DEFAULT 'user'`;
56
+ }
57
+ if (!submissionColumns.has("unattended_execution_grant_json")) {
58
+ sql`ALTER TABLE pi_submissions ADD COLUMN unattended_execution_grant_json TEXT`;
59
+ }
60
+ if (!submissionColumns.has("unattended_failure")) {
61
+ sql`ALTER TABLE pi_submissions ADD COLUMN unattended_failure TEXT`;
62
+ }
54
63
  sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_one_running
55
64
  ON pi_submissions(status)
56
65
  WHERE status = 'running'`;
@@ -156,6 +165,9 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
156
165
  .map((column) => column.name),
157
166
  );
158
167
  let columns = approvalColumns();
168
+ if (!columns.has("confirmation_json")) {
169
+ sql`ALTER TABLE pi_approvals ADD COLUMN confirmation_json TEXT`;
170
+ }
159
171
  if (!columns.has("execution_level")) {
160
172
  // 旧 Session 没有持久化当时的授权档位,safe 失败关闭;只影响旧 pending 回执展示,不会静默放行。
161
173
  sql`ALTER TABLE pi_approvals
@@ -1,3 +1,4 @@
1
+ import { schedulePiToolCandidates } from "../tools/schedule";
1
2
  import type { SkillSource } from "agents/skills";
2
3
  import type {
3
4
  RuntimeGatewayPort,
@@ -264,6 +265,16 @@ async function createToolSurface(
264
265
  if (!EXECUTION_LEVELS.includes(candidate.requiredExecutionLevel)) {
265
266
  throw new Error(`SpringBrand Tool ${name} execution level is invalid`);
266
267
  }
268
+ if (candidate.unattendedExecution) {
269
+ requiredName(
270
+ candidate.unattendedExecution.capability,
271
+ `SpringBrand Tool ${name} unattended capability`,
272
+ );
273
+ requiredName(
274
+ candidate.unattendedExecution.operation,
275
+ `SpringBrand Tool ${name} unattended operation`,
276
+ );
277
+ }
267
278
  if (tools.has(name)) {
268
279
  throw new Error(`Duplicate Runtime SpringBrand Tool: ${name}`);
269
280
  }
@@ -632,6 +643,7 @@ class RuntimeBuilder {
632
643
  : {}),
633
644
  hostTools: [
634
645
  ...this.hostTools,
646
+ ...(this.schedule ? schedulePiToolCandidates(this.schedule) : []),
635
647
  ...subagentPiToolCandidates(
636
648
  this.subagents,
637
649
  [...this.enabledSubagents],
@@ -249,9 +249,20 @@ function describeTools(candidates: readonly PiToolCandidate[]) {
249
249
  description: candidate.tool.description ?? candidate.tool.label,
250
250
  parameters: candidate.tool.parameters,
251
251
  requiredExecutionLevel: candidate.requiredExecutionLevel,
252
+ ...(candidate.confirmation ? { confirmation: true } : {}),
252
253
  ...(candidate.alwaysRequiresApproval
253
254
  ? { alwaysRequiresApproval: true }
254
255
  : {}),
256
+ ...(candidate.unattendedExecution
257
+ ? {
258
+ unattendedExecution: {
259
+ capability: candidate.unattendedExecution.capability,
260
+ operation: candidate.unattendedExecution.operation,
261
+ objectConstrained:
262
+ candidate.unattendedExecution.objectForInput !== undefined,
263
+ },
264
+ }
265
+ : {}),
255
266
  ...(candidate.deferLoading ? { deferLoading: true } : {}),
256
267
  ...(candidate.source ? { source: candidate.source } : {}),
257
268
  retry: piToolRetryPolicy(candidate),