@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 CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-scheduler",
3
- "version": "0.3.3",
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('skips when not idle and force is false', async () => {
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(0)
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
- it('skips when idle but has pending messages', async () => {
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
- // 场景:busy tick 标记 pending=true(W4 tick 重试)→ disable enable 重算到未来。
412
- // 修复前 pending 残留 下个 tick step3 `pending && enabled` 在重算的未来时间点之前提前 dispatch。
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:任务到期 + busy step2 pending=true,step3 dispatchTask busy 跳过(pending 保留)
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(true) // busy tick 残留 pending(W4 跨 tick 重试)
427
- expect(controllableBackend.sentMessages).toHaveLength(0)
486
+ expect(task.pending).toBe(false) // 直投成功后 pending 已清除
487
+ expect(controllableBackend.sentMessages).toHaveLength(1) // 直投成功
428
488
 
429
- // disable → enable:enable 重算 nextRunAt 到未来(T0+61s + 60s = T0+121s)
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,切 idle 排除 busy 干扰 → 不应 dispatch
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(0)
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(1)
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
- // TC8:busy 场景(任务 enabled ctx.isIdle() 返回 false)→ dispatch no-op
199
- // DISPATCH_SKIPPED。m2 只覆盖了 disabled 场景,busy dispatchTask 的
200
- // `!task.force && (!ctx.isIdle() || ctx.hasPendingMessages())` 独立分支。
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
- expect(result.success).toBe(false)
211
- expect(result.errorCode).toBe('DISPATCH_SKIPPED')
212
- expect(result.message).toContain('busy')
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: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
63
+ ctx?: Pick<ExtensionContext, 'isIdle' | 'hasPendingMessages'>,
52
64
  isCtxStale?: () => boolean,
53
65
  ) {
54
66
  this.backend = backend
55
- this.ctx = ctx
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
- // 注入 message(await:async 错误必须被捕获,fire-and-forget 会漏)
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
- // 更新状态(收敛到一个 now 值,避免多次 backend.now() 在真实时钟下漂移)
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 // 成功 dispatch 后清除历史错误
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', // CL8:对齐 TaskStatus,非 'ok'
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)