Claude Code Agent Wakeup:模型如何把未来事件交给 Harness

面向 agent / harness 工程师,拆解 Claude Code 2.1.193 的 ScheduleWakeup、Cron、Monitor、后台任务、task-notification、Stop hook rewake 与 remote trigger 机制。

这篇文章面向 agent / harness 工程师,讨论一个容易被产品语言压扁的问题:Claude Code 里的 agent 能不能“自己决定下一次什么时候醒来,或者被什么事件触发”?

短答案:能。更准确的说法是:模型没有长期运行权,它把下一次 continuation 的意图登记给 harness。模型负责选择“等多久”“监听什么”“收到事件后做什么”;harness 负责计时、监听、落盘、恢复、入队,并在未来把事件重新投影成一个 model turn。

以下分析基于本机安装的 Claude Code 2.1.193,安装路径为 /opt/homebrew/Caskroom/claude-code/2.1.193/claude,build time 2026-06-25T18:18:11Z。信息来源是本机 binary 中可观察的工具说明、schema 字符串、混淆后的 runtime 片段和本机 .claude 状态,不依赖过时源码。

Executive Summary

  • Claude Code 有一组 wake / continuation 机制,单一 sleep API 无法覆盖这套设计。
  • ScheduleWakeup/loop dynamic 的一次性自节奏工具:模型选择 delaySecondsreasonprompt,harness 在未来重新提交 prompt。
  • CronCreate 是更通用的 prompt scheduler:支持 recurring / one-shot,默认 session-only,durable: true 时写入 .claude/scheduled_tasks.json
  • Monitor 是事件驱动 wake source:后台脚本 stdout 每一行都是事件,harness 把它们变成 <task-notification>
  • Bash / Agent / Workflow / MCP background task 完成后也会以 <task-notification> 重新唤醒主 agent。
  • Stop / SubagentStop hook 看到 background_taskssession_crons,可以区分“会话真的结束了”与“会话正在等未来 wakeup”。
  • Hook schema 里有 asyncRewake:后台 hook exit code 2 时可以把 hook 输出作为 feedback 重新喂给模型。
  • RemoteTrigger 暴露出远端/cloud session trigger 面,但受 allow_remote_sessions policy gate 控制,不能当作普通本地 loop 能力来理解。
  • 设计抽象是:model declares future intent, harness owns time and events, continuation re-enters as queued user-role input
  • 对自研 harness 的核心借鉴:统一所有异步来源到一个 provenance-aware command queue,并让模型看到结构化、可归因、受限大小的 continuation envelope。

Mental Model

把 Claude Code 的 wakeup 机制想成三层。

  1. Model-visible layer:模型看到工具、tool prompt、loop reminder、<task-notification>
  2. Harness runtime layer:harness 管 timer、cron、watcher、background process、task registry、locks、persistent files。
  3. Projection layer:未来事件发生时,harness 把事件投影成 queued input,再启动一次主循环。
flowchart TD User["User / loop instruction"] --> Main["Main Claude turn"] Main --> SW["ScheduleWakeup(delay,prompt)"] Main --> Cron["CronCreate(cron,prompt,durable)"] Main --> Mon["Monitor(command)"] Main --> Bg["Bash / Agent / Workflow background task"] Main --> HookCfg["Stop hook asyncRewake config"] SW --> Scheduler["Session scheduler"] Cron --> Scheduler Mon --> Watcher["Event watcher"] Bg --> Registry["Task registry"] HookCfg --> HookRunner["Hook runner"] Scheduler --> Queue["Command queue"] Watcher --> Notice["task-notification envelope"] Registry --> Notice HookRunner --> Notice Notice --> Queue Queue --> NextTurn["Next model turn"]

这个图里最重要的是 Queue -> NextTurn。模型并没有在后台保持一个 JavaScript closure,也没有跨 turn 持有 stack。continuation 的形态是新的输入:可能是一段 scheduled prompt,可能是 <task-notification>,可能是 hook feedback,可能是远端 inbox message。

Wake Source Taxonomy

Claude Code 现在至少有六类 wake source。

