简介
支持团队经常会承诺稍后再次检查工单:可能是在客户尝试修复方案之后、服务窗口结束之后,或升级截止时间之前。浏览器计时器无法安全地承担这项任务,因为关闭标签页就会使计时器消失。Agent 调度会将未来要执行的操作保存到指定的 Agent 中,这样时间到达时,平台就能唤醒该持久化实例。
在本实验中,你将构建一个不使用语言模型的小型跟进面板:
schedule()注册一个延迟回调,并返回持久化调度 ID。listSchedules()使用当前的异步 API,让应用检查待处理任务。cancelSchedule()在服务器验证任务归属后,移除仍在等待的任务。- 回调会在 Agent 状态中记录一条有数量上限的完成记录,并输出一条限制隐私信息的日志。
你将安排一个短时间任务并观察它完成,然后创建一个较长时间的任务,并在执行前将其取消。调用只使用虚构的工单引用。相同的注册请求会启用 SDK 幂等性,防止误点击两次而创建重复任务。
Agents SDK 在 SQLite 支持的 Durable Object alarm 之上实现了这一生命周期。你可以使用更高级别的调度 API,而不必自行管理 alarm 时间戳和存储记录;但任务仍然属于一个指定的 Agent 实例,并且能够在普通 Worker 重启后继续存在。
在直接进入本课程之前,请先完成将 LabEx 连接到你的 Cloudflare 账户。 每个新的 LabEx 虚拟机都需要单独完成 Wrangler 授权。建议先完成本课程之前的实验,但本实验会创建并删除自己的隔离资源。
授权虚拟机并配置 Agent
在此步骤中,你将为这个全新的虚拟机完成授权,并定义本实验使用的唯一临时 Worker 和 Durable Object 类。
cd /home/labex/project/follow-up-agent
npx wrangler login
npx wrangler whoami --json
在 LabEx 浏览器中打开输出的设备链接,确认显示的代码,然后批准学习账户。不要向任何人发送密码、令牌或授权码。在 JSON 结果中确认 "loggedIn": true,读取账户名称并复制账户 ID。
生成唯一的资源名称,并创建 wrangler.jsonc:
RUN="labex-c11-s04-$(openssl rand -hex 6)"
ACCOUNT_ID="YOUR_ACCOUNT_ID"
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/server.ts",
"compatibility_date": "2026-09-18",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true },
"durable_objects": {
"bindings": [
{ "name": "FollowUpAgent", "class_name": "FollowUpAgent" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["FollowUpAgent"] }
]
}
JSON
python3 .labex/verify.py auth
绑定名称是路由器和客户端使用的名称;类名称对应具体实现。迁移 v1 会要求 Cloudflare 为该类创建 SQLite 支持的存储。它不会立即创建某个指定名称的实例——例如 planning 这样的实例,会在流量第一次访问它时出现。
实现持久化跟进调度
在此步骤中,你将在一个指定名称的 Agent 中实现注册、检查、取消和最终回调。
cat > src/server.ts <<'TS'
import { Agent, callable, routeAgentRequest, type Schedule } from "agents";
type CompletedFollowUp = { ticketId: string; completedAt: string };
export type FollowUpState = { completed: CompletedFollowUp[]; revision: number };
export type PendingFollowUp = { id: string; ticketId: string; runAt: string };
export class FollowUpAgent extends Agent<Cloudflare.Env, FollowUpState> {
initialState: FollowUpState = { completed: [], revision: 0 };
private ticket(value: unknown): string {
const ticketId = typeof value === "string" ? value.trim().toUpperCase() : "";
if (!/^T-[A-Z0-9-]{3,24}$/.test(ticketId)) {
throw new Error("ticket must look like T-DEMO-101");
}
return ticketId;
}
@callable()
async scheduleFollowUp(ticketInput: string, delaySeconds: number): Promise<PendingFollowUp> {
const ticketId = this.ticket(ticketInput);
if (!Number.isInteger(delaySeconds) || delaySeconds < 3 || delaySeconds > 300) {
throw new Error("delay must be an integer from 3 to 300 seconds");
}
const scheduled = await this.schedule(
delaySeconds,
"completeFollowUp",
{ ticketId },
{
idempotent: true,
retry: { maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 500 }
}
);
return this.pending(scheduled);
}
@callable()
async listFollowUps(): Promise<PendingFollowUp[]> {
const schedules = await this.listSchedules({ type: "delayed" });
return schedules
.filter((item) => item.callback === "completeFollowUp")
.map((item) => this.pending(item))
.sort((left, right) => left.runAt.localeCompare(right.runAt));
}
@callable()
async cancelFollowUp(scheduleId: string): Promise<boolean> {
if (!/^[a-zA-Z0-9_-]{8,80}$/.test(scheduleId)) throw new Error("invalid schedule ID");
const owned = await this.getScheduleById(scheduleId);
if (!owned || owned.callback !== "completeFollowUp") return false;
return this.cancelSchedule(scheduleId);
}
@callable()
getBoard(): FollowUpState {
return this.state;
}
async completeFollowUp(payload: unknown, _schedule: Schedule<unknown>): Promise<void> {
const ticketId = this.ticket((payload as { ticketId?: unknown })?.ticketId);
const next: FollowUpState = {
completed: [...this.state.completed, { ticketId, completedAt: new Date().toISOString() }].slice(-5),
revision: this.state.revision + 1
};
this.setState(next);
console.log(JSON.stringify({
event: "follow_up_completed",
instance: this.name,
revision: next.revision,
completedCount: next.completed.length
}));
}
private pending(schedule: Schedule<unknown>): PendingFollowUp {
const payload = schedule.payload as { ticketId?: unknown };
return {
id: schedule.id,
ticketId: this.ticket(payload.ticketId),
runAt: new Date(schedule.time * 1000).toISOString()
};
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return (await routeAgentRequest(request, env)) ?? new Response("Not found", { status: 404 });
}
};
TS
python3 .labex/verify.py server
Cloudflare.Env 来自你将在编译前生成的 Wrangler 绑定声明,因此源代码不需要再维护一份手写的环境定义。schedule() 接收相对延迟时间、回调名称和较小的可序列化负载。{ idempotent: true } 表示重复提交相同的回调和负载时,会返回现有的待处理调度,而不会再添加一个新任务。重试策略最多允许回调尝试两次,并使用较短且有上限的退避时间;因此,永久性错误不会无限循环。回调最多只保留五条虚构工单的完成记录,并且结构化日志不会包含工单引用。
列表和查找方法都明确使用了 await。旧示例可能会展示同步的 getSchedule() 或 getSchedules() 调用;当前的 Agents SDK 代码应使用 getScheduleById() 和 listSchedules()。
连接跟进面板
在此步骤中,你将配置当前的装饰器转换,并将提供的页面连接到一个指定名称的 Agent。
cat > tsconfig.json <<'JSON'
{
"extends": "agents/tsconfig",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*.ts", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON
cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import agents from "agents/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [agents(), cloudflare()] });
TS
cat > src/client.ts <<'TS'
import { AgentClient } from "agents/client";
import type { FollowUpState, PendingFollowUp } from "./server";
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<main><p class="eyebrow">Durable scheduling</p><h1>Support Follow-Up Board</h1>
<p id="status" class="status">Connecting to FollowUpAgent:planning…</p>
<form id="form"><input id="ticket" value="T-DEMO-101" aria-label="Ticket reference">
<input id="delay" type="number" min="3" max="300" value="12" aria-label="Delay in seconds">
<button>Schedule follow-up</button></form><p id="error" class="error"></p>
<div class="columns"><section class="panel"><h2>Pending</h2><div id="pending"></div></section>
<section class="panel"><h2>Completed</h2><div id="completed"></div></section></div>
<p class="notice">This demonstration uses synthetic ticket references only.</p></main>`;
const client = new AgentClient<FollowUpState>({ agent: "FollowUpAgent", name: "planning", host: window.location.host });
const pendingView = document.querySelector<HTMLDivElement>("#pending")!;
const completedView = document.querySelector<HTMLDivElement>("#completed")!;
const statusView = document.querySelector<HTMLParagraphElement>("#status")!;
const errorView = document.querySelector<HTMLParagraphElement>("#error")!;
function renderCompleted(state: FollowUpState) {
completedView.innerHTML = state.completed.map((item) =>
`<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.completedAt).toLocaleTimeString()}</small></div>`
).join("") || '<p class="empty">No completed follow-ups yet</p>';
}
async function refresh() {
const pending = await client.call<PendingFollowUp[]>("listFollowUps", []);
pendingView.innerHTML = pending.map((item) =>
`<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.runAt).toLocaleTimeString()}</small><br>` +
`<button class="secondary" data-id="${item.id}">Cancel</button></div>`
).join("") || '<p class="empty">No pending follow-ups</p>';
const state = await client.call<FollowUpState>("getBoard", []);
renderCompleted(state);
}
await client.ready;
statusView.textContent = "Connected to FollowUpAgent:planning";
await refresh();
setInterval(() => refresh().catch(() => undefined), 2000);
document.querySelector<HTMLFormElement>("#form")!.addEventListener("submit", async (event) => {
event.preventDefault(); errorView.textContent = "";
try {
const ticket = document.querySelector<HTMLInputElement>("#ticket")!.value;
const delay = Number(document.querySelector<HTMLInputElement>("#delay")!.value);
await client.call("scheduleFollowUp", [ticket, delay]); await refresh();
} catch (cause) { errorView.textContent = cause instanceof Error ? cause.message : String(cause); }
});
pendingView.addEventListener("click", async (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-id]");
if (!button) return;
await client.call("cancelFollowUp", [button.dataset.id]); await refresh();
});
TS
python3 .labex/verify.py client
页面每两秒轮询一次 Agent,这只是为了让这个纯 TypeScript 示例更容易阅读。调度本身不是浏览器计时器:关闭页面不会取消调度。服务器始终负责验证、确认归属并执行任务。
生成类型并构建应用
在此步骤中,你将生成绑定类型,并在启动任何运行时之前构建应用的两部分。
根据准确的绑定生成环境类型,检查两部分 TypeScript 代码,然后构建 Worker 和静态页面:
npx wrangler types
grep -n "FollowUpAgent" worker-configuration.d.ts | head
npm run check
npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'
python3 .labex/verify.py build
无错误的构建结果表明绑定、装饰器转换、共享类型和打包结果彼此一致。但这还不能证明 alarm 会触发,也不能证明云账户拥有已部署的资源;这些将在接下来的步骤中通过运行时检查确认。
在本地验证生命周期
在此步骤中,你将验证持久化执行和取消功能是否能在本地 Cloudflare 运行时正常工作。
将本地运行时作为持续运行的后台任务启动:
CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
for attempt in $(seq 1 40); do
curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
sleep 1
done
curl --silent --head http://127.0.0.1:5173/ | head
在 LabEx 桌面浏览器中打开 http://localhost:5173。将 T-DEMO-101 安排在 12 秒后执行。它首先会显示在 Pending 下;关闭或刷新页面不会接管这项任务。到达调度时间后,回调会将它从调度存储中移除,并将它记录到 Completed 下。
接着安排 T-DEMO-CANCEL 在 90 秒后执行,然后点击 Cancel。它会从 Pending 中消失,并且不会进入 Completed。运行独立探测程序。该程序使用自己的随机 Agent 名称,并验证幂等注册、执行和取消:
python3 .labex/verify.py local
部署并检查已调度的任务
在此步骤中,你将在 Cloudflare 上重复整个生命周期,并将可观察到的行为与 Dashboard 证据对应起来。
停止指定的本地进程,部署生产构建,并等待其 URL 可用:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy
WORKER_URL="https://YOUR_WORKER_URL"
for attempt in $(seq 1 30); do
curl --silent --fail "$WORKER_URL/" > /dev/null && break
sleep 2
done
在内置浏览器中打开准确的 URL。将 T-CLOUD-101 安排在 20 秒后执行,并先观察持久化的待处理行。

执行时间和调度 ID 属于本次临时运行,显示的值会与你的不同。重要的是确认该任务由 Agent 列出,而不是由页面中保存的倒计时列出。
等待回调执行,并确认同一个虚构工单出现在 Completed 下。

创建 T-CLOUD-CANCEL,将其安排在 90 秒后执行,记录它处于待处理状态,然后取消它。Pending 面板应恢复为空,而已完成的条目保持不变。


打开 Workers & Pages,选择准确的 labex-c11-s04-... Worker,然后检查 Bindings。确认 FollowUpAgent 指向相同的类名称。

打开 Durable Objects,检查 FollowUpAgent 命名空间。由于 Agent 状态和调度需要持久化记录,该命名空间使用 SQL 存储。

最后打开 Observability → Logs,筛选 follow_up_completed,并展开其中一条事件。该有数量上限的事件包含 Agent 实例、修订版本和完成数量,但不包含工单引用。

Dashboard 视图可能会延迟更新,因此独立的远程探测程序才是权威验证:
python3 .labex/verify.py deployed
python3 .labex/verify.py observed
删除调度命名空间和 Worker
在此步骤中,你将只删除本实验创建的类命名空间和 Worker。
调度记录和完成状态都存储在 Durable Object 类命名空间中。删除剩余的无状态 Worker 之前,先明确删除该类:
cat > src/cleanup.ts <<'TS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
TS
RUN="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).name)')"
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/cleanup.ts",
"compatibility_date": "2026-09-18",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"preview_urls": false,
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["FollowUpAgent"] },
{ "tag": "v2", "deleted_classes": ["FollowUpAgent"] }
]
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
python3 .labex/verify.py deleted
不要删除账户中的其他资源。确认只有准确生成的 Worker 及其 FollowUpAgent 命名空间被删除。


撤销此虚拟机的授权
在此步骤中,你将删除存储在这个临时虚拟机中的 OAuth 授权,并验证结构化的退出登录状态。
云端清理成功后,删除存储在这个临时虚拟机中的 OAuth 授权:
npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout
确认返回结果明确显示 "loggedIn": false。网络错误无法证明退出登录成功,应重试。临时 Worker、其调度命名空间以及此虚拟机的本地授权现在都已删除。
总结
你为一个指定名称的 Cloudflare Agent 提供了持久化的未来任务,而不依赖打开的浏览器或语言模型。你注册了有数量上限的延迟回调,使重复注册具备幂等性,使用当前的异步 API 检查待处理调度,在取消前验证任务归属,并且只保留少量完成记录。
你还将 SDK 抽象与 Durable Object alarm 生命周期联系起来,在本地和远程验证了完成与取消功能,检查了限制隐私信息的证据,并明确删除了类命名空间、Worker 和临时虚拟机授权。



