Skip to content

feat(loop): add second-resolution session wakeup engine - #5182

Merged
wenshao merged 9 commits into
QwenLM:mainfrom
qqqys:feat/loop-session-wakeup
Jun 18, 2026
Merged

wenshao merged 9 commits into
QwenLM:mainfrom
qqqys:feat/loop-session-wakeup

Conversation

@qqqys

@qqqys qqqys commented Jun 16, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Adds a session-scoped second-resolution wakeup engine for self-paced /loop, aligned with Claude Code's ScheduleWakeup. This is Step 1 of aligning /loop with CC.

The engine is an independent wakeup channel inside CronScheduler, separate from cron jobs — never durable, not counted against MAX_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 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-arming keeps the loop alive, omitting the call ends it (CC's "call to keep alive / omit to end"). cancelWakeup / cancelAllWakeups primitives back future loop-scoped cancellation on abort.

A loop_wakeup tool exposes it: a delaySeconds schema 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); a prompt arg passed verbatim so the next firing re-enters the skill; a reason shown to the user. getDefaultPermission stays 'ask' (out of SAFE_TOOL_ALLOWLIST) so AUTO routes it through the classifier, like CronCreate.

Why it's needed

Self-paced /loop needs 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 /loop wiring (#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.
  • Permission / autoMode / config registration suites green.
  • cli cron-delivery suites (nonInteractiveCli / useGeminiStream / ACP Session) green — confirming no cli source change is required.
  • npm run build + npm run typecheck green.

CI is green on macOS, Windows, and Linux.

Evidence (Before & After)

N/A — non-UI change. Behavioral delta: before, /loop could only schedule via minute-rounded recurring cron; after, the model can arm an exact, second-resolution one-shot wakeup (scheduleWakeup) that fires through the same onFire channel.

Tested on

OS Status
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Unit tests only (vitest); no local runtime needed.

Risk & Scope

  • Main risk or tradeoff: a new scheduling channel inside CronScheduler; mitigated by reusing the existing onFire delivery and sessionSize accounting, so no cli delivery-path code changes.
  • Not validated / out of scope: wiring into the /loop skill (Wire prompt-only /loop to self-paced wakeups #5184), the loop.md task file, autonomous bare /loop, monitor-as-primary signal, and Esc-driven cancelAllWakeups.
  • Breaking changes / migration notes: none — additive, session-only, never persisted.

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 工具对外暴露它:delaySeconds schema + 结构化的 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)全绿。
  • permission / autoMode / config 注册相关套件全绿。
  • cli cron 投递套件(nonInteractiveCli / useGeminiStream / ACP Session)全绿——印证无需改动 cli 源码。
  • npm run build + npm run typecheck 全绿。

CI 在 macOS、Windows、Linux 三平台均为绿。

证据(前后对比)

N/A —— 非 UI 改动。行为差异:之前 /loop 只能通过分钟取整的重复 cron 调度;之后模型可以 arm 一个精确的秒级一次性 wakeup(scheduleWakeup),并通过同一 onFire 通道触发。

测试平台

OS 状态
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

仅单测(vitest);无需本地运行时。

风险与范围

  • 主要风险 / 取舍:在 CronScheduler 内新增一条调度通道;通过复用既有 onFire 投递与 sessionSize 计数来缓解,因此不改动 cli 投递路径代码。
  • 未验证 / 超出范围:接入 /loop skill(Wire prompt-only /loop to self-paced wakeups #5184)、loop.md 任务文件、自治的裸 /loop、monitor 作为主信号、以及 Esc 驱动的 cancelAllWakeups。
  • 破坏性变更 / 迁移说明:无——纯增量、仅会话级、从不持久化。

关联 Issue

Closes #5156

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]>
@qqqys
qqqys force-pushed the feat/loop-session-wakeup branch from 7a392ef to f3ceec1 Compare June 16, 2026 09:21
@qqqys qqqys changed the title feat(loop): add session wakeup primitive feat(loop): add second-resolution session wakeup engine Jun 16, 2026
doudouOUC
doudouOUC previously approved these changes Jun 16, 2026
}