SourceModel-visible?触发条件典型用途生命周期
ScheduleWakeupdelay 到期/loop dynamic 自节奏一次性,必须每轮 re-arm
CronCreatecron match定时 prompt / recurring tasksession-only 或 durable
Monitorstdout line event日志、CI、PR comment、文件变化task registry 管理
run_in_backgroundshell / agent 退出长命令、异步 subagenttask registry 管理
asyncRewake hook配置可见,非普通 toolhook exit code 2Stop hook feedback / verifierhook runtime 管理
RemoteTrigger条件暴露远端 trigger APIcloud / CCR sessionpolicy gated

另外还有 PushNotificationSendUserMessage(status: proactive)。它们会拉用户注意力,甚至推到手机;它们解决的是“通知人”,模型 re-entry 仍然依赖 queue / task-notification / scheduled prompt 这类 continuation 通道。

Model-Visible Tool Surface

如果把这套能力当成 harness API,而不是产品功能,可以先按“谁能调用、输入是什么、结果怎样回到模型”来读。

SurfaceTool / API调用方核心输入直接输出未来 continuation
Dynamic delayScheduleWakeup主模型delaySeconds, reason, promptnext wakeup timestamp, clamped delayscheduled prompt 重新入队
Cron schedulerCronCreate主模型cron, prompt, recurring, durablejob id, persisted/session-only 状态cron fire 后 prompt 重新入队
Cron controlCronDelete / CronList主模型jobId 或空输入cancel 结果 / job list无;用于管理已登记 jobs
Event watcherMonitor主模型command, description, timeout_ms, persistentbackground task id / output filestdout event 变成 <task-notification>
Background shellBash(..., run_in_background)主模型shell command, background flagtask id / output fileprocess exit 变成 <task-notification>
Background agentAgent(..., run_in_background)主模型prompt, description, subagent_type, isolationagent id / launched statusagent stop 变成 <task-notification>
Background workflowWorkflow主模型script, name, args, scriptPath, resumeFromRunIdworkflow task id / run idworkflow complete 变成 <task-notification>
Task managementTaskList主模型通常空输入running / completed task summaries无;用于发现 monitor/background task
Task cancellationTaskStop主模型task_idstopped / failed status可能生成 stop notification
Task outputTaskOutput主模型task_id, block, timeoutstatus + output弱化的兼容面;优先读 output file
Hook rewakeasyncRewakesettings / plugin hook 配置hook command, rewakeMessage, rewakeSummaryhook backgroundedhook exit code 2 变成 feedback continuation
Remote triggerRemoteTrigger条件暴露的模型工具 / remote surfaceaction, trigger_id, bodyremote API result取决于 cloud / CCR trigger
User attentionPushNotification主模型short messageterminal / mobile notification result不直接唤醒模型

这张表里有三个边界要注意。

第一,ScheduleWakeupCronCreate 都能让 prompt 在未来重新进入会话,但生命周期完全不同。ScheduleWakeup/loop dynamic 的一次性 self-pacing continuation;CronCreate 是 cron job,默认 session-only,显式 durable: true 才会写 .claude/scheduled_tasks.json

第二,Monitor、background Bash、background Agent、Workflow 的共同点不是“都能后台运行”,而是都会把完成或事件投影成 <task-notification>。模型真正消费的是 notification envelope,而不是后台进程本身。

第三,asyncRewakeRemoteTrigger 不应被塞进普通 model-callable tools 一类。asyncRewake 是 hook 配置面,触发权在 hook runtime;RemoteTrigger 是 gated remote surface,受登录、policy、remote session 能力控制。它们证明 harness 有更多 wake source,但不能直接当成本地 agent 的通用自唤醒工具。

用伪类型表达,模型可直接调用的 wake-related subset 更接近这样:

type WakeTool =
  | {
      name: "ScheduleWakeup"
      input: { delaySeconds: number; reason: string; prompt: string }
      continuation: { mode: "prompt"; source: "scheduler" }
    }
  | {
      name: "CronCreate"
      input: { cron: string; prompt: string; recurring?: boolean; durable?: boolean }
      continuation: { mode: "prompt"; source: "cron" }
    }
  | {
      name: "Monitor"
      input: { command: string; description: string; timeout_ms?: number; persistent?: boolean }
      continuation: { mode: "task-notification"; source: "stdout" }
    }
  | {
      name: "Bash"
      input: { command: string; run_in_background?: boolean }
      continuation: { mode: "task-notification"; source: "process_exit" }
    }
  | {
      name: "Agent"
      input: {
        prompt: string
        description: string
        subagent_type?: string
        run_in_background?: boolean
        isolation?: string
      }
      continuation: { mode: "task-notification"; source: "agent_stop" }
    }

