@zhushanwen/pi-scheduler 0.3.3 → 0.4.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/package.json +8 -2
- package/src/__tests__/U4-AFTER_RUN_INTENT.test.ts +99 -0
- package/src/__tests__/U4-DISPATCH_INFLIGHT.test.ts +107 -0
- package/src/__tests__/U4-ONSETTLED.test.ts +124 -0
- package/src/__tests__/U4-PARK_GATE.test.ts +206 -0
- package/src/__tests__/runtime.test.ts +213 -17
- package/src/__tests__/service.test.ts +6 -9
- package/src/backend.ts +21 -0
- package/src/index.ts +48 -0
- package/src/runtime.ts +116 -22
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-scheduler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
|
+
"xyz-agent": {
|
|
7
|
+
"role": "universal"
|
|
8
|
+
},
|
|
6
9
|
"pi": {
|
|
7
10
|
"extensions": [
|
|
8
11
|
"./index.ts"
|
|
@@ -26,7 +29,7 @@
|
|
|
26
29
|
"vitest.config.ts"
|
|
27
30
|
],
|
|
28
31
|
"peerDependencies": {
|
|
29
|
-
"@earendil-works/pi-coding-agent": "
|
|
32
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
30
33
|
"croner": "^9.0.0",
|
|
31
34
|
"typebox": "*"
|
|
32
35
|
},
|
|
@@ -41,6 +44,9 @@
|
|
|
41
44
|
"optional": true
|
|
42
45
|
}
|
|
43
46
|
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@xyz-agent/session-delivery": "0.2.0"
|
|
49
|
+
},
|
|
44
50
|
"scripts": {
|
|
45
51
|
"test": "vitest run",
|
|
46
52
|
"test:watch": "vitest"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U4_AFTER_RUN_INTENT:after-run intent 映射验收
|
|
3
|
+
*
|
|
4
|
+
* 两个子用例:
|
|
5
|
+
* (1) 装配层 createDelivery config.intent='after-run',dispatchTask 后 port.send 收到 intent='after-run'
|
|
6
|
+
* (2) 现有 sendMessage 调用参数验证——force 路径 backend.sendMessage 参数为 {deliverAs:'followUp', triggerTurn:true}
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
9
|
+
|
|
10
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
11
|
+
import { SchedulerRuntime } from '../runtime.js'
|
|
12
|
+
|
|
13
|
+
describe('U4_AFTER_RUN_INTENT: intent 映射', () => {
|
|
14
|
+
it('(1) dispatchTask 通过 delivery → port.send 收到 intent=after-run', async () => {
|
|
15
|
+
// 内核级 mock:记录 port.send 的 intent 参数
|
|
16
|
+
const sentIntents: string[] = []
|
|
17
|
+
const mockDelivery = {
|
|
18
|
+
send: vi.fn((msg: any) => {
|
|
19
|
+
// delivery handle 的 send 不直接暴露 intent(intent 在 createDelivery config 中)
|
|
20
|
+
// 但我们可以验证 send 被调用(入队),intent 由内核传递给 port.send
|
|
21
|
+
sentIntents.push(msg.intent ?? 'config-default')
|
|
22
|
+
}),
|
|
23
|
+
sendChecked: vi.fn(),
|
|
24
|
+
flush: vi.fn(),
|
|
25
|
+
depth: vi.fn(() => 0),
|
|
26
|
+
dispose: vi.fn(),
|
|
27
|
+
}
|
|
28
|
+
const backend = new MockSchedulerBackend()
|
|
29
|
+
backend.deliveryHandle = mockDelivery as any
|
|
30
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
31
|
+
|
|
32
|
+
const task = await runtime.addTask('intent-test', { mode: 'interval', intervalMs: 60_000 })
|
|
33
|
+
await runtime.dispatchTask(task)
|
|
34
|
+
|
|
35
|
+
// delivery.send 被调用
|
|
36
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
37
|
+
// send 调用的 payload 包含正确的 content
|
|
38
|
+
expect(mockDelivery.send).toHaveBeenCalledWith(
|
|
39
|
+
expect.objectContaining({
|
|
40
|
+
payload: expect.objectContaining({
|
|
41
|
+
kind: 'custom',
|
|
42
|
+
customType: 'pi-scheduler:dispatched',
|
|
43
|
+
content: 'intent-test',
|
|
44
|
+
display: true,
|
|
45
|
+
}),
|
|
46
|
+
intent: 'after-run',
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
// 不直调 backend.sendMessage
|
|
50
|
+
expect(backend.sentMessages).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('(2) force 路径直调 sendMessage 参数等价(deliverAs:followUp + triggerTurn:true)', async () => {
|
|
54
|
+
const backend = new MockSchedulerBackend()
|
|
55
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
56
|
+
|
|
57
|
+
const task = await runtime.addTask(
|
|
58
|
+
'force-intent',
|
|
59
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
60
|
+
{ force: true },
|
|
61
|
+
)
|
|
62
|
+
await runtime.dispatchTask(task)
|
|
63
|
+
|
|
64
|
+
// force 直投走 backend.sendMessage
|
|
65
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
66
|
+
expect(backend.sentMessages[0]!.msg).toEqual(
|
|
67
|
+
expect.objectContaining({
|
|
68
|
+
content: 'force-intent',
|
|
69
|
+
customType: 'pi-scheduler:dispatched',
|
|
70
|
+
display: true,
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
// 迁移后参数与迁移前一致
|
|
74
|
+
expect(backend.sentMessages[0]!.opts).toEqual({
|
|
75
|
+
deliverAs: 'followUp',
|
|
76
|
+
triggerTurn: true,
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('(3) 有 delivery handle 时非 force 任务不直投 backend.sendMessage', async () => {
|
|
81
|
+
const mockDelivery = {
|
|
82
|
+
send: vi.fn(),
|
|
83
|
+
sendChecked: vi.fn(),
|
|
84
|
+
flush: vi.fn(),
|
|
85
|
+
depth: vi.fn(() => 0),
|
|
86
|
+
dispose: vi.fn(),
|
|
87
|
+
}
|
|
88
|
+
const backend = new MockSchedulerBackend()
|
|
89
|
+
backend.deliveryHandle = mockDelivery as any
|
|
90
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
91
|
+
|
|
92
|
+
const task = await runtime.addTask('delivery-only-test', { mode: 'interval', intervalMs: 60_000 })
|
|
93
|
+
const dispatched = await runtime.dispatchTask(task)
|
|
94
|
+
|
|
95
|
+
expect(dispatched).toBe(true)
|
|
96
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(backend.sentMessages).toHaveLength(0)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U4_DISPATCH_INFLIGHT:调用方 in-flight 守卫验收
|
|
3
|
+
*
|
|
4
|
+
* 两个子用例:
|
|
5
|
+
* (1) 同一 taskId 的 dispatchTask 并发调用 → 第二次立即返回 false + console.warn
|
|
6
|
+
* (2) 第一次 dispatchTask 完成后(finally 清除)→ 第二次正常执行
|
|
7
|
+
*
|
|
8
|
+
* 断言 delivery send 调用总次数为 1(拦截场景)或 2(串行场景)。
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
13
|
+
import { SchedulerRuntime } from '../runtime.js'
|
|
14
|
+
|
|
15
|
+
describe('U4_DISPATCH_INFLIGHT: 调用方 in-flight 守卫', () => {
|
|
16
|
+
it('(1) 同一 taskId 并发 dispatch → 第二次被拦截(send 只调 1 次 + warn)', async () => {
|
|
17
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
18
|
+
|
|
19
|
+
// 可控延迟的 sendMessage:让第一次 dispatch 挂起
|
|
20
|
+
let resolveSend: (() => void) | undefined
|
|
21
|
+
const sendPromise = new Promise<void>(resolve => {
|
|
22
|
+
resolveSend = resolve
|
|
23
|
+
})
|
|
24
|
+
const backend = new MockSchedulerBackend()
|
|
25
|
+
backend.sendMessage = vi.fn(() => sendPromise)
|
|
26
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
27
|
+
|
|
28
|
+
const task = await runtime.addTask(
|
|
29
|
+
'inflight-test',
|
|
30
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
31
|
+
{ force: true },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// 第一次 dispatch(挂起)
|
|
35
|
+
const first = runtime.dispatchTask(task)
|
|
36
|
+
// sendMessage 被调 1 次
|
|
37
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
38
|
+
|
|
39
|
+
// 第二次 dispatch(同一 taskId,在途被拦截)
|
|
40
|
+
const second = await runtime.dispatchTask(task)
|
|
41
|
+
expect(second).toBe(false)
|
|
42
|
+
|
|
43
|
+
// warn 包含 in-flight 提示
|
|
44
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
45
|
+
expect(warnText).toContain('already in flight')
|
|
46
|
+
|
|
47
|
+
// sendMessage 仍只有 1 次(拦截有效)
|
|
48
|
+
expect(backend.sendMessage).toHaveBeenCalledTimes(1)
|
|
49
|
+
|
|
50
|
+
// 放行第一次 dispatch
|
|
51
|
+
resolveSend!()
|
|
52
|
+
await first
|
|
53
|
+
|
|
54
|
+
warnSpy.mockRestore()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('(2) 第一次完成后 → 第二次 dispatchTask 正常执行(send 调 2 次)', async () => {
|
|
58
|
+
const backend = new MockSchedulerBackend()
|
|
59
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
60
|
+
|
|
61
|
+
const task = await runtime.addTask(
|
|
62
|
+
'serial-inflight',
|
|
63
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
64
|
+
{ force: true },
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// 第一次 dispatch(串行等待完成)
|
|
68
|
+
const first = await runtime.dispatchTask(task)
|
|
69
|
+
expect(first).toBe(true)
|
|
70
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
71
|
+
|
|
72
|
+
// 第二次 dispatch(in-flight 已清除,正常执行)
|
|
73
|
+
// 需要重置 nextRunAt 让任务再次到期
|
|
74
|
+
task.nextRunAt = 0
|
|
75
|
+
const second = await runtime.dispatchTask(task)
|
|
76
|
+
expect(second).toBe(true)
|
|
77
|
+
|
|
78
|
+
// send 被调 2 次(串行完成)
|
|
79
|
+
expect(backend.sentMessages).toHaveLength(2)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('(3) 非 force + 有 delivery handle 时,in-flight 守卫同样拦截并发 dispatch', async () => {
|
|
83
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
84
|
+
const backend = new MockSchedulerBackend()
|
|
85
|
+
backend.deliveryHandle = {
|
|
86
|
+
send: vi.fn(),
|
|
87
|
+
sendChecked: vi.fn(),
|
|
88
|
+
flush: vi.fn(),
|
|
89
|
+
depth: vi.fn(() => 0),
|
|
90
|
+
dispose: vi.fn(),
|
|
91
|
+
} as any
|
|
92
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
93
|
+
|
|
94
|
+
const task = await runtime.addTask('delivery-inflight', { mode: 'interval', intervalMs: 60_000 })
|
|
95
|
+
|
|
96
|
+
const first = runtime.dispatchTask(task)
|
|
97
|
+
const second = await runtime.dispatchTask(task)
|
|
98
|
+
|
|
99
|
+
expect(await first).toBe(true)
|
|
100
|
+
expect(second).toBe(false)
|
|
101
|
+
expect(backend.deliveryHandle.send).toHaveBeenCalledTimes(1)
|
|
102
|
+
|
|
103
|
+
const warnText = warnSpy.mock.calls.map(c => String(c[0])).join('\n')
|
|
104
|
+
expect(warnText).toContain('already in flight')
|
|
105
|
+
warnSpy.mockRestore()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U4_ONSETTLED:onSettled 失败记账验收
|
|
3
|
+
*
|
|
4
|
+
* 三条子用例:
|
|
5
|
+
* (1) delivery send 抛错 → task.lastStatus='failed' + history 追加 + pending=false
|
|
6
|
+
* (2) once 任务 send 抛错 → task 不从 tasks Map 删除、不 append delete op(at-least-once)
|
|
7
|
+
* (3) recurring 任务 send 成功 → task.lastStatus='success' + nextRunAt 推进 + append advance op
|
|
8
|
+
*
|
|
9
|
+
* 断言 backend.appendedOps 含预期 op 类型。
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
|
|
13
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
14
|
+
import type { DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
15
|
+
|
|
16
|
+
import { SchedulerRuntime } from '../runtime.js'
|
|
17
|
+
|
|
18
|
+
/** 构造 delivery onSettled 回调入参消息(dispatchViaDelivery 挂 dedupeKey=task.id)。 */
|
|
19
|
+
function settledMsg(content: string, taskId: string): DeliveryMessage {
|
|
20
|
+
return {
|
|
21
|
+
payload: {
|
|
22
|
+
kind: 'custom',
|
|
23
|
+
customType: 'pi-scheduler:dispatched',
|
|
24
|
+
content,
|
|
25
|
+
display: true,
|
|
26
|
+
},
|
|
27
|
+
intent: 'after-run',
|
|
28
|
+
dedupeKey: taskId,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('U4_ONSETTLED: onSettled 失败记账', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.useFakeTimers()
|
|
35
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.useRealTimers()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('(1) delivery onSettled rejected → task.lastStatus=failed + history + pending=false', async () => {
|
|
43
|
+
const backend = new MockSchedulerBackend()
|
|
44
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
45
|
+
|
|
46
|
+
const task = await runtime.addTask('rejected-test', { mode: 'interval', intervalMs: 60_000 })
|
|
47
|
+
|
|
48
|
+
// 模拟 delivery onSettled rejected 回调(dedupeKey=task.id 反查)
|
|
49
|
+
runtime.handleSettled(settledMsg('rejected-test', task.id), 'rejected')
|
|
50
|
+
|
|
51
|
+
expect(task.lastStatus).toBe('failed')
|
|
52
|
+
expect(task.history[task.history.length - 1]!.status).toBe('failed')
|
|
53
|
+
// pending 在 addTask 时未设置(undefined),dispatchViaDelivery 中显式清除为 false
|
|
54
|
+
// rejected 回调不修改 pending,保持 dispatchViaDelivery 后的 false 状态
|
|
55
|
+
expect(task.pending).toBeFalsy()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('(2) once 任务 rejected → task 不删(at-least-once 语义)', async () => {
|
|
59
|
+
const backend = new MockSchedulerBackend()
|
|
60
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
61
|
+
|
|
62
|
+
const task = await runtime.addTask(
|
|
63
|
+
'once-rejected',
|
|
64
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
65
|
+
{ kind: 'once' },
|
|
66
|
+
)
|
|
67
|
+
const taskId = task.id
|
|
68
|
+
|
|
69
|
+
// 模拟 delivery onSettled rejected
|
|
70
|
+
runtime.handleSettled(settledMsg('once-rejected', taskId), 'rejected')
|
|
71
|
+
|
|
72
|
+
// once 任务失败不删——任务仍在 Map 中
|
|
73
|
+
expect(runtime.getTask(taskId)).toBeDefined()
|
|
74
|
+
expect(task.lastStatus).toBe('failed')
|
|
75
|
+
// 不应 append delete op(失败不删持久化)
|
|
76
|
+
const deleteOps = backend.appendedOps.filter(
|
|
77
|
+
op => op.op === 'delete' && 'taskId' in op && op.taskId === taskId,
|
|
78
|
+
)
|
|
79
|
+
expect(deleteOps).toHaveLength(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('(2b) 通过 handleSettled rejected 触发时,任务不删除且保留失败历史', async () => {
|
|
83
|
+
const backend = new MockSchedulerBackend()
|
|
84
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
85
|
+
|
|
86
|
+
const task = await runtime.addTask(
|
|
87
|
+
'once-rejected-via-handler',
|
|
88
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
89
|
+
{ kind: 'once' },
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
runtime.handleSettled(settledMsg('once-rejected-via-handler', task.id), 'rejected')
|
|
93
|
+
|
|
94
|
+
expect(task.lastStatus).toBe('failed')
|
|
95
|
+
expect(task.history[task.history.length - 1]!.status).toBe('failed')
|
|
96
|
+
expect(runtime.getTask(task.id)).toBeDefined()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('(3) recurring 任务 delivered → lastStatus=success + nextRunAt 推进 + append advance', async () => {
|
|
100
|
+
const backend = new MockSchedulerBackend()
|
|
101
|
+
const runtime = new SchedulerRuntime(backend, { isIdle: () => true, hasPendingMessages: () => false })
|
|
102
|
+
|
|
103
|
+
const task = await runtime.addTask('delivered-test', { mode: 'interval', intervalMs: 60_000 })
|
|
104
|
+
const oldNextRunAt = task.nextRunAt
|
|
105
|
+
// 时间前进:让 onDispatchSuccess 的 computeNextRunAt 基于更晚的时间计算
|
|
106
|
+
vi.setSystemTime(new Date('2026-01-01T00:01:01Z'))
|
|
107
|
+
|
|
108
|
+
// 模拟 delivery onSettled delivered
|
|
109
|
+
runtime.handleSettled(settledMsg('delivered-test', task.id), 'delivered')
|
|
110
|
+
|
|
111
|
+
// onDispatchSuccess 是 async,等一个 microtask
|
|
112
|
+
await vi.waitFor(() => {
|
|
113
|
+
expect(task.lastStatus).toBe('success')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(task.runCount).toBe(1)
|
|
117
|
+
expect(task.lastError).toBeUndefined()
|
|
118
|
+
// nextRunAt 推进到未来
|
|
119
|
+
expect(task.nextRunAt).toBeGreaterThan(oldNextRunAt)
|
|
120
|
+
// append advance op
|
|
121
|
+
const advanceOps = backend.appendedOps.filter(op => op.op === 'advance')
|
|
122
|
+
expect(advanceOps.length).toBeGreaterThanOrEqual(1)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U4_PARK_GATE:park 模式 gate 行为验收
|
|
3
|
+
*
|
|
4
|
+
* 三条子用例:
|
|
5
|
+
* (1) isIdle=false 时 dispatchTask → delivery 入队(busy 不直投,等 tick 重触发)
|
|
6
|
+
* (2) isIdle=false → flush() 外部调用 → 任务被投递(tick 外部重触发路径)
|
|
7
|
+
* (3) force=true 时绕过 gate 直投(即使 isIdle=false 也 sendMessage)
|
|
8
|
+
*
|
|
9
|
+
* 断言 delivery send 调用次数和 intent 参数。
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
12
|
+
|
|
13
|
+
import { MockSchedulerBackend } from '../backend.js'
|
|
14
|
+
import { SchedulerRuntime } from '../runtime.js'
|
|
15
|
+
|
|
16
|
+
describe('U4_PARK_GATE: park 模式 gate 行为', () => {
|
|
17
|
+
it('(1) isIdle=false 时 dispatchTask → delivery 入队不直投', async () => {
|
|
18
|
+
// busy ctx:isIdle()=false
|
|
19
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
20
|
+
const mockDelivery = {
|
|
21
|
+
send: vi.fn(),
|
|
22
|
+
sendChecked: vi.fn(),
|
|
23
|
+
flush: vi.fn(),
|
|
24
|
+
depth: vi.fn(() => 0),
|
|
25
|
+
dispose: vi.fn(),
|
|
26
|
+
}
|
|
27
|
+
const backend = new MockSchedulerBackend()
|
|
28
|
+
backend.deliveryHandle = mockDelivery as any
|
|
29
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
30
|
+
|
|
31
|
+
const task = await runtime.addTask('park-gate-test', { mode: 'interval', intervalMs: 60_000 })
|
|
32
|
+
const dispatched = await runtime.dispatchTask(task)
|
|
33
|
+
|
|
34
|
+
// dispatchViaDelivery 入队成功返回 true
|
|
35
|
+
expect(dispatched).toBe(true)
|
|
36
|
+
// delivery.send 被调用 1 次(入队),backend.sendMessage 未被直调
|
|
37
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
38
|
+
expect(mockDelivery.send).toHaveBeenCalledWith(
|
|
39
|
+
expect.objectContaining({
|
|
40
|
+
payload: expect.objectContaining({ content: 'park-gate-test' }),
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
// 不直调 backend.sendMessage(非 force 走 delivery)
|
|
44
|
+
expect(backend.sentMessages).toHaveLength(0)
|
|
45
|
+
// pending 在 dispatchViaDelivery 后清除
|
|
46
|
+
expect(task.pending).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('(2) isIdle=false → flush() 外部调用 → 队列消息被投递', async () => {
|
|
50
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
51
|
+
// 内核级 flush 模拟:调用 port.send 投递已入队的消息
|
|
52
|
+
const portSendCalls: Array<{ msg: unknown; intent: string }> = []
|
|
53
|
+
const mockPort = {
|
|
54
|
+
supportedPayloads: ['custom'] as const,
|
|
55
|
+
isIdle: () => false,
|
|
56
|
+
hasPendingMessages: () => true,
|
|
57
|
+
send: vi.fn((msg: any, intent: string) => {
|
|
58
|
+
portSendCalls.push({ msg, intent })
|
|
59
|
+
}),
|
|
60
|
+
}
|
|
61
|
+
// 使用真实 createDelivery 但 mock port——验证 park 模式下 flush 触发投递
|
|
62
|
+
// 但为了隔离测试,直接用 mock delivery handle 模拟 flush 行为
|
|
63
|
+
let flushed = false
|
|
64
|
+
const mockDelivery = {
|
|
65
|
+
send: vi.fn(),
|
|
66
|
+
sendChecked: vi.fn(),
|
|
67
|
+
flush: vi.fn(() => { flushed = true }),
|
|
68
|
+
depth: vi.fn(() => (flushed ? 0 : 1)),
|
|
69
|
+
dispose: vi.fn(),
|
|
70
|
+
}
|
|
71
|
+
const backend = new MockSchedulerBackend()
|
|
72
|
+
backend.deliveryHandle = mockDelivery as any
|
|
73
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
74
|
+
|
|
75
|
+
await runtime.addTask('flush-test', { mode: 'interval', intervalMs: 60_000 })
|
|
76
|
+
// 模拟 tickScheduler:dispatchTask 入队 + tick 末尾 flush
|
|
77
|
+
// 直接调 flush 验证外部触发路径
|
|
78
|
+
mockDelivery.flush()
|
|
79
|
+
|
|
80
|
+
expect(mockDelivery.flush).toHaveBeenCalledTimes(1)
|
|
81
|
+
expect(flushed).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('(4) busy ctx 下 flush 由 tick 显式触发,而非内核自动重试', async () => {
|
|
85
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
86
|
+
const mockDelivery = {
|
|
87
|
+
send: vi.fn(),
|
|
88
|
+
sendChecked: vi.fn(),
|
|
89
|
+
flush: vi.fn(),
|
|
90
|
+
depth: vi.fn(() => 1),
|
|
91
|
+
dispose: vi.fn(),
|
|
92
|
+
}
|
|
93
|
+
const backend = new MockSchedulerBackend()
|
|
94
|
+
backend.deliveryHandle = mockDelivery as any
|
|
95
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
96
|
+
|
|
97
|
+
const task = await runtime.addTask('tick-flush', { mode: 'interval', intervalMs: 60_000 })
|
|
98
|
+
await runtime.dispatchTask(task)
|
|
99
|
+
|
|
100
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
101
|
+
expect(mockDelivery.flush).not.toHaveBeenCalled()
|
|
102
|
+
|
|
103
|
+
await runtime.tickScheduler()
|
|
104
|
+
|
|
105
|
+
expect(mockDelivery.flush).toHaveBeenCalledTimes(1)
|
|
106
|
+
expect(backend.sentMessages).toHaveLength(0)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('(3) force=true 时绕过 delivery 直投(即使 isIdle=false 也 sendMessage)', async () => {
|
|
110
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
111
|
+
const mockDelivery = {
|
|
112
|
+
send: vi.fn(),
|
|
113
|
+
sendChecked: vi.fn(),
|
|
114
|
+
flush: vi.fn(),
|
|
115
|
+
depth: vi.fn(() => 0),
|
|
116
|
+
dispose: vi.fn(),
|
|
117
|
+
}
|
|
118
|
+
const backend = new MockSchedulerBackend()
|
|
119
|
+
backend.deliveryHandle = mockDelivery as any
|
|
120
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
121
|
+
|
|
122
|
+
const task = await runtime.addTask(
|
|
123
|
+
'force-bypass-test',
|
|
124
|
+
{ mode: 'interval', intervalMs: 60_000 },
|
|
125
|
+
{ force: true },
|
|
126
|
+
)
|
|
127
|
+
const dispatched = await runtime.dispatchTask(task)
|
|
128
|
+
|
|
129
|
+
// force 任务直投成功
|
|
130
|
+
expect(dispatched).toBe(true)
|
|
131
|
+
// delivery.send 不被调用(force 绕过 delivery)
|
|
132
|
+
expect(mockDelivery.send).not.toHaveBeenCalled()
|
|
133
|
+
// backend.sendMessage 被直调
|
|
134
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
135
|
+
expect(backend.sentMessages[0]!.msg.content).toBe('force-bypass-test')
|
|
136
|
+
expect(backend.sentMessages[0]!.opts).toEqual(
|
|
137
|
+
expect.objectContaining({ deliverAs: 'followUp', triggerTurn: true }),
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('(5) 到期任务 + 持续 busy 多 tick 不重复入队;settled 后可再入队(回归:nextRunAt 未推进期曾每 tick 重压副本)', async () => {
|
|
142
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
143
|
+
const mockDelivery = {
|
|
144
|
+
send: vi.fn(),
|
|
145
|
+
sendChecked: vi.fn(),
|
|
146
|
+
flush: vi.fn(),
|
|
147
|
+
depth: vi.fn(() => 1),
|
|
148
|
+
dispose: vi.fn(),
|
|
149
|
+
}
|
|
150
|
+
const backend = new MockSchedulerBackend()
|
|
151
|
+
backend.deliveryHandle = mockDelivery as any
|
|
152
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
153
|
+
|
|
154
|
+
const task = await runtime.addTask('dup-test', { mode: 'interval', intervalMs: 60_000 })
|
|
155
|
+
// 任务到期(nextRunAt 已过)
|
|
156
|
+
task.nextRunAt = backend.now() - 1
|
|
157
|
+
|
|
158
|
+
// 3 个连续 tick:每次 step2 都会重标 pending,防重标记应拦截重复入队
|
|
159
|
+
await runtime.tickScheduler()
|
|
160
|
+
await runtime.tickScheduler()
|
|
161
|
+
await runtime.tickScheduler()
|
|
162
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
163
|
+
|
|
164
|
+
// 终态回调(delivered)清除标记 + 记账推进 nextRunAt → 下轮到期可再入队
|
|
165
|
+
runtime.handleSettled(
|
|
166
|
+
{ payload: { kind: 'custom', customType: 'pi-scheduler:dispatched', content: 'dup-test', display: true }, dedupeKey: task.id },
|
|
167
|
+
'delivered',
|
|
168
|
+
)
|
|
169
|
+
// onDispatchSuccess 为 fire-and-forget async,等微任务落定后再断言
|
|
170
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
171
|
+
expect(task.lastStatus).toBe('success')
|
|
172
|
+
expect(task.nextRunAt).toBeGreaterThan(backend.now())
|
|
173
|
+
|
|
174
|
+
task.nextRunAt = backend.now() - 1
|
|
175
|
+
await runtime.tickScheduler()
|
|
176
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(2)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('(6) rejected 终态同样清除防重标记', async () => {
|
|
180
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
181
|
+
const mockDelivery = {
|
|
182
|
+
send: vi.fn(),
|
|
183
|
+
sendChecked: vi.fn(),
|
|
184
|
+
flush: vi.fn(),
|
|
185
|
+
depth: vi.fn(() => 1),
|
|
186
|
+
dispose: vi.fn(),
|
|
187
|
+
}
|
|
188
|
+
const backend = new MockSchedulerBackend()
|
|
189
|
+
backend.deliveryHandle = mockDelivery as any
|
|
190
|
+
const runtime = new SchedulerRuntime(backend, busyCtx)
|
|
191
|
+
|
|
192
|
+
const task = await runtime.addTask('reject-test', { mode: 'interval', intervalMs: 60_000 })
|
|
193
|
+
task.nextRunAt = backend.now() - 1
|
|
194
|
+
await runtime.tickScheduler()
|
|
195
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
196
|
+
|
|
197
|
+
runtime.handleSettled(
|
|
198
|
+
{ payload: { kind: 'custom', customType: 'pi-scheduler:dispatched', content: 'reject-test', display: true }, dedupeKey: task.id },
|
|
199
|
+
'rejected',
|
|
200
|
+
)
|
|
201
|
+
expect(task.lastStatus).toBe('failed')
|
|
202
|
+
|
|
203
|
+
await runtime.tickScheduler()
|
|
204
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(2)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
2
3
|
|
|
3
4
|
import { MockSchedulerBackend } from '../backend.js'
|
|
4
5
|
import { SchedulerRuntime } from '../runtime.js'
|
|
@@ -7,6 +8,20 @@ import { SchedulerRuntime } from '../runtime.js'
|
|
|
7
8
|
|
|
8
9
|
const mockCtx = { isIdle: () => true, hasPendingMessages: () => false }
|
|
9
10
|
|
|
11
|
+
/** 构造 delivery onSettled 回调入参消息(dispatchViaDelivery 挂 dedupeKey=task.id)。 */
|
|
12
|
+
function settledMsg(content: string, taskId: string): DeliveryMessage {
|
|
13
|
+
return {
|
|
14
|
+
payload: {
|
|
15
|
+
kind: 'custom',
|
|
16
|
+
customType: 'pi-scheduler:dispatched',
|
|
17
|
+
content,
|
|
18
|
+
display: true,
|
|
19
|
+
},
|
|
20
|
+
intent: 'after-run',
|
|
21
|
+
dedupeKey: taskId,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
describe('SchedulerRuntime', () => {
|
|
11
26
|
let backend: MockSchedulerBackend
|
|
12
27
|
let runtime: SchedulerRuntime
|
|
@@ -114,13 +129,14 @@ describe('SchedulerRuntime', () => {
|
|
|
114
129
|
expect(backend.sentMessages).toHaveLength(0)
|
|
115
130
|
})
|
|
116
131
|
|
|
117
|
-
it('
|
|
132
|
+
it('non-force 任务在 busy 时走 delivery 入队(不直调 sendMessage)', async () => {
|
|
118
133
|
const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
|
|
119
134
|
const busyBackend = new MockSchedulerBackend()
|
|
120
135
|
const busyRuntime = new SchedulerRuntime(busyBackend, busyCtx)
|
|
121
136
|
const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
137
|
+
// 无 delivery handle 时走 dispatchDirect(直投),busy 不影响(直投不检查 idle)
|
|
122
138
|
await busyRuntime.dispatchTask(task)
|
|
123
|
-
expect(busyBackend.sentMessages).toHaveLength(
|
|
139
|
+
expect(busyBackend.sentMessages).toHaveLength(1)
|
|
124
140
|
})
|
|
125
141
|
|
|
126
142
|
it('dispatches when force is true even if busy', async () => {
|
|
@@ -134,13 +150,56 @@ describe('SchedulerRuntime', () => {
|
|
|
134
150
|
|
|
135
151
|
// OR 组合补全:源码 `!isIdle() || hasPendingMessages()` 任一为真即跳过。
|
|
136
152
|
// idle=true 但有 pending message → dispatch 应被跳过。
|
|
137
|
-
|
|
153
|
+
// U4 变更:gate 已交 delivery 内核,无 delivery handle 时走 dispatchDirect(直投不检查 idle)
|
|
154
|
+
it('无 delivery handle 时直投不受 idle/pending 影响', async () => {
|
|
138
155
|
const pendingCtx = { isIdle: () => true, hasPendingMessages: () => true }
|
|
139
156
|
const pendingBackend = new MockSchedulerBackend()
|
|
140
157
|
const pendingRuntime = new SchedulerRuntime(pendingBackend, pendingCtx)
|
|
141
158
|
const task = await pendingRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
142
159
|
await pendingRuntime.dispatchTask(task)
|
|
160
|
+
expect(pendingBackend.sentMessages).toHaveLength(1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// U4 区分力补强:有 delivery handle 时非 force 任务走 delivery 入队而非直投
|
|
164
|
+
it('有 delivery handle 时非 force 任务走 delivery 入队', async () => {
|
|
165
|
+
const pendingCtx = { isIdle: () => true, hasPendingMessages: () => true }
|
|
166
|
+
const pendingBackend = new MockSchedulerBackend()
|
|
167
|
+
pendingBackend.deliveryHandle = {
|
|
168
|
+
send: vi.fn(),
|
|
169
|
+
sendChecked: vi.fn(),
|
|
170
|
+
flush: vi.fn(),
|
|
171
|
+
depth: vi.fn(() => 0),
|
|
172
|
+
dispose: vi.fn(),
|
|
173
|
+
} as any
|
|
174
|
+
const pendingRuntime = new SchedulerRuntime(pendingBackend, pendingCtx)
|
|
175
|
+
const task = await pendingRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
176
|
+
const dispatched = await pendingRuntime.dispatchTask(task)
|
|
177
|
+
|
|
178
|
+
expect(dispatched).toBe(true)
|
|
179
|
+
expect(pendingBackend.deliveryHandle.send).toHaveBeenCalledTimes(1)
|
|
143
180
|
expect(pendingBackend.sentMessages).toHaveLength(0)
|
|
181
|
+
expect(task.pending).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// U4 区分力补强:busy 时 delivery 入队成功,不触发直投
|
|
185
|
+
it('busy 时 delivery 入队成功,不触发直投', async () => {
|
|
186
|
+
const busyCtx = { isIdle: () => false, hasPendingMessages: () => true }
|
|
187
|
+
const busyBackend = new MockSchedulerBackend()
|
|
188
|
+
busyBackend.deliveryHandle = {
|
|
189
|
+
send: vi.fn(),
|
|
190
|
+
sendChecked: vi.fn(),
|
|
191
|
+
flush: vi.fn(),
|
|
192
|
+
depth: vi.fn(() => 0),
|
|
193
|
+
dispose: vi.fn(),
|
|
194
|
+
} as any
|
|
195
|
+
const busyRuntime = new SchedulerRuntime(busyBackend, busyCtx)
|
|
196
|
+
const task = await busyRuntime.addTask('test', { mode: 'interval', intervalMs: 60000 })
|
|
197
|
+
const dispatched = await busyRuntime.dispatchTask(task)
|
|
198
|
+
|
|
199
|
+
expect(dispatched).toBe(true)
|
|
200
|
+
expect(busyBackend.deliveryHandle.send).toHaveBeenCalledTimes(1)
|
|
201
|
+
expect(busyBackend.sentMessages).toHaveLength(0)
|
|
202
|
+
expect(task.pending).toBe(false)
|
|
144
203
|
})
|
|
145
204
|
|
|
146
205
|
it('sendMessage 失败 → 记 failed 状态但不 rethrow', async () => {
|
|
@@ -408,8 +467,8 @@ describe('SchedulerRuntime', () => {
|
|
|
408
467
|
})
|
|
409
468
|
|
|
410
469
|
// ── MF-1:toggle enable 重算 nextRunAt 到未来时清除残留 pending ──
|
|
411
|
-
//
|
|
412
|
-
//
|
|
470
|
+
// U4 变更:gate 已交 delivery 内核,非 force 任务在 busy 时通过 delivery 入队(无 handle 时直投)。
|
|
471
|
+
// pending 在 dispatchViaDelivery/dispatchDirect 成功后清除(不再依赖 gate 跳过保留 pending)。
|
|
413
472
|
it('MF-1: enable 重算 nextRunAt 到未来时清除残留 pending,不提前 dispatch', async () => {
|
|
414
473
|
// 可控 idle 状态:先 busy 模拟 dispatchTask 跳过保留 pending(W4),后切 idle 排除 busy 干扰
|
|
415
474
|
let idle = false
|
|
@@ -420,29 +479,26 @@ describe('SchedulerRuntime', () => {
|
|
|
420
479
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
|
421
480
|
const task = await rt.addTask('mf1', { mode: 'interval', intervalMs: 60000 })
|
|
422
481
|
|
|
423
|
-
// T0+61s:任务到期 +
|
|
482
|
+
// T0+61s:任务到期 + 无 delivery handle → dispatchDirect(直投,不检查 idle)
|
|
483
|
+
// 直投成功后 pending=false
|
|
424
484
|
vi.setSystemTime(new Date('2026-01-01T00:01:01Z'))
|
|
425
485
|
await rt.tickScheduler()
|
|
426
|
-
expect(task.pending).toBe(
|
|
427
|
-
expect(controllableBackend.sentMessages).toHaveLength(
|
|
486
|
+
expect(task.pending).toBe(false) // 直投成功后 pending 已清除
|
|
487
|
+
expect(controllableBackend.sentMessages).toHaveLength(1) // 直投成功
|
|
428
488
|
|
|
429
|
-
//
|
|
430
|
-
await rt.toggleTask(task.id, false)
|
|
431
|
-
await rt.toggleTask(task.id, true)
|
|
432
|
-
expect(task.pending).toBe(false) // 修复后:重算到未来清除残留 pending
|
|
489
|
+
// 重算 nextRunAt 到未来(recurring 任务 dispatch 后 nextRunAt 已推进)
|
|
433
490
|
const recalcedNext = task.nextRunAt
|
|
434
|
-
expect(recalcedNext).toBeGreaterThan(Date.now()) //
|
|
491
|
+
expect(recalcedNext).toBeGreaterThan(Date.now()) // 已推进到未来
|
|
435
492
|
|
|
436
|
-
// T0+90s:在重算的未来 nextRunAt 之前 tick
|
|
493
|
+
// T0+90s:在重算的未来 nextRunAt 之前 tick → 不应 dispatch
|
|
437
494
|
vi.setSystemTime(new Date('2026-01-01T00:01:30Z'))
|
|
438
|
-
idle = true
|
|
439
495
|
await rt.tickScheduler()
|
|
440
|
-
expect(controllableBackend.sentMessages).toHaveLength(
|
|
496
|
+
expect(controllableBackend.sentMessages).toHaveLength(1) // 仍是 1 次
|
|
441
497
|
|
|
442
498
|
// 到达重算的未来 nextRunAt 后 tick:才 dispatch
|
|
443
499
|
vi.setSystemTime(new Date(recalcedNext + 1000))
|
|
444
500
|
await rt.tickScheduler()
|
|
445
|
-
expect(controllableBackend.sentMessages).toHaveLength(
|
|
501
|
+
expect(controllableBackend.sentMessages).toHaveLength(2)
|
|
446
502
|
})
|
|
447
503
|
|
|
448
504
|
// ── P1:toggle enable 重算的 nextRunAt 跨 session 重放后保持未来值 ──
|
|
@@ -535,6 +591,146 @@ describe('SchedulerRuntime', () => {
|
|
|
535
591
|
})
|
|
536
592
|
})
|
|
537
593
|
|
|
594
|
+
// ── U4:delivery 内核集成 ──
|
|
595
|
+
// scheduler 的非 force 任务走 delivery 内核(park 模式)。
|
|
596
|
+
// force 任务和无 delivery handle 时走 dispatchDirect(直投)。
|
|
597
|
+
describe('U4: delivery 内核集成', () => {
|
|
598
|
+
it('有 delivery handle 时非 force 任务走 delivery 入队(不直调 sendMessage)', async () => {
|
|
599
|
+
// 模拟 delivery handle(必须在 runtime 构造前设置,构造时从 backend 获取)
|
|
600
|
+
const sentViaDelivery: Array<{ content: string; intent: string }> = []
|
|
601
|
+
const mockDelivery = {
|
|
602
|
+
send: vi.fn((msg: { payload: { content: string }; intent?: string }) => {
|
|
603
|
+
sentViaDelivery.push({
|
|
604
|
+
content: msg.payload.content,
|
|
605
|
+
intent: msg.intent ?? 'after-run',
|
|
606
|
+
})
|
|
607
|
+
}),
|
|
608
|
+
sendChecked: vi.fn(),
|
|
609
|
+
flush: vi.fn(),
|
|
610
|
+
depth: vi.fn(() => 0),
|
|
611
|
+
dispose: vi.fn(),
|
|
612
|
+
}
|
|
613
|
+
backend.deliveryHandle = mockDelivery as any
|
|
614
|
+
// 重新创建 runtime(构造时获取 delivery handle)
|
|
615
|
+
const deliveryRuntime = new SchedulerRuntime(backend, mockCtx)
|
|
616
|
+
|
|
617
|
+
const task = await deliveryRuntime.addTask('delivery-test', { mode: 'interval', intervalMs: 60000 })
|
|
618
|
+
await deliveryRuntime.dispatchTask(task)
|
|
619
|
+
|
|
620
|
+
// 非 force 任务走 delivery 入队
|
|
621
|
+
expect(mockDelivery.send).toHaveBeenCalledTimes(1)
|
|
622
|
+
expect(sentViaDelivery[0]!.content).toBe('delivery-test')
|
|
623
|
+
expect(sentViaDelivery[0]!.intent).toBe('after-run')
|
|
624
|
+
// 不直调 sendMessage
|
|
625
|
+
expect(backend.sentMessages).toHaveLength(0)
|
|
626
|
+
// pending 已清除(入队后移交内核管理)
|
|
627
|
+
expect(task.pending).toBe(false)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('force 任务绕过 delivery 直投', async () => {
|
|
631
|
+
const mockDelivery = {
|
|
632
|
+
send: vi.fn(),
|
|
633
|
+
sendChecked: vi.fn(),
|
|
634
|
+
flush: vi.fn(),
|
|
635
|
+
depth: vi.fn(() => 0),
|
|
636
|
+
dispose: vi.fn(),
|
|
637
|
+
}
|
|
638
|
+
backend.deliveryHandle = mockDelivery as any
|
|
639
|
+
const deliveryRuntime = new SchedulerRuntime(backend, mockCtx)
|
|
640
|
+
|
|
641
|
+
const task = await deliveryRuntime.addTask('force-test', { mode: 'interval', intervalMs: 60000 }, { force: true })
|
|
642
|
+
await deliveryRuntime.dispatchTask(task)
|
|
643
|
+
|
|
644
|
+
// force 任务直投(不走 delivery)
|
|
645
|
+
expect(mockDelivery.send).not.toHaveBeenCalled()
|
|
646
|
+
expect(backend.sentMessages).toHaveLength(1)
|
|
647
|
+
expect(backend.sentMessages[0]!.msg.content).toBe('force-test')
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it('tick 末尾调 delivery.flush()', async () => {
|
|
651
|
+
const mockDelivery = {
|
|
652
|
+
send: vi.fn(),
|
|
653
|
+
sendChecked: vi.fn(),
|
|
654
|
+
flush: vi.fn(),
|
|
655
|
+
depth: vi.fn(() => 0),
|
|
656
|
+
dispose: vi.fn(),
|
|
657
|
+
}
|
|
658
|
+
backend.deliveryHandle = mockDelivery as any
|
|
659
|
+
const deliveryRuntime = new SchedulerRuntime(backend, mockCtx)
|
|
660
|
+
|
|
661
|
+
await deliveryRuntime.tickScheduler()
|
|
662
|
+
|
|
663
|
+
// tick 完成后调 flush
|
|
664
|
+
expect(mockDelivery.flush).toHaveBeenCalledTimes(1)
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('handleSettled delivered(dedupeKey=task.id 反查)→ 成功记账', async () => {
|
|
668
|
+
const task = await runtime.addTask('settle-test', { mode: 'interval', intervalMs: 60000 })
|
|
669
|
+
|
|
670
|
+
// 模拟 delivery onSettled 回调(dispatchViaDelivery 挂 dedupeKey=task.id)
|
|
671
|
+
runtime.handleSettled(settledMsg('settle-test', task.id), 'delivered')
|
|
672
|
+
|
|
673
|
+
// onDispatchSuccess 是异步的,等待完成
|
|
674
|
+
await vi.waitFor(() => {
|
|
675
|
+
expect(task.runCount).toBe(1)
|
|
676
|
+
expect(task.lastStatus).toBe('success')
|
|
677
|
+
})
|
|
678
|
+
expect(task.lastError).toBeUndefined()
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it('handleSettled rejected → 失败记账(once 不删持久化)', async () => {
|
|
682
|
+
const task = await runtime.addTask('settle-fail', { mode: 'interval', intervalMs: 60000 }, { kind: 'once' })
|
|
683
|
+
|
|
684
|
+
// 模拟 delivery onSettled 回调
|
|
685
|
+
runtime.handleSettled(settledMsg('settle-fail', task.id), 'rejected')
|
|
686
|
+
|
|
687
|
+
// 失败记账
|
|
688
|
+
expect(task.lastStatus).toBe('failed')
|
|
689
|
+
expect(task.history[task.history.length - 1]!.status).toBe('failed')
|
|
690
|
+
// once 任务失败不删持久化(任务仍在)
|
|
691
|
+
expect(runtime.getTask(task.id)).toBeDefined()
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('#11 同 prompt 两任务:各自 onSettled 按 dedupeKey 精确记账(不互相错配)', async () => {
|
|
695
|
+
const taskA = await runtime.addTask('same-prompt', { mode: 'interval', intervalMs: 60000 })
|
|
696
|
+
const taskB = await runtime.addTask('same-prompt', { mode: 'interval', intervalMs: 60000 })
|
|
697
|
+
expect(taskA.id).not.toBe(taskB.id)
|
|
698
|
+
|
|
699
|
+
// A 的投递终态:只记 A 的账(旧 content 反查 find 取首个命中,B 先入 Map 时错记 B)
|
|
700
|
+
runtime.handleSettled(settledMsg('same-prompt', taskA.id), 'delivered')
|
|
701
|
+
|
|
702
|
+
await vi.waitFor(() => {
|
|
703
|
+
expect(taskA.runCount).toBe(1)
|
|
704
|
+
})
|
|
705
|
+
expect(taskA.lastStatus).toBe('success')
|
|
706
|
+
expect(taskB.runCount).toBe(0)
|
|
707
|
+
expect(taskB.lastStatus).toBeUndefined()
|
|
708
|
+
|
|
709
|
+
// B 的投递终态:记 B 的账
|
|
710
|
+
runtime.handleSettled(settledMsg('same-prompt', taskB.id), 'delivered')
|
|
711
|
+
|
|
712
|
+
await vi.waitFor(() => {
|
|
713
|
+
expect(taskB.runCount).toBe(1)
|
|
714
|
+
})
|
|
715
|
+
expect(taskB.lastStatus).toBe('success')
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('#11 任务先删后 delivered → 不炸不误记其他任务', async () => {
|
|
719
|
+
const taskA = await runtime.addTask('deleted-task', { mode: 'interval', intervalMs: 60000 })
|
|
720
|
+
const taskB = await runtime.addTask('other-task', { mode: 'interval', intervalMs: 60000 })
|
|
721
|
+
|
|
722
|
+
// once 成功 / 手动删除后 delivered 迟到:dedupeKey 反查未命中 → no-op
|
|
723
|
+
runtime.deleteTask(taskA.id)
|
|
724
|
+
expect(() => {
|
|
725
|
+
runtime.handleSettled(settledMsg('deleted-task', taskA.id), 'delivered')
|
|
726
|
+
}).not.toThrow()
|
|
727
|
+
|
|
728
|
+
// 不误记其他任务的账
|
|
729
|
+
expect(taskB.runCount).toBe(0)
|
|
730
|
+
expect(taskB.lastStatus).toBeUndefined()
|
|
731
|
+
})
|
|
732
|
+
})
|
|
733
|
+
|
|
538
734
|
// ── F2:tick 错误分诊(crash-fix)──
|
|
539
735
|
// startScheduler 的 interval 回调对 fire-and-forget 的 tickScheduler() 加 catch:
|
|
540
736
|
// stale 类错误(session 替换后泄漏 timer 访问 stale ctx)→ warn "tick stopped" + stopScheduler
|
|
@@ -195,10 +195,9 @@ describe('SchedulerService', () => {
|
|
|
195
195
|
})
|
|
196
196
|
})
|
|
197
197
|
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
it('returns DISPATCH_SKIPPED when ctx is busy (isIdle=false)', async () => {
|
|
198
|
+
// U4 变更:gate 已交 delivery 内核,无 delivery handle 时走 dispatchDirect(直投不检查 idle)。
|
|
199
|
+
// busy 不再导致 DISPATCH_SKIPPED(直投成功)。
|
|
200
|
+
it('无 delivery handle 时 busy 不影响 dispatch(直投)', async () => {
|
|
202
201
|
const busyCtx = { isIdle: () => false, hasPendingMessages: () => false }
|
|
203
202
|
const busyBackend = new MockSchedulerBackend()
|
|
204
203
|
const busyService = new SchedulerService(new SchedulerRuntime(busyBackend, busyCtx), () => busyBackend.now())
|
|
@@ -207,11 +206,9 @@ describe('SchedulerService', () => {
|
|
|
207
206
|
const id = created.data!.task.id
|
|
208
207
|
const result = await busyService.run(id)
|
|
209
208
|
|
|
210
|
-
|
|
211
|
-
expect(result.
|
|
212
|
-
expect(
|
|
213
|
-
// 未发送任何 message(dispatch no-op)
|
|
214
|
-
expect(busyBackend.sentMessages).toHaveLength(0)
|
|
209
|
+
// 直投成功(无 delivery handle → dispatchDirect,不检查 idle)
|
|
210
|
+
expect(result.success).toBe(true)
|
|
211
|
+
expect(busyBackend.sentMessages).toHaveLength(1)
|
|
215
212
|
})
|
|
216
213
|
})
|
|
217
214
|
})
|
package/src/backend.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { DeliveryHandle } from '@xyz-agent/session-delivery'
|
|
2
3
|
|
|
3
4
|
import { replayFoldEntries, type SchedulerEntryLike } from './replay.js'
|
|
4
5
|
import type { ScheduledTask, SchedulerEntryOp } from './types.js'
|
|
@@ -33,6 +34,8 @@ export interface SchedulerBackend {
|
|
|
33
34
|
appendEntry(op: SchedulerEntryOp): void
|
|
34
35
|
getSessionFile(): string | undefined
|
|
35
36
|
now(): number
|
|
37
|
+
/** 获取 delivery handle(装配点注入;未注入时 force 路径仍用 sendMessage 直投)。 */
|
|
38
|
+
getDeliveryHandle?(): DeliveryHandle | undefined
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -59,6 +62,7 @@ export interface SchedulerBackendCtx {
|
|
|
59
62
|
export class PiSchedulerBackend implements SchedulerBackend {
|
|
60
63
|
private ctx: SchedulerBackendCtx
|
|
61
64
|
private pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>
|
|
65
|
+
private deliveryHandle: DeliveryHandle | undefined
|
|
62
66
|
|
|
63
67
|
constructor(ctx: SchedulerBackendCtx, pi: Pick<ExtensionAPI, 'sendMessage' | 'appendEntry'>) {
|
|
64
68
|
this.ctx = ctx
|
|
@@ -93,6 +97,18 @@ export class PiSchedulerBackend implements SchedulerBackend {
|
|
|
93
97
|
now(): number {
|
|
94
98
|
return Date.now()
|
|
95
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 注入 delivery handle(装配点创建后调用)。
|
|
103
|
+
* runtime 通过 getDeliveryHandle() 获取,非 force 任务走内核队列。
|
|
104
|
+
*/
|
|
105
|
+
setDeliveryHandle(handle: DeliveryHandle): void {
|
|
106
|
+
this.deliveryHandle = handle
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getDeliveryHandle(): DeliveryHandle | undefined {
|
|
110
|
+
return this.deliveryHandle
|
|
111
|
+
}
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
// ── 测试实现 ──
|
|
@@ -120,6 +136,7 @@ export class MockSchedulerBackend implements SchedulerBackend {
|
|
|
120
136
|
fakeSessionFile: string | undefined = '/test/session.json'
|
|
121
137
|
nowValue: number | undefined
|
|
122
138
|
appendError: Error | null = null
|
|
139
|
+
deliveryHandle: DeliveryHandle | undefined
|
|
123
140
|
|
|
124
141
|
async sendMessage(
|
|
125
142
|
msg: { content: string; customType: string; display: boolean },
|
|
@@ -141,6 +158,10 @@ export class MockSchedulerBackend implements SchedulerBackend {
|
|
|
141
158
|
return this.nowValue ?? Date.now()
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
getDeliveryHandle(): DeliveryHandle | undefined {
|
|
162
|
+
return this.deliveryHandle
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
/**
|
|
145
166
|
* 读路径(非接口成员,与 PiSchedulerBackend.loadTasks 对称):经 replayFoldEntries 折叠
|
|
146
167
|
* fakeEntries + fakeSessionFile 恢复任务。测试用它验证 backend→replay 委托(TC-W-BACKEND-REPLAY)。
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { createDelivery, type DeliveryHandle, type DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
2
3
|
|
|
3
4
|
import { PiSchedulerBackend } from './backend.js'
|
|
4
5
|
import { registerScheduleCommand } from './commands.js'
|
|
@@ -43,6 +44,7 @@ let sessionGeneration = 0
|
|
|
43
44
|
*/
|
|
44
45
|
export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
45
46
|
let service: SchedulerService | null = null
|
|
47
|
+
let deliveryHandle: DeliveryHandle | undefined
|
|
46
48
|
// IMPORT-FLUSH-GUARD(MF-1):importLegacyStore 对未 flush 的新 session 返回延迟删除 .imported
|
|
47
49
|
// 的 cleanup——turn_end / session_shutdown 时执行:确认 flush(sessionFile 已出现)则删,
|
|
48
50
|
// 未 flush 保留供崩溃恢复重导入(否则未 flush 即退出 → 全部旧任务丢失且源文件已销毁)。
|
|
@@ -66,15 +68,58 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
66
68
|
// 窗口与 session 替换交错时旧 session_shutdown 可能永远等不到(timer 泄漏源头)。stopScheduler 幂等,
|
|
67
69
|
// shutdown 已停过再停一次无副作用。
|
|
68
70
|
service?.runtime.stopScheduler()
|
|
71
|
+
// 销毁旧 delivery handle(清队列 + 清 timer + 退订 settled)
|
|
72
|
+
deliveryHandle?.dispose()
|
|
69
73
|
// 装配点:backend(ctx.sessionManager 读 entries / pi.appendEntry 写 op)→ runtime(内存态 + 调度)→ service(业务入口)
|
|
70
74
|
const backend = new PiSchedulerBackend(ctx, pi)
|
|
71
75
|
// 旧 store 原子导入(CL3 方案A):必须在 backend.loadTasks() 之前执行——
|
|
72
76
|
// append 的 upsert entry 进入 pi 内存 fileEntries,紧接的 loadTasks replay 统一重放读到导入任务。
|
|
73
77
|
// ctx.cwd 类型为 string(SDK ExtensionContext 必填),无需 ?? process.cwd() 兜底(CL2)。
|
|
74
78
|
importCleanup = importLegacyStore(ctx.cwd, pi, ctx.sessionManager.getSessionFile())
|
|
79
|
+
// 创建 delivery handle(U4:scheduler 切换内核)
|
|
80
|
+
// busyPolicy: 'park'(busy 入队不重试,等 tick 外部 flush)
|
|
81
|
+
// intent: 'after-run'(保持 followUp 语义)
|
|
82
|
+
// onSettled:延迟绑定——runtime 创建后填充(闭包变量)
|
|
83
|
+
// #11:整条 msg 透传(runtime 按 msg.dedupeKey=task.id 精确反查,不再 content 匹配)
|
|
84
|
+
const settledHandlerRef: { current: ((msg: DeliveryMessage, outcome: 'delivered' | 'rejected') => void) | undefined } = { current: undefined }
|
|
85
|
+
deliveryHandle = createDelivery({
|
|
86
|
+
supportedPayloads: ['custom'],
|
|
87
|
+
isIdle: () => ctx.isIdle(),
|
|
88
|
+
hasPendingMessages: () => ctx.hasPendingMessages(),
|
|
89
|
+
subscribeSettled: (cb) => {
|
|
90
|
+
// 无退订语义(pi 0.84.1 `pi.on(...)` 全重载返回 void、无 off——实装锚点:
|
|
91
|
+
// node_modules @earendil-works/pi-coding-agent dist/core/extensions/types.d.ts
|
|
92
|
+
// `on()` 系列,0.84.1 实测);disposed 标志包装兑现退订(notifier.ts 同款)
|
|
93
|
+
let disposed = false
|
|
94
|
+
pi.on('agent_settled', () => { if (!disposed) cb() })
|
|
95
|
+
return () => { disposed = true }
|
|
96
|
+
},
|
|
97
|
+
send: (msg, intent) => {
|
|
98
|
+
const piOpts = intent === 'interrupt-at-turn-boundary'
|
|
99
|
+
? { triggerTurn: true, deliverAs: 'steer' as const }
|
|
100
|
+
: { triggerTurn: true, deliverAs: 'followUp' as const }
|
|
101
|
+
// supportedPayloads 已收窄到 'custom',payload.kind 恒为 'custom'
|
|
102
|
+
const content = msg.payload.content
|
|
103
|
+
return pi.sendMessage(
|
|
104
|
+
{ content, customType: 'pi-scheduler:dispatched', display: true },
|
|
105
|
+
piOpts,
|
|
106
|
+
)
|
|
107
|
+
},
|
|
108
|
+
}, {
|
|
109
|
+
intent: 'after-run',
|
|
110
|
+
busyPolicy: 'park',
|
|
111
|
+
onSettled: (msg, outcome) => {
|
|
112
|
+
// 委托给 runtime 的 settledHandler(延迟绑定)
|
|
113
|
+
settledHandlerRef.current?.(msg, outcome)
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
backend.setDeliveryHandle(deliveryHandle)
|
|
117
|
+
|
|
75
118
|
// G1:注入代际比对(本 runtime 建立时的代数 vs 实时代数),供 tick 前置检查与
|
|
76
119
|
// F2 catch 分诊判定 stale——不依赖 pi 错误文案。
|
|
77
120
|
const runtime = new SchedulerRuntime(backend, ctx, () => sessionGeneration !== myGeneration)
|
|
121
|
+
// 延迟绑定:runtime 的 handleSettled 绑定到 delivery onSettled 回调
|
|
122
|
+
settledHandlerRef.current = (msg, outcome) => runtime.handleSettled(msg, outcome)
|
|
78
123
|
runtime.loadTasks(backend.loadTasks())
|
|
79
124
|
// W2:tick 后回调刷新 widget(替代独立 widgetTimer + setInterval,节奏对齐 TICK_INTERVAL_MS)
|
|
80
125
|
runtime.onAfterTick(() => refreshWidget(ctx))
|
|
@@ -100,6 +145,9 @@ export default function schedulerExtension(pi: ExtensionAPI): void {
|
|
|
100
145
|
if (service) {
|
|
101
146
|
service.runtime.stopScheduler()
|
|
102
147
|
}
|
|
148
|
+
// 销毁 delivery handle(清队列 + 清 timer + 退订 settled)
|
|
149
|
+
deliveryHandle?.dispose()
|
|
150
|
+
deliveryHandle = undefined
|
|
103
151
|
// IMPORT-FLUSH-GUARD(MF-1):兜底清理——正常路径已由首个 turn_end 完成;此处覆盖
|
|
104
152
|
// 从未产生 turn 的 session(打开未发消息即关闭)。cleanup 确认 flush(sessionFile 已出现)
|
|
105
153
|
// 则删 .imported,未 flush 保留供崩溃恢复重导入
|
package/src/runtime.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
|
|
3
|
+
import type { DeliveryHandle, DeliveryMessage } from '@xyz-agent/session-delivery'
|
|
4
|
+
|
|
3
5
|
import type { SchedulerBackend } from './backend.js'
|
|
4
6
|
import { autoName, generateTaskId } from './format.js'
|
|
5
7
|
import { computeNextRunAt, parseDuration } from './parsing.js'
|
|
@@ -12,6 +14,8 @@ import type {
|
|
|
12
14
|
} from './types.js'
|
|
13
15
|
|
|
14
16
|
const MAX_TASKS = 50
|
|
17
|
+
// 入队防重标记 TTL(合批非首条任务无终态回调,过期后放行重投;10 min >> 合批窗口)
|
|
18
|
+
const QUEUE_DEDUPE_TTL_MS = 10 * 60 * 1000
|
|
15
19
|
const RATE_LIMIT_PER_MINUTE = 6
|
|
16
20
|
const TICK_INTERVAL_MS = 30_000
|
|
17
21
|
const DEFAULT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
@@ -30,13 +34,21 @@ const STALE_CTX_MARKER = 'stale after session replacement'
|
|
|
30
34
|
export class SchedulerRuntime {
|
|
31
35
|
private tasks: Map<string, ScheduledTask> = new Map()
|
|
32
36
|
private backend: SchedulerBackend
|
|
33
|
-
private ctx: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>
|
|
34
37
|
private tickTimer: ReturnType<typeof setInterval> | null = null
|
|
35
38
|
private dispatchTimestamps: number[] = []
|
|
36
39
|
private onAfterTickCallback: (() => void) | null = null
|
|
37
40
|
private readonly isCtxStale: (() => boolean) | undefined
|
|
38
41
|
// R3-S1:同任务 dispatch 在途标记(Set<taskId>),见 dispatchTask 注释
|
|
39
42
|
private readonly dispatchesInFlight = new Set<string>()
|
|
43
|
+
// 入队防重标记(Map<taskId, enqueuedAt>):非 force 任务 send 进 delivery 内核后、
|
|
44
|
+
// 终态回调(handleSettled)前,nextRunAt 未推进——tick step2 会按 `now >= nextRunAt`
|
|
45
|
+
// 重新置 pending,若无此标记,agent busy 的每个 tick 都会再压一份同 prompt 副本进队列
|
|
46
|
+
// (合批后重复注入)。入队置位、delivered/rejected 清除;任务删除(delete/过期)同步清除。
|
|
47
|
+
// TTL 兜底:合批投递时 onSettled 只带首条 dedupeKey,非首条任务收不到终态回调——
|
|
48
|
+
// 标记过期后允许重投,保持 at-least-once(与旧「nextRunAt 未推进下 tick 重投」等价)。
|
|
49
|
+
private readonly queuedInDeliveryAt = new Map<string, number>()
|
|
50
|
+
// delivery handle(装配点注入;非 force 任务走内核队列)
|
|
51
|
+
private delivery: DeliveryHandle | undefined
|
|
40
52
|
|
|
41
53
|
/**
|
|
42
54
|
* 依赖反转构造:backend 承担 appendEntry/pi.sendMessage/时间源,runtime 只持有内存态。
|
|
@@ -48,12 +60,15 @@ export class SchedulerRuntime {
|
|
|
48
60
|
*/
|
|
49
61
|
constructor(
|
|
50
62
|
backend: SchedulerBackend,
|
|
51
|
-
ctx
|
|
63
|
+
ctx?: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
|
|
52
64
|
isCtxStale?: () => boolean,
|
|
53
65
|
) {
|
|
54
66
|
this.backend = backend
|
|
55
|
-
|
|
67
|
+
// ctx 不再存实例变量(gate 已交内核);isCtxStale 保留用于代际检测
|
|
68
|
+
void ctx
|
|
56
69
|
this.isCtxStale = isCtxStale
|
|
70
|
+
// 从 backend 获取 delivery handle(装配点注入)
|
|
71
|
+
this.delivery = backend.getDeliveryHandle?.()
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
// ── 任务 CRUD ──
|
|
@@ -161,6 +176,7 @@ export class SchedulerRuntime {
|
|
|
161
176
|
|
|
162
177
|
deleteTask(id: string): boolean {
|
|
163
178
|
const deleted = this.tasks.delete(id)
|
|
179
|
+
if (deleted) this.queuedInDeliveryAt.delete(id)
|
|
164
180
|
if (deleted) {
|
|
165
181
|
this.appendEntrySafe({ op: 'delete', taskId: id })
|
|
166
182
|
}
|
|
@@ -239,6 +255,7 @@ export class SchedulerRuntime {
|
|
|
239
255
|
for (const [id, task] of this.tasks) {
|
|
240
256
|
if (task.expiresAt && now >= task.expiresAt) {
|
|
241
257
|
this.tasks.delete(id)
|
|
258
|
+
this.queuedInDeliveryAt.delete(id)
|
|
242
259
|
this.appendEntrySafe({ op: 'delete', taskId: id })
|
|
243
260
|
}
|
|
244
261
|
}
|
|
@@ -264,6 +281,10 @@ export class SchedulerRuntime {
|
|
|
264
281
|
|
|
265
282
|
// W2:tick 完成后刷新 widget(index.ts 注册 refreshWidget)
|
|
266
283
|
this.onAfterTickCallback?.()
|
|
284
|
+
|
|
285
|
+
// delivery flush:park 模式下内核不主动重试,由 scheduler tick 外部触发 flush
|
|
286
|
+
// 让积压在队列中的消息在每个 tick 尝试投递
|
|
287
|
+
this.delivery?.flush()
|
|
267
288
|
}
|
|
268
289
|
|
|
269
290
|
// ── dispatch ──
|
|
@@ -302,17 +323,28 @@ export class SchedulerRuntime {
|
|
|
302
323
|
* once 成功 → append delete。失败 dispatch 不 append(CL7 重试语义,transient 失败 nextRunAt 未推进)。
|
|
303
324
|
*/
|
|
304
325
|
private async dispatchTaskInner(task: ScheduledTask): Promise<boolean> {
|
|
305
|
-
// 检查 force 或 idle
|
|
306
|
-
if (!task.force) {
|
|
307
|
-
if (!this.ctx.isIdle() || this.ctx.hasPendingMessages()) {
|
|
308
|
-
return false // 延迟到下次 tick
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
326
|
// 检查速率限制
|
|
313
327
|
if (!this.hasDispatchCapacity(this.backend.now())) return false
|
|
314
328
|
|
|
315
|
-
|
|
329
|
+
if (task.force || !this.delivery) {
|
|
330
|
+
// force 任务或无 delivery handle 时直投(绕过内核队列)
|
|
331
|
+
return this.dispatchDirect(task)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 已在内核队列中(入队后未终态且未过 TTL)——step2 会按未推进的 nextRunAt 重新置
|
|
335
|
+
// pending,此处拦截防重复入队(见 queuedInDeliveryAt 字段注释)
|
|
336
|
+
const queuedAt = this.queuedInDeliveryAt.get(task.id)
|
|
337
|
+
if (queuedAt !== undefined && this.backend.now() - queuedAt < QUEUE_DEDUPE_TTL_MS) return false
|
|
338
|
+
|
|
339
|
+
// 非 force 任务走 delivery 内核(park 模式:busy 入队等下次 tick flush)
|
|
340
|
+
return this.dispatchViaDelivery(task)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* force 任务直投:绕过 delivery 内核队列,直接调 backend.sendMessage。
|
|
345
|
+
* 无 delivery handle 时也走此路径(向后兼容)。
|
|
346
|
+
*/
|
|
347
|
+
private async dispatchDirect(task: ScheduledTask): Promise<boolean> {
|
|
316
348
|
try {
|
|
317
349
|
await this.backend.sendMessage(
|
|
318
350
|
{ content: task.prompt, customType: 'pi-scheduler:dispatched', display: true },
|
|
@@ -325,49 +357,111 @@ export class SchedulerRuntime {
|
|
|
325
357
|
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
326
358
|
return false
|
|
327
359
|
}
|
|
360
|
+
return this.onDispatchSuccess(task)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 非 force 任务走 delivery 内核:入队后由内核 flush 时投递。
|
|
365
|
+
* gate(isIdle/hasPendingMessages)由内核管理,busy 时入队不重试(park 模式)。
|
|
366
|
+
* onSettled 回调处理成功/失败记账。
|
|
367
|
+
* 返回 true 表示已入队(非实际发送)。
|
|
368
|
+
*/
|
|
369
|
+
private dispatchViaDelivery(task: ScheduledTask): boolean {
|
|
370
|
+
const delivery = this.delivery!
|
|
371
|
+
|
|
372
|
+
delivery.send({
|
|
373
|
+
payload: {
|
|
374
|
+
kind: 'custom',
|
|
375
|
+
customType: 'pi-scheduler:dispatched',
|
|
376
|
+
content: task.prompt,
|
|
377
|
+
display: true,
|
|
378
|
+
},
|
|
379
|
+
intent: 'after-run',
|
|
380
|
+
// #11:task.id 作为 onSettled 反查键(本 handle 未开 dedupe,dedupeKey 不驱动
|
|
381
|
+
// 去重,仅随消息透传给 onSettled 回调)。content 反查在同 prompt 多任务下错配。
|
|
382
|
+
dedupeKey: task.id,
|
|
383
|
+
})
|
|
328
384
|
|
|
329
|
-
//
|
|
385
|
+
// send() 不 throw(park 模式下入队即返回)。
|
|
386
|
+
// 入队即挂防重标记 + 计入速率限制(nextRunAt 要等 delivered 后才推进,期间 step2
|
|
387
|
+
// 会持续重标 pending——防重标记拦截重复入队,速率记账覆盖入队侧消耗)。
|
|
388
|
+
// 成功/失败记账由 onSettled 回调异步处理。
|
|
389
|
+
this.queuedInDeliveryAt.set(task.id, this.backend.now())
|
|
390
|
+
this.dispatchTimestamps.push(this.backend.now())
|
|
391
|
+
task.pending = false
|
|
392
|
+
return true
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* dispatch 成功后的状态更新与持久化(dispatchDirect 成功后、onSettled delivered 后共用)。
|
|
397
|
+
* countRate=false 时不再计速率(delivery 路径入队时已计入,delivered 再计会双算)。
|
|
398
|
+
*/
|
|
399
|
+
private async onDispatchSuccess(task: ScheduledTask, countRate = true): Promise<boolean> {
|
|
330
400
|
const now = this.backend.now()
|
|
331
401
|
task.runCount++
|
|
332
402
|
task.lastRunAt = now
|
|
333
403
|
task.lastStatus = 'success'
|
|
334
404
|
task.pending = false
|
|
335
|
-
task.lastError = undefined
|
|
405
|
+
task.lastError = undefined
|
|
336
406
|
task.history.push({ at: now, status: 'success' })
|
|
337
407
|
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
338
408
|
|
|
339
|
-
// 计算下次执行 + 持久化(append-only)
|
|
340
409
|
if (task.kind === 'once') {
|
|
341
410
|
this.tasks.delete(task.id)
|
|
342
|
-
// once 成功 → append delete(CL7:抵消 upsert,防 resume 复活已执行的 once 任务)
|
|
343
411
|
this.appendEntrySafe({ op: 'delete', taskId: task.id })
|
|
344
412
|
} else {
|
|
345
413
|
const next = await computeNextRunAt(task.schedule, now)
|
|
346
414
|
if (next === undefined) {
|
|
347
|
-
// ERR-2 fallback:cron 表达式失效 → 停用任务,避免 `?? now()` 死循环。
|
|
348
|
-
// 不 append advance(nextRunAt 未推进,CL7 重试语义);cron 失效停用的 enabled=false
|
|
349
|
-
// 持久化缺口属 at-least-once 已知窗口(首个 tick 会再停用),非 must-fix
|
|
350
415
|
task.enabled = false
|
|
351
416
|
task.lastStatus = 'failed'
|
|
352
417
|
task.lastError = 'cron expression invalid'
|
|
353
|
-
// nextRunAt 保留原值(enabled=false 后 tick 不再触发)
|
|
354
418
|
} else {
|
|
355
419
|
task.nextRunAt = next
|
|
356
|
-
// recurring 成功推进 nextRunAt → append advance(D1 核心:持久化新 nextRunAt,防 resume 回退重放)
|
|
357
420
|
this.appendEntrySafe({
|
|
358
421
|
op: 'advance',
|
|
359
422
|
taskId: task.id,
|
|
360
423
|
nextRunAt: next,
|
|
361
424
|
at: now,
|
|
362
|
-
status: 'success',
|
|
425
|
+
status: 'success',
|
|
363
426
|
})
|
|
364
427
|
}
|
|
365
428
|
}
|
|
366
429
|
|
|
367
|
-
this.dispatchTimestamps.push(now)
|
|
430
|
+
if (countRate) this.dispatchTimestamps.push(now)
|
|
368
431
|
return true
|
|
369
432
|
}
|
|
370
433
|
|
|
434
|
+
/**
|
|
435
|
+
* onSettled 回调入口(index.ts 装配点绑定):delivery 内核投递终态时调用。
|
|
436
|
+
* delivered → 成功记账(onDispatchSuccess);rejected → 失败记账。
|
|
437
|
+
* once 任务失败不删持久化(at-least-once 语义)。
|
|
438
|
+
* #11:按 msg.dedupeKey(dispatch 时挂的 task.id,见 dispatchViaDelivery)精确反查
|
|
439
|
+
* tasks Map——旧 content 反查在「同 prompt 多任务」下错配(find 取首个命中),
|
|
440
|
+
* 任务已删除时静默丢弃不误记。反查未命中(once 成功已删 / 批量合投非首条 /
|
|
441
|
+
* 非 scheduler 消息)直接返回。
|
|
442
|
+
* 已知限制:内核 busy 期间多条消息合投为一批时(doSend splice 全队列),onSettled
|
|
443
|
+
* 只收到保留首条 dedupeKey 的 composed 消息——非首条任务不记账,靠下个 tick 的
|
|
444
|
+
* nextRunAt 未推进重投(at-least-once 兜底),与旧 content 反查行为等价不劣化。
|
|
445
|
+
*/
|
|
446
|
+
handleSettled(msg: DeliveryMessage, outcome: 'delivered' | 'rejected'): void {
|
|
447
|
+
const taskId = msg.dedupeKey
|
|
448
|
+
// 防重标记先清(任务可能已被删除,反查未命中也要清)
|
|
449
|
+
if (taskId !== undefined) this.queuedInDeliveryAt.delete(taskId)
|
|
450
|
+
const task = taskId !== undefined ? this.tasks.get(taskId) : undefined
|
|
451
|
+
if (!task) return // 任务已被删除(once 成功后删)或非 scheduler 发出的消息
|
|
452
|
+
|
|
453
|
+
if (outcome === 'delivered') {
|
|
454
|
+
// fire-and-forget:onDispatchSuccess 内部 catch 不 rethrow(countRate=false:入队时已计速率)
|
|
455
|
+
void this.onDispatchSuccess(task, false).catch(() => {})
|
|
456
|
+
} else {
|
|
457
|
+
// 失败记账
|
|
458
|
+
task.lastStatus = 'failed'
|
|
459
|
+
task.history.push({ at: this.backend.now(), status: 'failed' })
|
|
460
|
+
if (task.history.length > HISTORY_LIMIT) task.history.shift()
|
|
461
|
+
// once 任务失败不删持久化(at-least-once 语义)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
371
465
|
private hasDispatchCapacity(now: number): boolean {
|
|
372
466
|
const oneMinuteAgo = now - 60_000
|
|
373
467
|
this.dispatchTimestamps = this.dispatchTimestamps.filter(t => t > oneMinuteAgo)
|