/**
* Forward the continuation prompt and cadence to the AUTO classifier —

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
* 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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
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)}`,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

@wenshao

wenshao commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer verification — wakeup engine driven end-to-end in a real TUI

This is an engine + tool PR (Step 1; /loop skill wiring is out of scope per #5184), so I verified it where it actually runs: I drove the loop_wakeup tool through the real interactive TUI under tmux, watched a scheduled wakeup actually fire ~60s later and re-invoke the model, and confirmed the clamp/exit-summary behavior — plus the full changed-file test suite and a typecheck.

Setup

  • Source: PR head 8dd6268, fresh npm ci + npm run bundle → dist/cli.js (v0.18.1).
  • Env: Linux, Node v22. Config isolated via QWEN_HOME.
  • Driving the agent: invoking loop_wakeup needs the model to emit a tool call, so I pointed the OpenAI-compatible provider at a local mock that emits one loop_wakeup({delaySeconds: 5, prompt: "<continuation>", reason: ...}) and then, when the fired continuation prompt comes back as a fresh user turn, replies with text (without re-arming, to end the loop). Only the tool call is scripted — the scheduler, the onFire delivery, the notification queue, and the re-invocation are all the PR's real code. loop_wakeup is registered by default (cronEnabled defaults true) and, despite being a deferred tool, a direct model call executes fine.

1. Schedule + clamp report (delaySeconds: 5 → clamped to 60s)

The tool surfaces as an ask-gated confirmation (same as CronCreate), and the requested 5s is reported as clamped to the floor:

?  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)

Approved → the scheduler returns an exact, second-resolution ISO fire time and echoes the 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. The wakeup actually fires and re-invokes the model (the headline)

~60s later the tick fires it through the existing onFire channel → it lands on the interactive notification queue as a Cron: item → auto-submits as its own turn → the model is re-invoked with the verbatim continuation prompt:

✦ Scheduled a wakeup; I will resume automatically when it fires.
● Cron: WAKEUP_FIRED continue the self-paced loo          ← fire delivered via onFire
✦ ✅ Woke up via loop_wakeup and resumed the loop. Ending now.   ← model re-invoked with the prompt

Because the woken-up turn did not schedule another wakeup, the loop ended — matching CC's "call again to keep alive / omit to end." (Re-arming instead would continue it; the engine clears the prior wakeup on each scheduleWakeup, so only one is ever pending.) This confirms "no CLI delivery-path changes" — it rides the same path cron fires already use.

3. Pending wakeup is tracked (exit summary)

Scheduling another wakeup and quitting before it fires prints the session exit summary, listing the pending wakeup with id, local fire time, and 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 exposure of wakeups is covered by the unit tests below.)

4. Focused tests + typecheck (on PR head)

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], 1200 default for non-finite, wasClamped reported 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, and scheduleWakeup clears 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer re-verification — new commit f9fb3e6c driven end-to-end (delta on my prior 8dd6268 check)

My earlier verification covered head 8dd6268. The branch has since advanced by exactly one commit — f9fb3e6c "fix(loop): align wakeup delay reporting" — and that commit changes user-visible reporting, so I rebuilt the new head and re-drove it through the real interactive TUI under tmux, focused on what this commit changes, plus a full regression of the core schedule → fire → re-invoke path and the changed-file test suite.

What f9fb3e6c actually changes (4 things)

  1. Exports WAKEUP_MIN_SECONDS / WAKEUP_MAX_SECONDS and interpolates them into the clamp message + the delaySeconds schema description (single source of truth; same [60, 3600]).
  2. Exit-summary fire time .toLocaleString() → .toISOString().
  3. getDescription() now rounds delaySeconds before the clamp comparison → a sub-second input that rounds into range no longer shows a (requested …) suffix, aligning the preview with the wasClamped flag.
  4. toAutoClassifierInput projects the clamped delay (5 → 60) into the AUTO classifier.

Setup

  • Source: PR head f9fb3e6c, fresh npm run bundle → dist/cli.js (v0.18.1). The bundled chunk literally carries the new code (wakeup at ${new Date(wakeup.fireAtMs).toISOString()}, the roundedDelaySeconds logic, the clamped classifier projection).
  • Env: Linux, Node v22. Config isolated via a throwaway HOME; OpenAI-compatible provider pointed at a local mock that emits one loop_wakeup(...) tool call and then, when the fired continuation comes back as a fresh user turn, replies with plain text (no re-arm, to end the loop). Only the tool call is scripted — the scheduler, the onFire delivery, the notification queue and the re-invocation are all the PR's real code.
  • Aside: the only npm run build failure on this box is the unrelated web-shell package (missing katex asset in this environment) — this PR touches packages/core only; core compiles clean and the bundle is built directly from source.

1. Clamp report still correct — 5 → 60s (requested 5s), now via the exported constants

delaySeconds: 5 rounds to 5, which is below the floor → genuinely clamped → the (requested 5s) suffix shows, and an exact ISO fire time is returned:

?  LoopWakeup 60s (requested 5s): WAKEUP_FIRED continue the self-paced loop
 › 1. Yes, allow once  …
✓  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. NEW alignment — 59.6 → 60s with no (requested …) suffix

delaySeconds: 59.6 rounds to 60, which is in range → it is not a meaningful clamp, so the preview drops the suffix (matching wasClamped=false). Direct contrast with §1:

?  LoopWakeup 60s: WAKEUP_FIRED continue the self-paced loop          ← no "(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

This is exactly the inconsistency the commit fixes: before, the description said 60s (requested 59.6s) while the schedule path reported no clamp — now both agree.

3. NEW exit-summary is ISO (.toISOString())

Scheduling a wakeup and quitting before it fires now prints the pending wakeup with an ISO fire time (my prior report on 8dd6268 showed the locale form 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

The id 70ffg3d4 matches the scheduled wakeup, and /quit cancelled the single pending one — pending-wakeup tracking intact.

4. Core path — exact fire + re-invoke, no regression

The headline behavior from the prior check still holds on the new head. The wakeup fired through the existing onFire channel → landed on the notification queue → auto-submitted as its own turn → re-invoked the model with the verbatim continuation, then ended (no re-arm):

✦ Scheduled. I will resume automatically when the wakeup fires.
● Cron: WAKEUP_FIRED continue the self-paced loo          ← fire delivered via onFire
✦ ✅ Woke up via loop_wakeup and resumed the loop. Ending now.   ← model re-invoked with the prompt

Fired at 01:04:17.971Z vs scheduled 01:04:17.862Z — ~110 ms, exact second-resolution, not minute-rounded.

5. AUTO-classifier clamp projection (5 → 60)

Change #4 feeds the clamped delay to the AUTO classifier; that projection is internal input (not surfaced in interactive ask mode, which is what I drove), and is covered by the new unit test projects the clamped delay into AUTO classifier input (green, by name below).

6. Focused tests + typecheck (on PR head f9fb3e6c)

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 处)

  1. 导出 WAKEUP_MIN_SECONDS / WAKEUP_MAX_SECONDS,并在 clamp 文案与 delaySeconds schema 描述中插值(单一真源;数值仍为 [60, 3600])。
  2. 退出摘要触发时间 .toLocaleString() → .toISOString()。
  3. getDescription() 在比较 clamp 前先对 delaySeconds 取整 → 取整后落入区间的亚秒输入不再显示 (requested …) 后缀,使预览与 wasClamped 标志一致。
  4. 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.

wenshao
wenshao previously approved these changes Jun 18, 2026
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao merged commit 716d221 into QwenLM:main Jun 18, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a second-resolution session wakeup engine (align CC ScheduleWakeup)

3 participants