这也解释了为什么 Claude Code 的 wakeup 机制看起来分散:模型看到的是多个领域工具,harness 看到的是同一种 continuation plumbing。

ScheduleWakeup: Dynamic Loop 的自节奏工具

ScheduleWakeup 的工具说明非常明确:它用于 /loop dynamic mode。用户调用 /loop 但不指定 interval 时,模型需要自己决定下一轮什么时候值得跑。

简化后的 tool contract 是:

type ScheduleWakeupInput = {
  delaySeconds: number
  reason: string
  prompt: string
}

type ScheduleWakeupResult = {
  scheduledFor: number
  clampedDelaySeconds: number
  wasClamped: boolean
}

关键运行语义:

  • delaySeconds 会被 runtime clamp 到 [60, 3600]
  • runtime 会把 wakeup 对齐到分钟级 fire time。
  • 5 分钟 prompt cache TTL 是 pacing 设计的一部分。
  • 60s-270s 适合 cache warm 的积极轮询。
  • 300s 被明确标成差选择:刚好跨过 cache TTL,又没有换来足够长的等待。
  • 1200s-1800s 是 idle / fallback heartbeat 的推荐区间。
  • 这个 wakeup 只 fire 一次。下一轮还要继续,模型必须再次调用 ScheduleWakeup
  • 如果模型没有 re-arm,loop 会结束;如果 keepalive gate 打开,runtime 可以给一次 fallback keepalive,但预算很小。

模型在 dynamic loop 里收到的指导大概是:

Run the task now.
If the next run is gated on an observable event, arm Monitor.
Confirm what you did and what fallback delay you chose.
As the last action, call ScheduleWakeup.
If woken by task-notification, handle the event, then re-arm ScheduleWakeup.
To stop, omit ScheduleWakeup and stop any Monitor you armed.

设计上,ScheduleWakeup 的适用面很窄:它服务 /loop dynamic,不承担通用 sleep 或后台任务轮询器角色。工具说明专门提醒:不要对 harness 已经能跟踪的后台工作做短间隔 polling。后台 Bash / Agent / Workflow 完成时会自动发 <task-notification>;如果还要兜底,设一个长 heartbeat。

stateDiagram-v2 [*] --> Tick: /loop dynamic Tick --> Work: run current prompt Work --> ArmMonitor: next signal is observable event Work --> PickDelay: no primary event source ArmMonitor --> PickDelay: monitor is primary wake signal PickDelay --> Schedule: ScheduleWakeup(delay,prompt) Schedule --> Sleep: turn ends Sleep --> EventWake: Monitor / background task event Sleep --> TimerWake: delay fires EventWake --> Tick: task-notification turn TimerWake --> Tick: scheduled prompt turn Tick --> Done: omit ScheduleWakeup

CronCreate: Prompt Scheduler

CronCreate 是更普通的 scheduler。它把一个 prompt 绑定到 cron 表达式上。

简化 schema:

type CronCreateInput = {
  cron: string
  prompt: string
  recurring?: boolean
  durable?: boolean
}

运行语义:

  • cron 是标准 5-field cron,按用户本地时区解释。
  • recurring: false 表示 one-shot,在下一次 match 后 fire 一次并自动删除。
  • recurring: true 是默认值,直到删除或 auto-expire。
  • durable: false 是默认值:任务只在当前 Claude session 内存里,Claude 退出就消失。
  • durable: true 会写入 .claude/scheduled_tasks.json,重启后恢复。
  • durable one-shot 如果在 Claude 关闭期间错过,下一次启动时会被 surfaced for catch-up,然后删除。
  • scheduled jobs 只在 REPL idle 时 fire,不会在 mid-query 中打断模型。
  • recurring job 默认最多活 7 天;到期时最后 fire 一次,然后删除。
  • recurring fire 会有 deterministic jitter:最多 10% period,cap 15 分钟。
  • one-shot 如果落在 :00 / :30,可能提前最多 90 秒,目的是打散 fleet spike。

本机 binary 里能看到默认 jitter config:

{
  recurringFrac: 0.5,
  recurringCapMs: 1800000,
  oneShotMaxMs: 90000,
  oneShotFloorMs: 0,
  oneShotMinuteMod: 30,
  recurringMaxAgeMs: 604800000,
  cacheLeadMs: 15000
}

