@sema-agent/core
Version:
Stateless, task-oriented AI agent core
187 lines (186 loc) • 14 kB
TypeScript
/**
* design/105 — Scheduler seam(自唤醒/自调度)。「手」腿(`ExecutionEnv`)的**跨-task** 未来意图能力:一个 run 里的模型
* 把「未来要做的活」(cron 排程 / 延迟唤醒)持久化交给一个**常驻 backend**(TOC = shell daemon),由它在未来重起一个
* **新** task。
*
* 🔴 与 {@link import("./background-shell.js").BackgroundShellCapability} 的根本区别(design/105 §3.1 D1):
* background-shell 的进程 ⊆ task(在每条退出路径 dispose,不跨 suspend);scheduler 的意图必须 **SURVIVE task 结束**
* (schedule 的全部意义就是当前 task 结束后 daemon 还能重起)——镜像反转,**绝不 dispose-on-exit**。
*
* core 薄壳裁定:core 只出接口 + 工具壳 + `hasScheduler` 门控;**持久化存储 / cron 时钟 / idle 触发 / 起新 task 全在
* backend(daemon)**——core 是 request-driven 不常驻,绝不假装调度器。NodeExecutionEnv 不自带 daemon(不同于
* background-shell 自带 spawn),而是**委托一个注入的 backend**(由一个 shell daemon 实现本接口)。
*/
import type { ExecutionEnv, Result } from "../internal/harness-types.js";
/** 一个已排未来意图的句柄。**backend-local、durable(daemon 持久化、重启存活)、对调用方不透明** —— 绝不暴露可枚举的 raw id。 */
export type ScheduledTaskId = string & {
readonly __brand: "ScheduledTaskId";
};
/**
* 类型化错误码(对齐 background-shell 风格)。方法 MUST NOT throw(返回 {@link Result})。
*/
export type SchedulerErrorCode = "unsupported" | "not_found" | "limit_exceeded" | "invalid_schedule" | "io";
/** scheduler 操作的类型化错误。`code` 给程序分类,`message` 给模型自纠。 */
export declare class SchedulerError extends Error {
readonly code: SchedulerErrorCode;
readonly cause?: unknown | undefined;
constructor(code: SchedulerErrorCode, message: string, cause?: unknown | undefined);
}
/**
* 🔴 **模型可控面(不可信)**:纯意图,**无任何身份字段** —— 防模型经自排 prompt 权限抬升(design/62 同款红线:身份是
* Runner-held,非工具参数)。`prompt` = 未来 task 的不可信 `objective`(daemon 起 task 时经完整 prepare-task gate)。
*/
export interface ScheduledIntent {
/** 未来要跑的 prompt(agent 自排)。daemon 起新 task 时作 `objective`。 */
prompt: string;
/** 何时触发。`cron`=重复(受 minCronIntervalSec 约束);`at`=一次性绝对时刻;`delay`=相对延迟(一次性 self re-wake 用)。 */
when: {
kind: "cron";
expr: string;
} | {
kind: "at";
atMs: number;
} | {
kind: "delay";
delaySec: number;
};
/** 可选短标签;与 `(scope, when)` 一起做 upsert 去重键(防工具重试产生重复 intent)。 */
label?: string;
/**
* [c209-D2] 生存期维度(durable 分层激活批 2026-07-16;缺省 `"durable"` = 既有行为,零破坏)。
* - `"durable"`:daemon 持久化、重启存活、跨会话继续触发——本 seam 的历史唯一语义(intent 不带本字段
* 即此义,老 backend 零迁移)。
* - `"session"`:会话级易失体(上游同名工具的缺省语义——「会话内过会儿跑一下」,不该活成永生任务)。
* 🔴 消费契约(daemon 半场,shell 仓实现):session intent MUST 随带 `sessionId` 的
* {@link SchedulerContext} 排入(工具壳保证);该会话终结(正常收束 / 超时 reap / 显式关闭)时 daemon
* MUST 一并 reap 本 intent——绝不让它在会话死后继续触发。不支持本维度的 backend 不会收到 session
* intent:工具壳按 {@link SchedulerCapability} 的 `supportsSessionLifetime` 能力位门控,不支持时对
* session 语义调用诚实拒绝并引导 `durable: true`——**绝不静默升级为永生任务**(本案的整个意义)。
*/
lifetime?: "durable" | "session";
/**
* [c209-D2] `when.kind === "cron"` 专用:`false` = 命中一次即删(上游 recurring:false 语义——触发一次后
* 等价一次性 at);缺省 `true` = 每次 cron 匹配都触发(既有行为,intent 不带本字段即此义)。
* 🔴 消费契约(daemon 半场,shell 仓实现):recurring:false 的 cron intent 首次触发后 daemon MUST 删除
* 该 intent,不再排下次。core 侧只把字段进 seam;delay/at kind 与 recurring 同给在工具层 fail-closed 拒
* (recurring 只属 cron),故 daemon 只会在 cron intent 上见到本字段。
*/
recurring?: boolean;
/**
* 执行形态(CC ScheduleWakeup 对齐批 2026-07-11;缺省 `"task"` = 既有行为,全向后兼容)。
* - `"task"`:daemon 起一个**无上下文新 task**(`prompt` 作 objective,经完整 prepare-task gate)——CronCreate 语义。
* - `"session-wakeup"`:daemon **续 `ctx.sessionId` 指定的同一会话**(`prompt` 作该会话下一轮输入;同上下文、
* 同 prompt cache)——CC ScheduleWakeup 的 /loop dynamic 语义。🔴 契约:`mode:"session-wakeup"` 的 intent
* MUST 随一个带 `sessionId` 的 {@link SchedulerContext} 排入(工具壳保证);daemon 触发时 MUST resume 该
* session 而非起新 task,且 principal 原样(不升权)。不支持续会话的 daemon MUST 拒(`invalid_schedule`),
* 绝不静默降级成新 task(丢上下文=静默换语义)。
*/
mode?: "task" | "session-wakeup";
}
/**
* 🔴 **Runner-held 可信面**(工具工厂闭包捕获,模型无法触及)。镜像 subagent 经 `ctx.principal` 继承的代码层强制
* (`agents/subagent.ts`)。`schedule` 的实现 MUST 把 `principal` 钉死进持久化 intent;daemon 触发时原样传
* `runTask({ principal })` —— **不升权、不省略**。
*/
export interface SchedulerContext {
/**
* 越界隔离键(cancel/list 只能操作同 scope 排的)。由 Runner 填,且**按被操作 intent 的 lifetime 分叉**
* (ruled 2026-08-03):`lifetime:"session"` 的意图用会话链(sessionId ?? principal ?? taskId ?? "default");
* durable 意图用跨会话稳定身份(principal ?? "default")——否则 durable job 会被钉死在排它的那个会话里,
* 后续会话 cancel/list 一律 `not_found`(任务照常触发却再也删不掉)。backend 侧无需分辨腿别:照旧只按
* 收到的 `scope` 隔离即可;**同一 daemon 上两类键共存**,一次 `list` 只回本 scope 的行(工具壳自己并两腿)。
* ⚠️ 部署面:本分叉之前写下的 durable 行仍带旧的会话键,一次性重铸键归 backend 半场,engine 不做迁移。
*/
scope: string;
/** 排程者身份;daemon 触发时原样传给 runTask(防权限抬升)。 */
principal?: string;
/** 可选:daemon 触发新 task 时复用的 session(chat-continuity;缺省=fresh-session)。⚠️ 复用需 durable sessionStore(design/105 §3.4)。 */
sessionId?: string;
/**
* 可选 opaque 起-task 配置(model/policy 提示)。v1 daemon 可忽略、用部署默认模板起 task(intent.prompt + 继承的
* principal 已足);未来精细化时由 daemon 解释。core 不定其 schema(最薄)。**不可由模型提供**(Runner-held)。
*/
taskConfig?: unknown;
}
/** {@link SchedulerCapability.list} 的条目:足够 self-discovery + 防重复排,不含完整意图。 */
export interface ScheduledTaskSummary {
id: ScheduledTaskId;
/** 渲染用:cron expr / at 时刻 / delay 秒 的人读摘要。 */
when: string;
label?: string;
/** 下次触发的 epoch ms(cron/at 可算;daemon 提供)。 */
nextRunMs?: number;
/** 执行形态回显({@link ScheduledIntent.mode})。ScheduleWakeup 的 stop:true 靠它定位要取消的 pending
* wakeup(cron/task 意图不受 stop 影响——CC 206 D9 语义)。老 backend 不回 = undefined(工具壳以
* label 兜底过滤)。 */
mode?: "task" | "session-wakeup";
/** [c209-D2]:生命周期层级回显({@link ScheduledIntent.lifetime})——缺了它,任务创建后就再无
* 公开面能辨认自己是会话级还是持久级(CronCreate 的即时 details 是唯一一瞥)。老 backend 不回 =
* undefined(层级未知,按既有 durable 直觉读)。 */
lifetime?: "durable" | "session";
/** [c209-D2]:one-shot cron 回显({@link ScheduledIntent.recurring});undefined = 老 backend
* 或非 cron 意图。 */
recurring?: boolean;
}
/**
* 「手」腿的跨-task 调度能力。一个具体 `ExecutionEnv` 可选附加它(交叉类型),经 {@link hasScheduler} 检测。不支持的
* env(无注入 daemon backend)= `schedulerCapabilities.supported=false`,四个 scheduler 工具自动不挂(INERT)。
*/
export interface SchedulerCapability {
/** 显式声明能力(像 background-shell 一样不猜)。`supported:false` → 工具不挂(design/105 §3.9 TOB / 无 daemon)。 */
readonly schedulerCapabilities: {
readonly supported: boolean;
/** 单 scope 存量 intent 上限(防自排炸弹;超限 `limit_exceeded`)。 */
readonly maxScheduledPerScope: number;
/** delay/at 最小延迟秒(防 busy 自醒)。 */
readonly minDelaySec: number;
/** cron 解析后实际触发间隔下限秒(`* * * * *` 占 1 slot 但无限触发 → 须有下限)。 */
readonly minCronIntervalSec: number;
/** 最远排程地平线秒(fail-closed 有界,绝不无限未来)。 */
readonly maxScheduledHorizonSec: number;
/**
* [c209-D2] backend 是否支持 {@link ScheduledIntent.lifetime} `"session"`(intent 随排入会话终结被
* reap)。缺省 undefined/false = 不支持:工具壳收到 session 语义调用(CC 形 `durable` 缺省 false)时
* 诚实拒绝并引导 `durable: true`——**绝不静默落成永生任务**。声明 `true` 的 backend MUST 实现 session
* intent 的 reap 消费契约(见 {@link ScheduledIntent.lifetime} 注释)。
* 🔴(TOCTOU 契约条款):本能力位 MUST 在 backend **实例生命周期内不可变**(`readonly` 快照,
* 不得热翻)——工具壳的门控读与 `schedule()` 是两次操作,中途翻位会让 session intent 落到不会 reap 的
* backend 上。防线双置:声明面不可变 + `schedule()` 实现 MUST 自行拒绝它无法履行 reap 契约的
* `lifetime:"session"` intent(fail-closed 兜底,不依赖工具壳的先行探测)。
*
* 「诚实拒绝」条款的适用边界:它约束**携带 session 语义字段的调用**(CronCreate 的 `durable` 形)。
* 一个没有 durable 逃生口的工具(ScheduleWakeup 形)对不支持位的 backend 走的是另一臂——发送
* 历史 durable intent 并在回执与工具描述上**披露**该 wakeup 不随会话终结(披露≠静默;拒绝会在
* 零收益下拿掉该宿主的整只工具)。该臂为既裁形(capability 位 opt-out 裁定),非本条款的违例。
*/
readonly supportsSessionLifetime?: boolean;
/**
* TB 裁决 — 宿主是否有会兑现 `mode:"session-wakeup"` intent 的常驻唤醒腿。
* **opt-out 方向(与 supportsSessionLifetime 的 fail-closed 缺省相反,有意)**:session-wakeup
* 是既有工作语义(daemon 宿主已在兑现),缺省关断=误伤现网——缺省/undefined=支持;headless 单发
* 宿主(如 `-p` print 路径:零 daemon,进程终答即退)**显式声明 false** ⇒ 工具壳对 ScheduleWakeup
* 诚实拒绝(排入即失约=静默尸体,比拒绝更糟)。不可变纪律同 supportsSessionLifetime。
*/
readonly supportsSessionWakeup?: boolean;
};
/**
* 持久化一个未来意图。`intent` = 模型可控(无身份);`ctx` = Runner-held 可信上下文(principal/scope/...)。返回不透明
* 句柄。🔴 实现 MUST 把 `ctx.principal` 钉死进持久化 intent(daemon 触发原样传 runTask,不升权);MUST 用 cron 库
* 解析 `when.expr` **禁 shell-exec**(RCE 红线);MUST 持久化(durable,daemon 重启存活)。upsert:同
* `(scope, when, label, lifetime)` 覆盖(幂等)——**lifetime 是身份的一部分**(RB-12/server 1.215 F1 案:
* session 级与 durable 级、或不同会话的 session 级 job 撞 (when,label) 是正常用法,MUST NOT 互相覆盖;
* session 级的身份还含 sessionId)。
*/
schedule(intent: ScheduledIntent, ctx: SchedulerContext): Promise<Result<{
id: ScheduledTaskId;
}, SchedulerError>>;
/** 取消一个已排意图(幂等:取消不存在的是 ok no-op)。🔴 越界:`id` MUST 属本 scope;非本 scope 一律 `not_found`,不越界存在性。 */
cancel(id: ScheduledTaskId, ctx: SchedulerContext): Promise<Result<void, SchedulerError>>;
/** 列**本 scope** 已排意图(self-discovery + 防重复排)。范围 = `ctx.scope`,非全局。 */
list(ctx: SchedulerContext): Promise<Result<ScheduledTaskSummary[], SchedulerError>>;
}
/**
* 结构 + 语义检测:`env` 是否暴露 scheduler 能力且声明 `supported:true`。挂载门控用此(对齐 `hasBackgroundShell`)。
*/
export declare function hasScheduler(env: ExecutionEnv): env is ExecutionEnv & SchedulerCapability;
export declare function isValidCronExpr(expr: string): boolean;