@zhushanwen/pi-scheduler 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +3 -3
- package/skills/scheduler-ext-config/SKILL.md +4 -4
- package/src/__tests__/cron.test.ts +2 -3
- package/src/__tests__/parsing.test.ts +2 -7
- package/src/__tests__/service.test.ts +1 -1
- package/src/commands.ts +1 -1
- package/src/format.ts +26 -14
- package/src/parsing.ts +32 -22
- package/src/runtime.ts +7 -11
- package/src/service.ts +3 -2
- package/src/tool.ts +2 -2
- package/src/types.ts +0 -1
- package/src/widget.ts +4 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ session_start
|
|
|
57
57
|
|
|
58
58
|
## /schedule 命令用法
|
|
59
59
|
|
|
60
|
-
注册为 `/schedule`
|
|
60
|
+
注册为 `/schedule` 命令。无参数时返回 TUI 未实现提示(用 `/schedule list` 查看任务);第一个参数匹配子命令关键词则走子命令分支,否则尝试创建任务。
|
|
61
61
|
|
|
62
62
|
### 子命令
|
|
63
63
|
|
|
@@ -90,7 +90,7 @@ session_start
|
|
|
90
90
|
|
|
91
91
|
### duration(间隔调度)
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
`<数字><单位>`,数字与单位间不留空格,**大小写不敏感**:
|
|
94
94
|
|
|
95
95
|
| 单位 | 含义 | 乘数 |
|
|
96
96
|
|------|------|------|
|
|
@@ -163,14 +163,14 @@ session_start
|
|
|
163
163
|
| 默认过期 | **7 天**(`DEFAULT_EXPIRY_MS`) | recurring 任务缺省 `expires` 时;`expires: 'never'` 关闭 |
|
|
164
164
|
| once 任务 | 触发后自动删除 | 不参与后续调度 |
|
|
165
165
|
| cron 失效 | 任务停用 + `lastStatus=failed` + `lastError='cron expression invalid'` | 不会用 `now()` 兜底导致每 tick 重触发死循环 |
|
|
166
|
-
| 忙时 dispatch | 非 force
|
|
166
|
+
| 忙时 dispatch | 非 force 任务经 session delivery 内核 park 模式:忙时消息入队等待(等 agent 空闲的 settled 边沿 / 下个 tick flush 投递),不丢弃 | force=true 绕过内核队列直投 |
|
|
167
167
|
| history | 保留最近 **20** 条执行记录 | 超出丢弃最旧;重放折叠时同样裁剪 |
|
|
168
168
|
| 持久化 | custom entry append 到 session JSONL(dispatch 成功后立即 append advance 记录执行) | 任务随 owner session 持久化,resume 后重放恢复,无需额外写盘 |
|
|
169
169
|
| 交付语义 | **at-least-once**(至少一次) | dispatch 成功后内存更新 nextRunAt 并 append advance;append 之前若进程崩溃可能重复注入一次(无精确一次保证,可接受) |
|
|
170
170
|
| 延迟写入窗口 | 新 session 首 turn 内建任务后进程崩溃可能丢失 | pi 延迟写入:首条 assistant 消息前不 flush。窗口窄、概率极低、无恢复手段 |
|
|
171
171
|
| 触发条件 | pi 进程需存活且 session 打开 | 电脑睡眠 / pi 进程未运行 = 不触发(非系统 cron,无后台守护) |
|
|
172
172
|
|
|
173
|
-
错误语义:创建时 schedule 解析失败 → `INVALID_SCHEDULE`;`run`/`toggle`/`delete` 引用不存在的 id → `TASK_NOT_FOUND`;`run` 时任务 disabled /
|
|
173
|
+
错误语义:创建时 schedule 解析失败 → `INVALID_SCHEDULE`;`run`/`toggle`/`delete` 引用不存在的 id → `TASK_NOT_FOUND`;`run` 时任务 disabled / rate-limited / 同任务已在投递队列(TTL 窗口内)→ `DISPATCH_SKIPPED`(message 含 `disabled, rate-limited, or already queued for delivery`)。
|
|
174
174
|
|
|
175
175
|
## 数据存储位置
|
|
176
176
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"xyz-agent": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"vitest.config.ts"
|
|
30
30
|
],
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
32
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
33
33
|
"croner": "^9.0.0",
|
|
34
34
|
"typebox": "*"
|
|
35
35
|
},
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@xyz-agent/session-delivery": "0.3.0",
|
|
49
|
-
"@zhushanwen/pi-extension-logger": "0.
|
|
49
|
+
"@zhushanwen/pi-extension-logger": "0.4.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"test": "vitest run",
|
|
@@ -33,7 +33,7 @@ description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)
|
|
|
33
33
|
|
|
34
34
|
- **`schedule`**(创建):参数 `prompt`(必填,到期注入的消息)、`schedule`(必填,duration 或 cron)、`kind`(`once`/`recurring`,默认 `recurring`)、`name`(可选,缺省从 prompt 自动截取前 30 字)、`expires`(可选,默认 7 天;传 `"never"` 关闭过期)、`force`(可选,默认 `false`)。
|
|
35
35
|
- **`schedule_control`**(管理):`action` = `list`/`toggle`/`delete`/`run`,`id`(toggle/delete/run 必填),`enabled`(toggle 必填)。
|
|
36
|
-
- 两个工具的返回都是结构化 `{content: [{type:'text', text}], details
|
|
36
|
+
- 两个工具的返回都是结构化 `{content: [{type:'text', text}], details}`;业务失败以异常抛出(pi 只对 execute throw 置 `isError:true`,错误 message 作为 toolResult content 返回),不通过返回值表达失败。
|
|
37
37
|
|
|
38
38
|
> 创建/管理操作无需 agent idle——只有**到期 dispatch** 才受 idle/速率限制约束(见「运行限制与 dispatch 行为」)。
|
|
39
39
|
|
|
@@ -52,7 +52,7 @@ description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)
|
|
|
52
52
|
| `h` | `hr`/`hour`/`hours` | 3_600_000 |
|
|
53
53
|
| `d` | `day`/`days` | 86_400_000 |
|
|
54
54
|
|
|
55
|
-
示例:`5m`、`2h`、`1d`、`30seconds`。正则 `/^(\d+)\s*(s|sec|...)$/i
|
|
55
|
+
示例:`5m`、`2h`、`1d`、`30seconds`。正则 `/^(\d+)\s*(s|sec|...)$/i`(注意:含空格的输入在 parseSchedule 分流时一律走 cron 分支,duration 实际不允许空格,`5 m` 解析失败)。
|
|
56
56
|
|
|
57
57
|
### cron(cron 表达式)
|
|
58
58
|
|
|
@@ -119,7 +119,7 @@ description: "使用或排查 @zhushanwen/pi-scheduler(定时任务调度)
|
|
|
119
119
|
|
|
120
120
|
dispatch 触发条件(`dispatchTask`):
|
|
121
121
|
|
|
122
|
-
- **非 force
|
|
122
|
+
- **非 force 任务**:走统一 session delivery 内核(park 模式)——到期即入队,agent 忙时消息 park 在内核队列,等 agent 空闲的 settled 边沿投递,scheduler 每 30s tick 触发一次 flush 兜底重试;不丢弃。
|
|
123
123
|
- **force=true 任务**:即使 agent busy 也立即触发(用于必须准点执行的场景)。
|
|
124
124
|
- dispatch 成功后:recurring 推进 `nextRunAt` 并 append `advance`;once 删除任务并 append `delete`;失败(`sendMessage` 抛错)记 `lastStatus='failed'` 不 rethrow,下个 tick 重试(transient 失败重试语义,不 append advance)。
|
|
125
125
|
- 注入的消息:`{content: task.prompt, customType: 'pi-scheduler:dispatched', display: true}`,`deliverAs: 'followUp'` + `triggerTurn: true`(排进 followUp 队列并唤醒 agent 开新 turn)。
|
|
@@ -137,7 +137,7 @@ dispatch 触发条件(`dispatchTask`):
|
|
|
137
137
|
- `force`:是否在 agent busy 时强制 dispatch。
|
|
138
138
|
- `createdAt` / `nextRunAt` / `expiresAt?`:时间戳(ms)。
|
|
139
139
|
- `runCount` / `lastRunAt?` / `lastStatus?`(`success`|`failed`)/ `lastError?`:执行统计。
|
|
140
|
-
- `history`:最近 20 条 `ExecutionRecord`(`{at, status
|
|
140
|
+
- `history`:最近 20 条 `ExecutionRecord`(`{at, status}`)。
|
|
141
141
|
- `ownerSessionFile?`:归属 session JSONL 路径(fork 过滤用,非持久化业务字段)。
|
|
142
142
|
- `pending?`:运行时标记「到期待 dispatch」,非持久化(与 `enabled` 正交)。
|
|
143
143
|
|
|
@@ -58,7 +58,7 @@ describe('computeNextCronRuns', () => {
|
|
|
58
58
|
})
|
|
59
59
|
|
|
60
60
|
describe('parseSchedule (cron 分支)', () => {
|
|
61
|
-
it('5 字段 cron
|
|
61
|
+
it('5 字段 cron 表达式补秒字段', async () => {
|
|
62
62
|
const result = await parseSchedule('*/10 * * * *')
|
|
63
63
|
expect(result).toEqual({
|
|
64
64
|
spec: { mode: 'cron', cronExpression: '0 */10 * * * *' },
|
|
@@ -85,8 +85,7 @@ describe('parseSchedule (cron 分支)', () => {
|
|
|
85
85
|
|
|
86
86
|
describe('normalizeCronExpression', () => {
|
|
87
87
|
it('6 字段 cron 原样返回', () => {
|
|
88
|
-
|
|
89
|
-
expect(result).toEqual({ expression: '0 9 * * 1-5 0' })
|
|
88
|
+
expect(normalizeCronExpression('0 9 * * 1-5 0')).toBe('0 9 * * 1-5 0')
|
|
90
89
|
})
|
|
91
90
|
|
|
92
91
|
it('2 字段返回 undefined', () => {
|
|
@@ -84,16 +84,11 @@ describe('formatDuration', () => {
|
|
|
84
84
|
|
|
85
85
|
describe('normalizeCronExpression', () => {
|
|
86
86
|
it('prepends seconds field for 5-field cron', () => {
|
|
87
|
-
|
|
88
|
-
expect(result).toEqual({
|
|
89
|
-
expression: '0 */10 * * * *',
|
|
90
|
-
note: 'Auto-prepended seconds field (0)',
|
|
91
|
-
})
|
|
87
|
+
expect(normalizeCronExpression('*/10 * * * *')).toBe('0 */10 * * * *')
|
|
92
88
|
})
|
|
93
89
|
|
|
94
90
|
it('keeps 6-field cron as-is', () => {
|
|
95
|
-
|
|
96
|
-
expect(result).toEqual({ expression: '0 */10 * * * *' })
|
|
91
|
+
expect(normalizeCronExpression('0 */10 * * * *')).toBe('0 */10 * * * *')
|
|
97
92
|
})
|
|
98
93
|
|
|
99
94
|
it('returns undefined for invalid field count', () => {
|
|
@@ -191,7 +191,7 @@ describe('SchedulerService', () => {
|
|
|
191
191
|
expect(result).toEqual({
|
|
192
192
|
success: false,
|
|
193
193
|
errorCode: 'DISPATCH_SKIPPED',
|
|
194
|
-
message: `Task ${id} not dispatched (
|
|
194
|
+
message: `Task ${id} not dispatched (disabled, rate-limited, or already queued for delivery).`,
|
|
195
195
|
})
|
|
196
196
|
})
|
|
197
197
|
|
package/src/commands.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function registerScheduleCommand(
|
|
|
48
48
|
getService: () => SchedulerService | null,
|
|
49
49
|
) {
|
|
50
50
|
pi.registerCommand('schedule', {
|
|
51
|
-
description: 'Manage scheduled tasks.
|
|
51
|
+
description: 'Manage scheduled tasks. /schedule <schedule> <prompt> to create; /schedule list to see tasks.',
|
|
52
52
|
getArgumentCompletions(prefix: string) {
|
|
53
53
|
const service = getService()
|
|
54
54
|
const trimmed = prefix.trimStart()
|
package/src/format.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { formatDuration } from './parsing.js'
|
|
1
|
+
import { formatDuration, MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND } from './parsing.js'
|
|
2
2
|
import type { ScheduleSpec, TaskKind } from './types.js'
|
|
3
3
|
|
|
4
|
+
/** 相对时间显示的"现在"判定窗口(±5s 内视为 now)。 */
|
|
5
|
+
const NOW_THRESHOLD_MS = 5000
|
|
6
|
+
/** 省略号 "..." 的字符数(truncate 截断预留宽度)。 */
|
|
7
|
+
const ELLIPSIS_LENGTH = 3
|
|
8
|
+
|
|
4
9
|
/** Format ScheduleSpec to readable string. kind 区分 once/recurring(once 显示 'once in X' 而非误导性的 'every X')。 */
|
|
5
10
|
export function formatSchedule(spec: ScheduleSpec, kind?: TaskKind): string {
|
|
6
11
|
if (spec.mode === 'interval') {
|
|
@@ -25,14 +30,14 @@ export function formatRelativeTime(timestamp: number, now?: number): string {
|
|
|
25
30
|
const diff = timestamp - currentTime
|
|
26
31
|
|
|
27
32
|
// 5秒内视为"现在"
|
|
28
|
-
if (Math.abs(diff) <
|
|
33
|
+
if (Math.abs(diff) < NOW_THRESHOLD_MS) return 'now'
|
|
29
34
|
|
|
30
35
|
const absDiff = Math.abs(diff)
|
|
31
36
|
const units: [string, number][] = [
|
|
32
|
-
['d',
|
|
33
|
-
['h',
|
|
34
|
-
['m',
|
|
35
|
-
['s',
|
|
37
|
+
['d', MS_PER_DAY],
|
|
38
|
+
['h', MS_PER_HOUR],
|
|
39
|
+
['m', MS_PER_MINUTE],
|
|
40
|
+
['s', MS_PER_SECOND],
|
|
36
41
|
]
|
|
37
42
|
|
|
38
43
|
let formatted = ''
|
|
@@ -45,7 +50,7 @@ export function formatRelativeTime(timestamp: number, now?: number): string {
|
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
if (!formatted) {
|
|
48
|
-
formatted = `${Math.round(absDiff /
|
|
53
|
+
formatted = `${Math.round(absDiff / MS_PER_SECOND)}s`
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
return diff > 0 ? `in ${formatted}` : `${formatted} ago`
|
|
@@ -56,24 +61,31 @@ export function formatRelativeTime(timestamp: number, now?: number): string {
|
|
|
56
61
|
*/
|
|
57
62
|
export function truncate(text: string, maxLen: number): string {
|
|
58
63
|
if (text.length <= maxLen) return text
|
|
59
|
-
if (maxLen <=
|
|
60
|
-
return text.slice(0, maxLen -
|
|
64
|
+
if (maxLen <= ELLIPSIS_LENGTH) return text.slice(0, maxLen)
|
|
65
|
+
return text.slice(0, maxLen - ELLIPSIS_LENGTH) + '...'
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
/** 生成任务 ID 的随机字节数(8 位 hex = 4 字节)。 */
|
|
69
|
+
const TASK_ID_RANDOM_BYTES = 4
|
|
70
|
+
const HEX_RADIX = 16
|
|
71
|
+
/** 每字节展开的 hex 字符数(padStart 宽度)。 */
|
|
72
|
+
const HEX_CHARS_PER_BYTE = 2
|
|
73
|
+
|
|
63
74
|
/**
|
|
64
75
|
* 生成任务 ID:8 位 hex。
|
|
65
76
|
*/
|
|
66
77
|
export function generateTaskId(): string {
|
|
67
|
-
const bytes = new Uint8Array(
|
|
78
|
+
const bytes = new Uint8Array(TASK_ID_RANDOM_BYTES)
|
|
68
79
|
crypto.getRandomValues(bytes)
|
|
69
|
-
return Array.from(bytes, b => b.toString(
|
|
80
|
+
return Array.from(bytes, b => b.toString(HEX_RADIX).padStart(HEX_CHARS_PER_BYTE, '0')).join('')
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
/** autoName 任务名最大长度。 */
|
|
84
|
+
const AUTO_NAME_MAX_LENGTH = 30
|
|
85
|
+
|
|
72
86
|
/**
|
|
73
87
|
* 从 prompt 自动生成任务名称:取前 30 字。
|
|
74
88
|
*/
|
|
75
89
|
export function autoName(prompt: string): string {
|
|
76
|
-
|
|
77
|
-
if (trimmed.length <= 30) return trimmed
|
|
78
|
-
return trimmed.slice(0, 27) + '...'
|
|
90
|
+
return truncate(prompt.trim(), AUTO_NAME_MAX_LENGTH)
|
|
79
91
|
}
|
package/src/parsing.ts
CHANGED
|
@@ -2,13 +2,19 @@ import type { ParseScheduleResult, ScheduleSpec } from './types.js'
|
|
|
2
2
|
|
|
3
3
|
// ── Duration 解析 ──
|
|
4
4
|
|
|
5
|
+
// 时间单位毫秒数(包内共享:format.ts / runtime.ts 复用)
|
|
6
|
+
export const MS_PER_DAY = 86_400_000
|
|
7
|
+
export const MS_PER_HOUR = 3_600_000
|
|
8
|
+
export const MS_PER_MINUTE = 60_000
|
|
9
|
+
export const MS_PER_SECOND = 1000
|
|
10
|
+
|
|
5
11
|
const DURATION_RE = /^(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i
|
|
6
12
|
|
|
7
13
|
const DURATION_MULTIPLIERS: Record<string, number> = {
|
|
8
|
-
s:
|
|
9
|
-
m:
|
|
10
|
-
h:
|
|
11
|
-
d:
|
|
14
|
+
s: MS_PER_SECOND, sec: MS_PER_SECOND, second: MS_PER_SECOND, seconds: MS_PER_SECOND,
|
|
15
|
+
m: MS_PER_MINUTE, min: MS_PER_MINUTE, minute: MS_PER_MINUTE, minutes: MS_PER_MINUTE,
|
|
16
|
+
h: MS_PER_HOUR, hr: MS_PER_HOUR, hour: MS_PER_HOUR, hours: MS_PER_HOUR,
|
|
17
|
+
d: MS_PER_DAY, day: MS_PER_DAY, days: MS_PER_DAY,
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
/**
|
|
@@ -34,10 +40,10 @@ export function formatDuration(ms: number): string {
|
|
|
34
40
|
if (ms <= 0) return '0s'
|
|
35
41
|
|
|
36
42
|
const units: [string, number][] = [
|
|
37
|
-
['d',
|
|
38
|
-
['h',
|
|
39
|
-
['m',
|
|
40
|
-
['s',
|
|
43
|
+
['d', MS_PER_DAY],
|
|
44
|
+
['h', MS_PER_HOUR],
|
|
45
|
+
['m', MS_PER_MINUTE],
|
|
46
|
+
['s', MS_PER_SECOND],
|
|
41
47
|
]
|
|
42
48
|
|
|
43
49
|
for (const [suffix, divisor] of units) {
|
|
@@ -47,11 +53,18 @@ export function formatDuration(ms: number): string {
|
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
// 兜底:用秒表示
|
|
50
|
-
return `${Math.round(ms /
|
|
56
|
+
return `${Math.round(ms / MS_PER_SECOND)}s`
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
// ── Cron 解析 ──
|
|
54
60
|
|
|
61
|
+
/** cron 标准字段数(分 时 日 月 周)。 */
|
|
62
|
+
const CRON_FIELD_COUNT = 5
|
|
63
|
+
/** 带秒字段的标准 cron 字段数(秒 分 时 日 月 周)。 */
|
|
64
|
+
const CRON_FIELD_COUNT_WITH_SECONDS = 6
|
|
65
|
+
/** computeNextCronRuns / computeNextRuns 默认返回的未来执行时间数。 */
|
|
66
|
+
const DEFAULT_NEXT_RUNS_COUNT = 5
|
|
67
|
+
|
|
55
68
|
let cronerModule: typeof import('croner') | null | undefined
|
|
56
69
|
|
|
57
70
|
async function getCroner(): Promise<typeof import('croner') | null> {
|
|
@@ -69,23 +82,20 @@ async function getCroner(): Promise<typeof import('croner') | null> {
|
|
|
69
82
|
* 规范化 cron 表达式:5 字段自动补秒字段。
|
|
70
83
|
* 返回 undefined 表示无效。
|
|
71
84
|
*/
|
|
72
|
-
export function normalizeCronExpression(input: string):
|
|
85
|
+
export function normalizeCronExpression(input: string): string | undefined {
|
|
73
86
|
const trimmed = input.trim()
|
|
74
87
|
if (!trimmed) return undefined
|
|
75
88
|
|
|
76
89
|
const parts = trimmed.split(/\s+/)
|
|
77
90
|
|
|
78
91
|
// 6 字段原样返回
|
|
79
|
-
if (parts.length ===
|
|
80
|
-
return
|
|
92
|
+
if (parts.length === CRON_FIELD_COUNT_WITH_SECONDS) {
|
|
93
|
+
return trimmed
|
|
81
94
|
}
|
|
82
95
|
|
|
83
96
|
// 5 字段补秒字段
|
|
84
|
-
if (parts.length ===
|
|
85
|
-
return {
|
|
86
|
-
expression: `0 ${trimmed}`,
|
|
87
|
-
note: 'Auto-prepended seconds field (0)',
|
|
88
|
-
}
|
|
97
|
+
if (parts.length === CRON_FIELD_COUNT) {
|
|
98
|
+
return `0 ${trimmed}`
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
return undefined
|
|
@@ -106,7 +116,7 @@ export async function computeNextCronRunAt(
|
|
|
106
116
|
const normalized = normalizeCronExpression(expression)
|
|
107
117
|
if (!normalized) return undefined
|
|
108
118
|
|
|
109
|
-
const job = new croner.Cron(normalized
|
|
119
|
+
const job = new croner.Cron(normalized, { startAt: from ? new Date(from) : undefined })
|
|
110
120
|
const next = job.nextRun()
|
|
111
121
|
return next ? next.getTime() : undefined
|
|
112
122
|
} catch {
|
|
@@ -121,7 +131,7 @@ export async function computeNextCronRunAt(
|
|
|
121
131
|
export async function computeNextCronRuns(
|
|
122
132
|
expression: string,
|
|
123
133
|
from?: number,
|
|
124
|
-
count =
|
|
134
|
+
count = DEFAULT_NEXT_RUNS_COUNT,
|
|
125
135
|
): Promise<number[]> {
|
|
126
136
|
const croner = await getCroner()
|
|
127
137
|
if (!croner) return []
|
|
@@ -130,7 +140,7 @@ export async function computeNextCronRuns(
|
|
|
130
140
|
const normalized = normalizeCronExpression(expression)
|
|
131
141
|
if (!normalized) return []
|
|
132
142
|
|
|
133
|
-
const job = new croner.Cron(normalized
|
|
143
|
+
const job = new croner.Cron(normalized, { startAt: from ? new Date(from) : undefined })
|
|
134
144
|
const runs: number[] = []
|
|
135
145
|
let current = from ? new Date(from) : new Date()
|
|
136
146
|
|
|
@@ -179,7 +189,7 @@ export async function parseSchedule(
|
|
|
179
189
|
const nextRun = await computeNextCronRunAt(trimmed)
|
|
180
190
|
if (nextRun !== undefined) {
|
|
181
191
|
return {
|
|
182
|
-
spec: { mode: 'cron', cronExpression: normalized
|
|
192
|
+
spec: { mode: 'cron', cronExpression: normalized },
|
|
183
193
|
}
|
|
184
194
|
}
|
|
185
195
|
}
|
|
@@ -215,7 +225,7 @@ export async function computeNextRunAt(
|
|
|
215
225
|
export async function computeNextRuns(
|
|
216
226
|
spec: ScheduleSpec,
|
|
217
227
|
from?: number,
|
|
218
|
-
count =
|
|
228
|
+
count = DEFAULT_NEXT_RUNS_COUNT,
|
|
219
229
|
): Promise<number[]> {
|
|
220
230
|
if (spec.mode === 'interval') {
|
|
221
231
|
const start = from ?? Date.now()
|
package/src/runtime.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { DeliveryHandle, DeliveryMessage } from '@xyz-agent/session-deliver
|
|
|
5
5
|
|
|
6
6
|
import type { SchedulerBackend } from './backend.js'
|
|
7
7
|
import { autoName, generateTaskId } from './format.js'
|
|
8
|
-
import { computeNextRunAt, parseDuration } from './parsing.js'
|
|
8
|
+
import { computeNextRunAt, MS_PER_DAY, MS_PER_MINUTE, parseDuration } from './parsing.js'
|
|
9
9
|
import type {
|
|
10
10
|
AddOptions,
|
|
11
11
|
ScheduledTask,
|
|
@@ -17,11 +17,13 @@ import type {
|
|
|
17
17
|
const logger = getLogger('scheduler')
|
|
18
18
|
|
|
19
19
|
const MAX_TASKS = 50
|
|
20
|
-
// 入队防重标记 TTL
|
|
21
|
-
const
|
|
20
|
+
// 入队防重标记 TTL 分钟数(合批非首条任务无终态回调,过期后放行重投;10 min >> 合批窗口)
|
|
21
|
+
const QUEUE_DEDUPE_TTL_MINUTES = 10
|
|
22
|
+
const QUEUE_DEDUPE_TTL_MS = QUEUE_DEDUPE_TTL_MINUTES * MS_PER_MINUTE
|
|
22
23
|
const RATE_LIMIT_PER_MINUTE = 6
|
|
23
24
|
const TICK_INTERVAL_MS = 30_000
|
|
24
|
-
const
|
|
25
|
+
const DEFAULT_EXPIRY_DAYS = 7
|
|
26
|
+
const DEFAULT_EXPIRY_MS = DEFAULT_EXPIRY_DAYS * MS_PER_DAY // 7 days
|
|
25
27
|
const HISTORY_LIMIT = 20 // 与 replayFoldEntries 的裁剪上限一致(advance 折叠 / dispatch 累积共用)
|
|
26
28
|
// pi ExtensionRunner 在 session 替换后访问 stale ctx 时抛出的错误文案片段。
|
|
27
29
|
// 兜底通道(防御纵深):G1 模块级代际检测(isCtxStale)为主判,覆盖同模块环境内的 session
|
|
@@ -466,7 +468,7 @@ export class SchedulerRuntime {
|
|
|
466
468
|
}
|
|
467
469
|
|
|
468
470
|
private hasDispatchCapacity(now: number): boolean {
|
|
469
|
-
const oneMinuteAgo = now -
|
|
471
|
+
const oneMinuteAgo = now - MS_PER_MINUTE
|
|
470
472
|
this.dispatchTimestamps = this.dispatchTimestamps.filter(t => t > oneMinuteAgo)
|
|
471
473
|
return this.dispatchTimestamps.length < RATE_LIMIT_PER_MINUTE
|
|
472
474
|
}
|
|
@@ -503,10 +505,4 @@ export class SchedulerRuntime {
|
|
|
503
505
|
const { ownerSessionFile: _o, pending: _p, history, ...rest } = task
|
|
504
506
|
return { ...rest, history: history.slice() }
|
|
505
507
|
}
|
|
506
|
-
|
|
507
|
-
// ── 工具方法 ──
|
|
508
|
-
|
|
509
|
-
getTaskCount(): number {
|
|
510
|
-
return this.tasks.size
|
|
511
|
-
}
|
|
512
508
|
}
|
package/src/service.ts
CHANGED
|
@@ -137,7 +137,8 @@ export class SchedulerService {
|
|
|
137
137
|
/**
|
|
138
138
|
* 立即执行任务。语义细分:
|
|
139
139
|
* 任务不存在 → TASK_NOT_FOUND;任务存在但 dispatch no-op
|
|
140
|
-
* (disabled /
|
|
140
|
+
* (disabled / rate-limited / 同任务入队已在 TTL 窗口内)→ DISPATCH_SKIPPED。
|
|
141
|
+
* busy 不再是 no-op:非 force 任务入队 delivery 内核即成功(park 等后续投递)。
|
|
141
142
|
* 修复了旧实现把 no-op 误报为 not found 的混同。
|
|
142
143
|
*/
|
|
143
144
|
async run(id: string | undefined): Promise<ServiceResult> {
|
|
@@ -152,7 +153,7 @@ export class SchedulerService {
|
|
|
152
153
|
return {
|
|
153
154
|
success: false,
|
|
154
155
|
errorCode: 'DISPATCH_SKIPPED',
|
|
155
|
-
message: `Task ${id} not dispatched (
|
|
156
|
+
message: `Task ${id} not dispatched (disabled, rate-limited, or already queued for delivery).`,
|
|
156
157
|
}
|
|
157
158
|
}
|
|
158
159
|
return { success: true, message: `Task ${id} executed.` }
|
package/src/tool.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const ScheduleParams = Type.Object({
|
|
|
11
11
|
schedule: Type.String({ description: 'Schedule spec: duration (5m/2h/1d) for interval, or cron expression (*/10 * * * *).' }),
|
|
12
12
|
kind: Type.Optional(Type.Union([Type.Literal('once'), Type.Literal('recurring')], { description: 'Task kind. Default: recurring.' })),
|
|
13
13
|
name: Type.Optional(Type.String({ description: 'Human-readable task name. Auto-generated from prompt if omitted.' })),
|
|
14
|
-
expires: Type.Optional(Type.String({ description: 'Expiry duration (30m/2h/7d). Default: 7d. Pass "never" to disable.' })),
|
|
14
|
+
expires: Type.Optional(Type.String({ description: 'Expiry duration (30m/2h/7d). Default: 7d. Pass "never" to disable. Only applies to recurring tasks (once tasks fire and are removed, expires is ignored).' })),
|
|
15
15
|
force: Type.Optional(Type.Boolean({ description: 'Dispatch even when agent is busy. Default: false.' })),
|
|
16
16
|
})
|
|
17
17
|
|
|
@@ -53,7 +53,7 @@ export const controlGuidelines = [
|
|
|
53
53
|
'Use action="list" to see all scheduled tasks.',
|
|
54
54
|
'After listing, use the returned id for toggle/delete/run.',
|
|
55
55
|
'Prefer toggle(enabled=false) over delete for temporary pauses.',
|
|
56
|
-
'action="run"
|
|
56
|
+
'action="run" dispatches the task now: force tasks are sent directly; non-force tasks are enqueued via the delivery kernel and delivered once the agent is idle (busy messages wait in the queue and are flushed later).',
|
|
57
57
|
]
|
|
58
58
|
|
|
59
59
|
export function createScheduleControlHandler(service: SchedulerService) {
|
package/src/types.ts
CHANGED
package/src/widget.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { formatRelativeTime, truncate } from './format.js'
|
|
2
2
|
import type { ScheduledTask } from './types.js'
|
|
3
3
|
|
|
4
|
+
/** widget 中任务名的最大显示宽度(列)。 */
|
|
5
|
+
const WIDGET_NAME_MAX_WIDTH = 20
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* 渲染 TUI status bar widget(string[],配合 SDK setWidget 第一重载)。
|
|
6
9
|
* 格式:[scheduler] 3 scheduled · check-build in 4m · 1 overdue
|
|
@@ -23,7 +26,7 @@ export function renderSchedulerWidget(tasks: ScheduledTask[]): string[] {
|
|
|
23
26
|
|
|
24
27
|
if (upcoming.length > 0) {
|
|
25
28
|
const next = upcoming[0]!
|
|
26
|
-
parts.push(`${truncate(next.name,
|
|
29
|
+
parts.push(`${truncate(next.name, WIDGET_NAME_MAX_WIDTH)} ${formatRelativeTime(next.nextRunAt)}`)
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
if (overdue.length > 0) {
|