604800000ms 正好是 7 天。这解释了为什么 recurring task 文案会提示用户:Recurring tasks auto-expire after 7 days。

Durable Store Shape

持久化文件位于工作目录链路下的 .claude/scheduled_tasks.json,旁边有 .claude/scheduled_tasks.lock

可观察到的 task shape 大致是:

type ScheduledTask = {
  id: string
  cron: string
  prompt: string
  createdAt: number
  lastFiredAt?: number
  recurring?: true
  permanent?: true
  createdBySessionId?: string
  createdByPid?: number
  createdByProcStart?: string
}

type SchedulerLock = {
  sessionId: string
  pid: number
  procStart?: string
  acquiredAt: number
}

这个 lock 很关键。durable scheduler 需要先确认当前进程拥有 fire 权。runtime 会尝试 acquire .claude/scheduled_tasks.lock,判断 PID 是否仍然活着,必要时 recover stale lock。task 自身还带 createdBySessionId / createdByPid / createdByProcStart,用于区分当前 session、同一项目里其他 session、以及已死进程留下的任务。

flowchart TD Start["scheduler.start"] --> Lock["acquire .claude/scheduled_tasks.lock"] Lock -->|held by live process| Wait["retry later"] Lock -->|stale or free| Own["become scheduler owner"] Own --> Read["read .claude/scheduled_tasks.json"] Read --> Missed["surface missed one-shot tasks"] Read --> Interval["periodic check"] Interval --> Idle{"REPL idle?"} Idle -->|no| Interval Idle -->|yes| Fire{"nextFire <= now?"} Fire -->|no| Interval Fire -->|yes| Queue["enqueue prompt / task"] Queue --> Update["update lastFiredAt or delete one-shot"] Update --> Interval

这个设计把三个问题拆开了:

  • 谁有资格 fire durable task。
  • 关闭期间错过的 one-shot 怎么补偿。
  • recurring task 如何更新 lastFiredAt 并 bounded lifetime。

Monitor: Event Stream To Conversation

Monitor 是事件驱动 continuation。它启动一个后台命令,命令的 stdout 每一行都被当成一个 event。

简化 schema:

type MonitorInput = {
  command: string
  description: string
  timeout_ms?: number
  persistent?: boolean
}

工具说明强调了三个使用模型:

需求推荐方式
只要一个通知,例如“server ready”Bash run_in_background,命令在条件满足时退出
每次事件都通知,例如每条 ERRORMonitor + unbounded command
直到自然结束的多事件流,例如 CI stepMonitor + 会退出的 poll loop

Monitor 的工程约束很多:

  • stdout 是事件流;stderr 默认只进 output file,不触发通知。
  • 如果要让 stderr 触发,需要在脚本里 2>&1 再过滤。
  • 每个 pipe stage 要 line-buffered;grep --line-bufferedawkfflush()
  • 监听 job outcome 时,filter 必须覆盖 failure / cancelled / timeout,不只匹配 success。
  • 每一行都会变成 conversation message,所以必须过滤到“值得模型行动”的信号。
  • 200ms 内多行会 batch 成一个 notification。
  • 输出过多会自动停止 monitor。
  • persistent: true 表示 session-length watch,典型用于 PR monitoring / log tail。
  • 取消用 TaskStop,查 task id 用 TaskList

Monitor 的价值不只是跑一个后台命令。普通 shell 也能后台跑。它真正提供的是 event projection:harness 把 stdout line 变成有 provenance 的 conversation event,并立即唤醒 loop,绕过 ScheduleWakeup 的 fallback deadline。

sequenceDiagram participant M as Model participant H as Harness participant P as Monitor Process participant Q as Command Queue M->>H: Monitor(command, description, persistent) H->>P: spawn shell command P-->>H: stdout line H->>H: batch / rate limit / format H->>Q: enqueue task-notification priority=next Q->>M: new turn with event M->>M: handle event in loop context M->>H: ScheduleWakeup fallback heartbeat

Background Tasks: Completion As Wake Signal

Bash、Agent、Workflow、MCP background task 都走相似模式:任务先在 task registry 里注册,工具调用可以先返回“已后台运行”,未来任务完成时发 <task-notification>

Bash 的 run_in_background 文案说明得很直:命令 detached 运行,跨 turn 保持运行,退出时重新调用你。模型不需要在命令后加 &;裸 & 反而绕开 harness 追踪,通知永远不会来。

