Repository navigation
feat(loop): add second-resolution session wakeup engine - #5182
Conversation
Add a session-scoped wakeup primitive for self-paced /loop, aligned with
Claude Code's ScheduleWakeup. An independent, second-resolution channel in
CronScheduler — separate from cron jobs (never durable, not counted against
MAX_JOBS, fired at an exact time, not minute-rounded):
- scheduleWakeup(delaySeconds, prompt): clamps to [60, 3600]s (1200s default
for non-finite input); returns {scheduledFor, clampedDelaySeconds, wasClamped}.
- Fires through the existing onFire channel and counts toward sessionSize, so
there are no cli delivery-path changes and a pending wakeup holds a headless
run open — re-arm keeps the loop alive, omitting the call ends it.
- cancelWakeup / cancelAllWakeups primitives (for loop-scoped cancellation).
- loop_wakeup tool: delaySeconds schema, structured clamp output, cache-window
picking guidance, verbatim /loop prompt, reason shown to the user, and the
"call to keep alive / omit to end" contract — all mirroring ScheduleWakeup.
getDefaultPermission stays 'ask' (out of SAFE_TOOL_ALLOWLIST) so AUTO still
routes scheduling future model input through the classifier, like CronCreate.
Closes QwenLM#5156
Co-authored-by: Qwen-Coder <[email protected]>
7a392ef to
f3ceec1
Compare
| } | ||
|
|
||
| /** | ||
| * Forward the continuation prompt and cadence to the AUTO classifier — |
There was a problem hiding this comment.
[Suggestion] toAutoClassifierInput forwards the raw model-supplied delaySeconds to the AUTO classifier, while the actual scheduled delay is clamped to [60, 3600] by scheduleWakeup. The human approver sees the clamped value via getDescription().
This means the classifier evaluates a different delay than what actually executes — e.g., delaySeconds: 5 looks like a 5-second wakeup to the classifier but schedules a 60-second one. cron_create's classifier input uses the cron expression verbatim, which IS the actual schedule — no such asymmetry exists there.
| * Forward the continuation prompt and cadence to the AUTO classifier — | |
| override toAutoClassifierInput( | |
| params: LoopWakeupParams, | |
| ): Record<string, unknown> { | |
| return { | |
| delaySeconds: clampWakeupSeconds(params.delaySeconds), | |
| prompt: params.prompt, | |
| reason: params.reason ?? '', | |
| }; | |
| } |
— qwen3.7-max via Qwen Code /review
| await expect(invocation.getDefaultPermission()).resolves.toBe('ask'); | ||
| }); | ||
|
|
||
| it('shows the clamped delay in the permission description', () => { |
There was a problem hiding this comment.
[Suggestion] Test gap: the scheduler-level tests cover clampWakeupSeconds(Infinity) and NaN, but no tool-level test passes non-finite delaySeconds through getDescription() or execute(). This means formatRequested(Infinity) → "Infinity" in the permission description and the clamp message ("Requested Infinity was clamped to the [60, 3600] s range.") is unverified at the tool layer. One additional test would cover both paths and the formatRequested non-finite arm.
— qwen3.7-max via Qwen Code /review
| expect(scheduler.cancelAllWakeups()).toBe(0); | ||
| }); | ||
|
|
||
| it('reports pending wakeups in the exit summary', () => { |
There was a problem hiding this comment.
[Suggestion] Test gap: existing tests cover cron-only and wakeup-only exit summaries, but no test creates both a session cron job AND a pending wakeup, then calls getExitSummary(). The combined path — count = sessionJobs.length + wakeups.length, pluralization with a mixed count, cron entries appearing before wakeup entries, and humanReadableCron() + wakeup at <locale> coexisting — is unverified. This is the most likely real-world scenario for a user with both a /loop cron and a self-paced wakeup.
— qwen3.7-max via Qwen Code /review
|
|
||
| getDescription(): string { | ||
| const clamped = clampWakeupSeconds(this.params.delaySeconds); | ||
| const prefix = |
There was a problem hiding this comment.
[Nice to have] wasClamped and getDescription() disagree for fractional boundary inputs. For delaySeconds = 59.6: wasClamped uses Math.round(59.6) = 60 (within bounds → false), but getDescription compares clamped(60) === raw(59.6) (→ false, shows "60s (requested 59.6s)"). The permission prompt implies modification while the tool result says no clamping occurred — contradictory signals in the same tool call.
Fix: compare against the rounded value in getDescription, mirroring scheduleWakeup:
| const prefix = | |
| getDescription(): string { | |
| const clamped = clampWakeupSeconds(this.params.delaySeconds); | |
| const rounded = Number.isFinite(this.params.delaySeconds) | |
| ? Math.round(this.params.delaySeconds) | |
| : this.params.delaySeconds; | |
| const prefix = | |
| clamped === rounded | |
| ? `${clamped}s` | |
| : `${clamped}s (requested ${formatRequested(this.params.delaySeconds)})`; | |
| return `${prefix}: ${this.params.prompt}`; | |
| } |
— qwen3.7-max via Qwen Code /review
| ` - [${wakeup.id}] wakeup at ${new Date( | ||
| wakeup.fireAtMs, | ||
| ).toLocaleString()}: ${truncatePrompt(wakeup.prompt)}`, | ||
| ); |
There was a problem hiding this comment.
[Nice to have] getExitSummary formats the wakeup fire time with toLocaleString() (locale-dependent, local timezone), while scheduleWakeup() returns new Date(fireAtMs).toISOString() (UTC ISO 8601). The same wakeup displays in two different formats/timezones — e.g., a user in UTC+8 sees wakeup at 1/15/2025, 6:35:00 PM in the exit summary after scheduling confirmed Scheduled for: 2025-01-15T10:35:00.000Z.
— qwen3.7-max via Qwen Code /review
|
|
||
| const count = sessionJobs.length; | ||
| const count = sessionJobs.length + wakeups.length; | ||
| const lines = [ |
There was a problem hiding this comment.
[Nice to have] getExitSummary says "N active loop(s) cancelled" counting both cron jobs and wakeups. A one-shot wakeup is not itself a loop — it's a scheduled continuation within a self-paced loop. A session with 1 cron job and 1 wakeup would say "2 active loops cancelled" when one is recurring and the other is one-shot. Minor wording imprecision.
— qwen3.7-max via Qwen Code /review
✅ Maintainer verification — wakeup engine driven end-to-end in a real TUIThis is an engine + tool PR (Step 1; Setup
1. Schedule + clamp report (
|
| File | Tests |
|---|---|
services/cronScheduler.test.ts (incl. 8 session-wakeup) |
84 ✅ |
tools/loop-wakeup.test.ts |
13 ✅ |
tools/cron-list.test.ts |
8 ✅ |
tools/cron-delete.test.ts |
7 ✅ |
permissions/permission-manager.test.ts |
246 ✅ |
permissions/autoMode.test.ts |
77 ✅ |
config/config.test.ts |
228 ✅ |
tsc --noEmit (core) |
clean ✅ |
663 tests pass. (No CLI source changed, so the cron-delivery suites the PR lists are unaffected.)
Notes
- Clamp boundaries match the spec end-to-end:
[60, 3600],1200default for non-finite,wasClampedreported in both the confirmation description and the tool result. - Firing is exact second-resolution (it fired right at the 60s mark, not minute-rounded) — distinct from cron jobs, exactly as designed.
- The wakeup map is separate from cron jobs: never durable, not counted against
MAX_JOBS, andscheduleWakeupclears any prior pending wakeup (single in-flight wakeup) — consistent with the observed behavior and the unit tests.
Verdict: The session wakeup engine and loop_wakeup tool work end-to-end through the real interactive delivery path — schedule, clamp, exact fire, re-invoke, and exit tracking all behave as described. Solid Step-1 foundation for #5156. LGTM. 👍
🇨🇳 中文版(点击展开)
✅ 维护者验证 —— 在真实 TUI 中端到端跑通了 wakeup 引擎
这是一个「引擎 + 工具」PR(Step 1,按 #5184 /loop skill 接线不在本 PR 范围内),所以我在它真正运行的地方做了验证:在 tmux 中通过真实交互式 TUI 驱动 loop_wakeup 工具,亲眼看到一个调度的 wakeup 在约 60s 后真正触发并重新唤起模型,并确认了 clamp / 退出摘要行为 —— 另加全部改动文件的测试与 typecheck。
环境
- 源码: PR head
8dd6268,全新npm ci+npm run bundle→dist/cli.js(v0.18.1)。 - 环境: Linux,Node v22。通过
QWEN_HOME隔离配置。 - 如何驱动 agent: 调用
loop_wakeup需要模型发出工具调用,因此我把 OpenAI 兼容 provider 指向本地 mock,让它发出一次loop_wakeup({delaySeconds: 5, prompt: "<continuation>", reason: ...});当被触发的 continuation prompt 作为新的 user 轮次回来时,回复文本(且不再次 re-arm,以结束循环)。只有「工具调用」是脚本化的 —— 调度器、onFire投递、通知队列、重新唤起全部是 PR 的真实代码。loop_wakeup默认注册(cronEnabled默认为 true);尽管它是 deferred 工具,模型直接调用也能正常执行。
1. 调度 + clamp 报告(delaySeconds: 5 → 收敛到 60s)
该工具以 ask 方式确认(与 CronCreate 一致),请求的 5s 被报告为收敛到下限:
? LoopWakeup 60s (requested 5s): WAKEUP_FIRED continue the self-paced loop
60s (requested 5s): WAKEUP_FIRED continue the self-paced loop
Do you want to proceed?
› 1. Yes, allow once
2. Always allow loop_wakeup in this project
3. Always allow loop_wakeup for this user
4. No, suggest changes (esc)
确认后,调度器返回精确到秒的 ISO 触发时间并回显 reason:
✓ LoopWakeup 60s (requested 5s): WAKEUP_FIRED continue the self-paced loop
Loop wakeup cxusm81j scheduled for 2026-06-16T23:34:05.712Z — demo: polling fast-changing state
✦ Scheduled a wakeup; I will resume automatically when it fires.
2. wakeup 真正触发并重新唤起模型(核心)
约 60s 后,tick 通过既有的 onFire 通道触发它 → 作为 Cron: 项落到交互式通知队列 → 自动作为独立轮次提交 → 模型被用逐字的 continuation prompt 重新唤起:
✦ Scheduled a wakeup; I will resume automatically when it fires.
● Cron: WAKEUP_FIRED continue the self-paced loo ← 通过 onFire 投递的触发
✦ ✅ Woke up via loop_wakeup and resumed the loop. Ending now. ← 模型被该 prompt 重新唤起
因为被唤醒的这一轮没有再次调度 wakeup,循环随之结束 —— 符合 CC 的「再次调用以保活 / 省略以结束」。(若改为 re-arm 则会继续;引擎在每次 scheduleWakeup 时清除前一个 wakeup,因此始终只有一个待触发。)这印证了「无 CLI 投递路径改动」—— 它复用了 cron 触发已有的路径。
3. 待触发的 wakeup 被跟踪(退出摘要)
再调度一个 wakeup 并在其触发前退出,会打印会话退出摘要,列出该待触发 wakeup 的 id、本地触发时间与 prompt:
Session ending. 1 active loop cancelled:
- [l959l7kt] wakeup at 6/17/2026, 7:36:36 AM: WAKEUP_FIRED continue the self-paced loop
(cron_list / cron_delete 对 wakeup 的暴露由下方单测覆盖。)
4. 聚焦测试 + typecheck(基于 PR head)
| 文件 | 测试数 |
|---|---|
services/cronScheduler.test.ts(含 8 个 session-wakeup) |
84 ✅ |
tools/loop-wakeup.test.ts |
13 ✅ |
tools/cron-list.test.ts |
8 ✅ |
tools/cron-delete.test.ts |
7 ✅ |
permissions/permission-manager.test.ts |
246 ✅ |
permissions/autoMode.test.ts |
77 ✅ |
config/config.test.ts |
228 ✅ |
tsc --noEmit(core) |
无报错 ✅ |
663 个测试全部通过。(本 PR 未改动任何 CLI 源码,所以 PR 中列出的 cron 投递套件不受影响。)
说明
- Clamp 边界端到端符合规范:
[60, 3600],非有限输入默认1200,wasClamped在确认描述与工具结果中均有报告。 - 触发是精确到秒的(在 60s 处准时触发,而非按分钟取整)—— 与 cron 任务不同,正如设计意图。
- wakeup 与 cron 任务分离存储:从不持久化、不计入
MAX_JOBS,且scheduleWakeup会清除前一个待触发 wakeup(同时只有一个)—— 与观测行为及单测一致。
结论: session wakeup 引擎与 loop_wakeup 工具通过真实交互投递路径端到端工作 —— 调度、clamp、精确触发、重新唤起、退出跟踪均符合描述。是 #5156 扎实的 Step-1 基础。可以合并。👍
Verified locally in an isolated environment; the model's tool call was stubbed via a local mock — the scheduler, onFire delivery, fire, and re-invocation are the PR's real code.
| * keys its hold-open loop on this: durable jobs outlive the process by | ||
| * design and never fire without lock ownership, so they must not pin it. | ||
| */ | ||
| get sessionSize(): number { |
There was a problem hiding this comment.
[Suggestion] get size() (line 379) returns this.jobs.size only, excluding wakeups. All other query surfaces (sessionSize here, list(), hasPendingWork, getExitSummary) were updated to include wakeups, but size was missed. While there are currently no production callers, this public getter's semantics are misleading — a future caller using size === 0 to check "scheduler is idle" would get a false negative when only a wakeup is pending.
Suggested fix: either include wakeups (return this.jobs.size + this.wakeups.size) or rename to jobCount to clarify scope.
— qwen3.7-max via Qwen Code /review
|
|
||
| if (jobs.length === 0) { | ||
| const result = 'No active cron jobs.'; | ||
| const result = 'No active cron jobs or loop wakeups.'; |
There was a problem hiding this comment.
[Suggestion] humanReadableCron('@wakeup') returns the raw @wakeup sentinel string because it's not a 5-field cron expression. Users running /cron_list see abc123 @wakeup [session-only] in terminal output — an internal implementation detail with no user-facing meaning.
Suggested fix: add a @wakeup branch to humanReadableCron (e.g., return "one-shot wakeup"), or special-case cronExpr === '@wakeup' in the display renderer.
— qwen3.7-max via Qwen Code /review
✅ Maintainer re-verification — new commit
|
| File | Tests |
|---|---|
services/cronScheduler.test.ts |
84 ✅ |
tools/loop-wakeup.test.ts (13 → 15: +requested suffix when rounding lands in range, +projects the clamped delay into AUTO classifier input) |
15 ✅ |
tools/cron-list.test.ts |
8 ✅ |
tools/cron-delete.test.ts |
7 ✅ |
permissions/permission-manager.test.ts |
246 ✅ |
permissions/autoMode.test.ts |
77 ✅ |
config/config.test.ts |
228 ✅ |
tsc --noEmit (core) |
clean ✅ |
665 tests pass.
Verdict
f9fb3e6c does exactly what it says: it aligns the three reporting surfaces (description preview, schedule/wasClamped flag, classifier input) so sub-second rounding is no longer mislabeled as "clamped", and switches the exit summary to ISO — all with no regression to the core schedule → exact fire → re-invoke path. My prior LGTM stands for the updated head. 👍
🇨🇳 中文版(点击展开)
✅ 维护者复验 —— 新提交 f9fb3e6c 端到端跑通(在此前 8dd6268 验证基础上的增量)
我此前的验证针对的是 head 8dd6268。此后分支只新增了一个提交 —— f9fb3e6c "fix(loop): align wakeup delay reporting" —— 且该提交改动了用户可见的上报内容,因此我重新构建了新 head,并在 tmux 下通过真实交互式 TUI 再次驱动,重点验证该提交改了什么,外加对核心「调度 → 触发 → 重新唤起」路径的完整回归与改动文件测试套件。
f9fb3e6c 实际改动(4 处)
- 导出
WAKEUP_MIN_SECONDS/WAKEUP_MAX_SECONDS,并在 clamp 文案与delaySecondsschema 描述中插值(单一真源;数值仍为[60, 3600])。 - 退出摘要触发时间
.toLocaleString()→.toISOString()。 getDescription()在比较 clamp 前先对delaySeconds取整 → 取整后落入区间的亚秒输入不再显示(requested …)后缀,使预览与wasClamped标志一致。toAutoClassifierInput向 AUTO 分类器投射 clamp 后的延迟(5→60)。
环境
- 源码: PR head
f9fb3e6c,全新npm run bundle→dist/cli.js(v0.18.1)。打包后的 chunk 确实包含新代码(wakeup at ${new Date(wakeup.fireAtMs).toISOString()}、roundedDelaySeconds逻辑、clamp 后的分类器投射)。 - 环境: Linux,Node v22。通过一次性
HOME隔离配置;OpenAI 兼容 provider 指向本地 mock,让其发出一次loop_wakeup(...)工具调用;当被触发的 continuation 作为新 user 轮次回来时回复纯文本(不再 re-arm,以结束循环)。只有「工具调用」是脚本化的 —— 调度器、onFire投递、通知队列与重新唤起全是 PR 的真实代码。 - 附注: 本机上
npm run build唯一的失败在无关的web-shell包(环境缺少katex资源)—— 本 PR 仅改动packages/core;core 编译干净,bundle 直接从源码构建。
1. Clamp 上报仍正确 —— 5 → 60s (requested 5s),现在走导出的常量
delaySeconds: 5 取整为 5,低于下限 → 确属 clamp → 显示 (requested 5s) 后缀,并返回精确 ISO 触发时间:
? LoopWakeup 60s (requested 5s): WAKEUP_FIRED continue the self-paced loop
✓ LoopWakeup 60s (requested 5s): WAKEUP_FIRED continue the self-paced loop
Loop wakeup wos8ljqu scheduled for 2026-06-18T01:04:17.862Z — demo: polling fast-changing state
2. 新增对齐 —— 59.6 → 60s,无 (requested …) 后缀
delaySeconds: 59.6 取整为 60,落在区间内 → 不算有意义的 clamp,故预览去掉后缀(与 wasClamped=false 一致)。与 §1 形成直接对比:
? LoopWakeup 60s: WAKEUP_FIRED continue the self-paced loop ← 没有 "(requested 59.6s)"
✓ LoopWakeup 60s: WAKEUP_FIRED continue the self-paced loop
Loop wakeup 70ffg3d4 scheduled for 2026-06-18T01:06:41.484Z — demo: sub-second rounding alignment
这正是该提交修复的不一致:此前描述显示 60s (requested 59.6s),而调度路径却报告未 clamp —— 现在两者一致。
3. 新增退出摘要为 ISO(.toISOString())
调度一个 wakeup 并在其触发前退出,现在以 ISO 触发时间打印待触发 wakeup(我此前在 8dd6268 的报告显示的是 locale 形式 6/17/2026, 7:36:36 AM):
Session ending. 1 active loop cancelled:
- [70ffg3d4] wakeup at 2026-06-18T01:06:41.484Z: WAKEUP_FIRED continue the self-paced loop
id 70ffg3d4 与所调度的 wakeup 一致,/quit 取消了这唯一待触发项 —— 待触发跟踪正常。
4. 核心路径 —— 精确触发 + 重新唤起,无回归
此前验证的核心行为在新 head 上依旧成立。wakeup 通过既有 onFire 通道触发 → 落到通知队列 → 作为独立轮次自动提交 → 用逐字 continuation 重新唤起模型,随后结束(未 re-arm):
✦ Scheduled. I will resume automatically when the wakeup fires.
● Cron: WAKEUP_FIRED continue the self-paced loo ← 通过 onFire 投递的触发
✦ ✅ Woke up via loop_wakeup and resumed the loop. Ending now. ← 模型被该 prompt 重新唤起
触发于 01:04:17.971Z,调度时间 01:04:17.862Z —— 约 110 ms,精确到秒,而非按分钟取整。
5. AUTO 分类器 clamp 投射(5 → 60)
改动 #4 把 clamp 后的延迟喂给 AUTO 分类器;该投射是内部输入(在我驱动的交互式 ask 模式下不外显),由新单测 projects the clamped delay into AUTO classifier input 覆盖(下表绿,按名核对)。
6. 聚焦测试 + typecheck(基于 PR head f9fb3e6c)
| 文件 | 测试数 |
|---|---|
services/cronScheduler.test.ts |
84 ✅ |
tools/loop-wakeup.test.ts(13 → 15:新增「rounding lands in range 不显示 requested 后缀」「向 AUTO 分类器投射 clamp 后的延迟」) |
15 ✅ |
tools/cron-list.test.ts |
8 ✅ |
tools/cron-delete.test.ts |
7 ✅ |
permissions/permission-manager.test.ts |
246 ✅ |
permissions/autoMode.test.ts |
77 ✅ |
config/config.test.ts |
228 ✅ |
tsc --noEmit(core) |
无报错 ✅ |
665 个测试全部通过。
结论
f9fb3e6c 名副其实:对齐了三处上报面(描述预览、调度 wasClamped 标志、分类器输入),使亚秒取整不再被误标为「clamped」,并将退出摘要切换为 ISO —— 且对核心「调度 → 精确触发 → 重新唤起」路径无回归。我此前的 LGTM 对更新后的 head 依然成立。👍
Re-verified locally in an isolated environment at head f9fb3e6c; the model's tool call was stubbed via a local mock — the scheduler, onFire delivery, fire, and re-invocation are the PR's real code.
|
@qwen-code /triage |
|
@qwen-code /triage |
What this PR does
Adds a session-scoped second-resolution wakeup engine for self-paced
/loop, aligned with Claude Code'sScheduleWakeup. This is Step 1 of aligning/loopwith CC.The engine is an independent wakeup channel inside
CronScheduler, separate from cron jobs — never durable, not counted againstMAX_JOBS, and fired at an exact time (second resolution, not minute-rounded):scheduleWakeup(delaySeconds, prompt)clamps the delay to[60, 3600]s (1200s for non-finite input) and returns{ scheduledFor, clampedDelaySeconds, wasClamped }. It fires through the existingonFirechannel and counts towardsessionSize, so there are no cli delivery-path changes, and a pending wakeup holds a headless run open: re-arming keeps the loop alive, omitting the call ends it (CC's "call to keep alive / omit to end").cancelWakeup/cancelAllWakeupsprimitives back future loop-scoped cancellation on abort.A
loop_wakeuptool exposes it: adelaySecondsschema with structured clamp reporting (wasClamped) and CC's cache-window picking guidance in the description (prefer 60–270s when polling fast-changing state, 1200s+ otherwise, avoid ~300s, default idle 1200–1800s); apromptarg passed verbatim so the next firing re-enters the skill; areasonshown to the user.getDefaultPermissionstays'ask'(out ofSAFE_TOOL_ALLOWLIST) so AUTO routes it through the classifier, likeCronCreate.Why it's needed
Self-paced
/loopneeds the model to schedule its own next iteration at an arbitrary, exact delay. The existing cron path is minute-rounded and oriented around recurring jobs, so it can't express "wake me in 90 seconds" or a one-shot model-controlled cadence. This engine provides that primitive at second resolution while reusing the existing delivery path, so it lands without touching cli consumers — the foundation the prompt-only/loopwiring (#5184) builds on.Reviewer Test Plan
How to verify
This is non-user-visible engine + tool code; verify via the unit suites:
npx vitest run packages/core/src/services/cronScheduler.test.ts— session wakeups (8) + existing cron (64) green.npx vitest run packages/core/src/tools/loop-wakeup.test.ts— tool surface (9) green.npm run build+npm run typecheckgreen.CI is green on macOS, Windows, and Linux.
Evidence (Before & After)
N/A — non-UI change. Behavioral delta: before,
/loopcould only schedule via minute-rounded recurring cron; after, the model can arm an exact, second-resolution one-shot wakeup (scheduleWakeup) that fires through the sameonFirechannel.Tested on
Environment (optional)
Unit tests only (
vitest); no local runtime needed.Risk & Scope
CronScheduler; mitigated by reusing the existingonFiredelivery andsessionSizeaccounting, so no cli delivery-path code changes./loopskill (Wire prompt-only /loop to self-paced wakeups #5184), theloop.mdtask file, autonomous bare/loop, monitor-as-primary signal, and Esc-drivencancelAllWakeups.Linked Issues
Closes #5156
中文说明
这个 PR 做了什么
为自定步
/loop增加一个会话级、秒级精度的 wakeup 引擎,对齐 Claude Code 的ScheduleWakeup。这是把/loop对齐 CC 的第 1 步。引擎是
CronScheduler内部一条独立的 wakeup 通道,与 cron job 分离——永不持久化、不计入MAX_JOBS、按精确时间触发(秒级,不做分钟取整):scheduleWakeup(delaySeconds, prompt)把延迟夹到[60, 3600]秒(非有限输入用 1200 秒),返回{ scheduledFor, clampedDelaySeconds, wasClamped }。它通过已有的onFire通道触发并计入sessionSize,因此不改动 cli 投递路径;一个待触发的 wakeup 会让 headless 运行保持打开:再次 arm 保持 loop 存活,省略调用则结束 loop(即 CC 的"调用以续命 / 省略以结束")。cancelWakeup/cancelAllWakeups原语为将来 loop 级别的 abort 取消打底。loop_wakeup工具对外暴露它:delaySecondsschema + 结构化的 clamp 报告(wasClamped),描述里带 CC 的缓存窗口选择指引(轮询快变状态优先 60–270s,否则 1200s+,避开 ~300s,空闲默认 1200–1800s);prompt参数原样传递,使下次触发重新进入 skill;reason展示给用户。getDefaultPermission保持'ask'(不在SAFE_TOOL_ALLOWLIST中),让 AUTO 像CronCreate一样把它交给分类器。为什么需要
自定步
/loop需要模型以任意、精确的延迟来调度自己的下一轮迭代。现有 cron 路径是分钟取整、面向重复任务的,无法表达"90 秒后唤醒我"或一次性的、由模型控制的节奏。本引擎在秒级精度上提供该原语,同时复用既有投递路径,因此落地时不触碰 cli 消费方——这是 prompt-only/loop接线(#5184)所依赖的基础。审阅者测试计划
如何验证
这是非用户可见的引擎 + 工具代码;通过单测验证:
npx vitest run packages/core/src/services/cronScheduler.test.ts—— session wakeups(8)+ 既有 cron(64)全绿。npx vitest run packages/core/src/tools/loop-wakeup.test.ts—— 工具层(9)全绿。npm run build+npm run typecheck全绿。CI 在 macOS、Windows、Linux 三平台均为绿。
证据(前后对比)
N/A —— 非 UI 改动。行为差异:之前
/loop只能通过分钟取整的重复 cron 调度;之后模型可以 arm 一个精确的秒级一次性 wakeup(scheduleWakeup),并通过同一onFire通道触发。测试平台
环境(可选)
仅单测(
vitest);无需本地运行时。风险与范围
CronScheduler内新增一条调度通道;通过复用既有onFire投递与sessionSize计数来缓解,因此不改动 cli 投递路径代码。/loopskill(Wire prompt-only /loop to self-paced wakeups #5184)、loop.md任务文件、自治的裸/loop、monitor 作为主信号、以及 Esc 驱动的cancelAllWakeups。关联 Issue
Closes #5156