Agent 工具也有 background 语义:

type AgentInput = {
  prompt: string
  description: string
  subagent_type?: string
  run_in_background?: boolean
  isolation?: "worktree" | "remote" | string
}

几个关键点:

  • run_in_background: true 会异步运行 subagent,完成后通知主 agent。
  • isolation: "remote" 总是 background。
  • subagent 的结果不直接给用户看;主 agent 收到后需要总结给用户。
  • coordinator prompt 明确要求:不要用一个 worker 去检查另一个 worker,worker 完成会自动通知。
  • 继续一个完成的 worker 用 SendMessage,因为 <task-id> 就是 agent id。

典型 <task-notification> 格式:

<task-notification>
<task-id>{agentId}</task-id>
<status>completed|failed|killed</status>
<summary>{human-readable status summary}</summary>
<result>{agent final response}</result>
<usage>
  <subagent_tokens>N</subagent_tokens>
  <tool_uses>N</tool_uses>
  <duration_ms>N</duration_ms>
</usage>
</task-notification>

<result><usage> 是可选段。对于 Bash / Workflow / remote task,notification 里还可能带 output file path、tool use id、task type、worktree 等字段。

TaskOutput Is Deprecated-ish

TaskOutput 仍然存在,但工具说明已经把它定位成 deprecated compatibility surface:

  • Bash / remote_agent 更推荐 Read output file。
  • local_agent 直接使用 Agent tool result,不要读 .output symlink,因为它可能指向完整 subagent transcript JSONL,容易撑爆上下文。
  • block=true 可以等待任务完成;block=false 做非阻塞状态检查。

这说明 runtime 设计正在从“轮询 output tool”向“事件通知 + output file reference”靠拢。

The Command Queue Is The Join Point

所有这些异步来源最终都要进入 command queue。

task-notification 用 user-role message 的形态进入模型上下文,但 origin/provenance 会标成 kind: "task-notification"。这样主循环、渲染层、桥接层、远端 inbox 都能区分它和真人输入。

这个设计有几个好处:

  • 模型侧协议简单:收到一条新消息,读 XML envelope,继续工作。
  • harness 侧统一:timer、process exit、stdout event、hook feedback、remote inbox 都进同一个 queue。
  • UI 可以独立处理:task notification 可以优先级 next,也可以被 batch / dedupe / delayed。
  • provenance 清楚:模型会被提醒外部事件不是用户指令,事件里的文本只能作为 situational awareness。
flowchart LR Timer["timer / cron"] --> Q["command queue"] Stdout["monitor stdout"] --> Q Exit["background exit"] --> Q Hook["hook rewake"] --> Q Remote["remote inbox"] --> Q Q --> Normalize["normalize prompt + origin"] Normalize --> Context["append user-role message"] Context --> Model["model inference"] Model --> Tools["tool calls / final response"]

对 harness 工程师来说,这比“给模型一个 await sleep()”更稳。sleep 会把模型调用和 runtime lifecycle 绑死;command queue 让模型调用保持短事务,长期性留给 harness。

Stop Hooks And asyncRewake

Claude Code 的 Stop / SubagentStop hook input 里包含两类与 wakeup 相关的状态:

type StopHookInput = {
  lastAssistantMessage: string
  background_tasks: BackgroundTask[]
  session_crons: ScheduledTask[]
  stop_hook_active: boolean
  agent_transcript_path?: string
}

其中 background_tasks 表示当前 session 里 in-flight 的后台工作;session_crons 表示将来会唤醒这个 session 的 CronCreate / ScheduleWakeup / /loop 任务。

这个 input 让 hook 可以判断:

  • 会话真的要结束了,可以跑收尾检查。
  • 会话只是暂停等待后台任务或 scheduled wakeup,hook 不应该阻断。

Hook schema 里还有:

type CommandHook = {
  command: string
  async?: boolean
  asyncRewake?: boolean
  rewakeMessage?: string
  rewakeSummary?: string
}

asyncRewake 的含义是:hook 在后台运行,且当 exit code 为 2 时唤醒模型。hook stdout/stderr 会被包装成类似 Stop hook feedback 的 task-notification / system-reminder,重新进入 queue。

这是一种 harness extension point。模型不能直接调用 asyncRewake 工具;它来自配置和 hook runtime。但从架构看,它和 Monitor / background task 一样,最终也把外部事件投影回模型 turn。

RemoteTrigger And Remote Sessions

本机 binary 里能看到 RemoteTrigger/v1/code/triggerstrigger_idlist/get/create/update/run 等字符串,也能看到 allow_remote_sessions policy gate。

这说明 Claude Code 还有一个远端 trigger 面,偏 cloud / CCR session:

type RemoteTriggerAction =
  | "list"
  | "get"
  | "create"
  | "update"
  | "run"

保守解释:RemoteTrigger 更像远端 session / routine / cloud trigger API 的 client surface,和普通本地 /loop 分属不同层。它是否暴露、能否使用、能做什么,受登录状态、组织 policy、remote session entitlement 影响。

文章主线里不把它作为本地 agent 自主唤醒的核心,因为本地可验证路径已经由 ScheduleWakeupCronCreateMonitor、background task 覆盖。

Headless Mode Boundary

一个非常重要的边界:headless / -p 场景没有 interactive REPL 的 task-notification re-invocation。

这意味着:

  • 交互式 session 里,run_in_background 很自然:任务完成后会叫醒模型。
  • headless session 里,background task 完成后没有同样的 re-entry path;长任务应该同步跑,或者由外部 orchestrator 管理。
  • 任何依赖“稍后通知我”的工具,在 headless harness 里都需要明确的 event pump / callback transport。

这条边界很容易被忽略。如果你在自己的 SDK / CI / GitHub Action agent 里照搬 interactive CLI 的 background 语义,却没有实现 queue re-entry,结果就是任务真的跑完了,但模型永远不会接结果。

What The Model Actually Sees

模型看到的是有限投影,而非 scheduler 全状态或 .claude/scheduled_tasks.json 原始内容。

来源模型看到什么
ScheduleWakeuptool schema、tool prompt、tool result 中的 next wakeup 信息
CronCreatecron tool schema、job id、CronList 结果
Monitortool prompt、TaskList/TaskStop、未来 <task-notification>
Background AgentAgent tool contract、未来 <task-notification> XML
Background BashBash tool contract、output file path、未来 notification
Stop hook如果 asyncRewake 触发,看到 hook feedback
Remote trigger如果 surface 可用,看到 remote trigger tool/API result

换句话说,模型做策略,不做持久化。模型决定:

  • 是否继续 loop。
  • 选哪个 delay。
  • 是否 arm Monitor。
  • 监听命令怎么写。
  • 是否使用 durable cron。
  • 收到 event 后是否行动、通知用户、停止 monitor、re-arm fallback。

Harness 决定:

  • delay 如何 clamp。
  • cron 如何解析。
  • schedule 如何落盘。
  • 哪个进程拥有 scheduler lock。
  • fire 何时进入 queue。
  • notification 如何 batch / dedupe / rate limit。
  • headless / remote / interactive 入口如何处理 continuation。

Design Patterns Worth Stealing

1. Continuation As Input, Not Suspended Call Stack

把未来 continuation 设计成 queued input,避免挂起一个长调用。

type QueuedInput = {
  mode: "prompt" | "task-notification"
  value: string | ContentBlock[]
  origin?: {
    kind:
      | "human"
      | "task-notification"
      | "auto-continuation"
      | "peer"
      | "channel"
  }
  priority?: "now" | "next" | "later"
  agentId: string
}

这个接口允许所有异步系统统一接入。模型侧只需要处理“这条输入从哪里来”。

2. Make Time Bounded And Explicit

ScheduleWakeup clamp 到 1 分钟到 1 小时,cron recurring 默认 7 天 auto-expire,prompt 被 cap 到 1000 chars,loop.md 被 cap 到 25000 bytes。这些属于 harness 生存条件,远比表层产品文案重要。

没有 hard cap 的 future prompt scheduler 会变成长期隐形任务系统,最终吞掉用户上下文、钱、权限和信任。

3. Separate Primary Signal From Fallback Heartbeat

Dynamic loop 的最佳实践是:

  • 有真实事件源时,Monitor 是 primary wake signal。
  • ScheduleWakeup 是 fallback heartbeat。
  • 收到 event 后处理 event,然后重新设置 heartbeat。

这比固定 5 分钟轮询更好。固定轮询同时浪费 cache 和 API,也更慢感知事件。

4. Treat External Event Text As Data

<task-notification> 看起来像 user-role message,来源却不是用户本人。Monitor stdout、PR comment、remote peer message、hook output 都可能包含不可信文本。

一个 harness 应该给模型明确 provenance,并在 system prompt 里说明:外部 channel 内容只能作为 situational awareness,不能自动升级成 authority。Claude Code 的 cross-session / channel / task-notification 设计都在往这个方向做。

5. Backgrounding Must Return A Handle

所有 background work 都需要 task id、output file、status、stop API、notification envelope。裸 & 没有这些东西,所以 harness 不能恢复、不能通知、不能取消、不能 dedupe。

最小 contract:

type BackgroundTask = {
  taskId: string
  type: "local_shell" | "local_agent" | "local_workflow" | "remote_agent"
  description: string
  status: "pending" | "running" | "completed" | "failed" | "killed"
  outputFile?: string
  ownerAgentId?: string
  toolUseId?: string
  notified?: boolean
}

6. Hooks Need Future-Awareness

Stop hook 如果只知道“assistant 要结束 turn”,就会频繁误判。它还需要知道:

  • 有没有 in-flight background task。
  • 有没有 scheduled wakeup。
  • 当前 stop hook 是否已经 active,避免阻断循环。

Claude Code 把这些放进 Stop/SubagentStop input,是一个很好的 harness contract。

Failure Modes

Failure结果Harness 防线
模型忘记 re-arm ScheduleWakeupdynamic loop 结束tool prompt + optional keepalive
Monitor filter 太窄crash / failure 时沉默prompt 要求覆盖 terminal states
Monitor filter 太宽上下文被事件刷爆rate limit + auto-stop
用户关闭 REPLsession-only cron 消失durable opt-in
durable one-shot missed下次启动 catch-upmissed task notification
多进程同时 fire durable jobs重复执行.claude/scheduled_tasks.lock
headless background task无 re-entry禁止/避免 background,改同步或外部 orchestrator
裸 shell &harness 不知道任务存在工具文案禁止,要求 run_in_background
外部事件含 prompt injection模型误当用户指令provenance + untrusted channel reminder

Putting It Together

一个 Claude Code 风格的自节奏 loop,大概可以写成下面的 harness 状态机:

flowchart TD A["model receives task"] --> B["does immediate work"] B --> C{"needs future continuation?"} C -->|no| Z["end normally"] C -->|time-based| D["ScheduleWakeup / CronCreate"] C -->|event-based| E["Monitor / background task"] E --> F["optional fallback ScheduleWakeup"] D --> G["harness stores future intent"] F --> G G --> H["turn ends"] H --> I{"future event fires"} I -->|timer| J["enqueue scheduled prompt"] I -->|stdout / task exit / hook| K["enqueue task-notification"] J --> L["new model turn"] K --> L L --> B

这个设计把 agent 的长期性拆成了两种能力:

  • 策略长期性:模型能读历史、理解目标、选择下一步、re-arm。
  • 运行长期性:harness 能持久化任务、监听事件、恢复进程、重新投影输入。

二者分开后,模型调用可以保持短事务,harness 可以被测试、限流、恢复、审计。Claude Code 的 wakeup 机制最值得借鉴的地方就在这里:它没有让模型假装自己是 daemon;它让 harness 成为 daemon,模型成为每次事件到达时重新规划的 planner / actor。

Practical Checklist For Harness Builders

如果你在设计自己的 agent runtime,可以直接拿这份清单对照:

  • 是否有统一 command queue,避免每类异步事件各走一套模型入口?
  • queued input 是否带 provenance,并能区分 human / task-notification / peer / channel?
  • background task 是否都有 task id、output file、status、stop API?
  • event watcher 是否有 output rate limit、batch、timeout、persistent flag?
  • time scheduler 是否有 max age、jitter、idle-only fire、durable opt-in?
  • durable scheduler 是否有 lock 和 stale lock recovery?
  • missed one-shot 是否会 catch-up,避免静默丢失?
  • headless mode 是否明确禁用或替代 interactive background continuation?
  • Stop hook 是否能看到 pending background tasks 和 scheduled wakeups?
  • 外部事件文本是否被标成 untrusted data?
  • 模型是否需要每轮显式 re-arm,从而保留停止权?

如果这些答案都清楚,你的 harness 就已经越过了“能跑后台任务”的阶段,进入了“能安全托管 agent 的未来”的阶段。