@mzzsfy/dsh-cron-board 0.2.1 → 0.3.1

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.
@@ -0,0 +1,123 @@
1
+ // 调度构建器纯函数行为锁定:结构化状态与 cron 表达式互转,反解不出即 custom。
2
+ // BDD:给定表单状态,生成表达式;给定表达式,反解为表单状态;不可表达形态一律 custom 兜底。
3
+
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { dirname, join } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ import { buildSchedule, createScheduleState, parseSchedule, FREQS } from '../src/schedule-builder.mjs'
11
+
12
+ test('FREQS 覆盖五种频率形态', () => {
13
+ assert.deepEqual(FREQS, ['daily', 'weekly', 'weekdays', 'interval', 'custom'])
14
+ })
15
+
16
+ test('daily:整点状态生成 每天 表达式', () => {
17
+ const state = createScheduleState('')
18
+ state.freq = 'daily'
19
+ state.hour = 2
20
+ state.minute = 0
21
+ assert.equal(buildSchedule(state), '0 2 * * *')
22
+ })
23
+
24
+ test('daily:反解 每天表达式 还原时刻', () => {
25
+ const state = parseSchedule('30 8 * * *')
26
+ assert.equal(state.freq, 'daily')
27
+ assert.equal(state.hour, 8)
28
+ assert.equal(state.minute, 30)
29
+ })
30
+
31
+ test('weekdays:工作日状态生成 1-5 表达式', () => {
32
+ const state = createScheduleState('')
33
+ state.freq = 'weekdays'
34
+ state.hour = 9
35
+ state.minute = 5
36
+ assert.equal(buildSchedule(state), '5 9 * * 1-5')
37
+ })
38
+
39
+ test('weekdays:反解 1-5 工作日形态', () => {
40
+ const state = parseSchedule('0 9 * * 1-5')
41
+ assert.equal(state.freq, 'weekdays')
42
+ })
43
+
44
+ test('weekly:多选星期按周序输出', () => {
45
+ const state = createScheduleState('')
46
+ state.freq = 'weekly'
47
+ state.hour = 22
48
+ state.minute = 0
49
+ state.weekdays = ['5', '1']
50
+ assert.equal(buildSchedule(state), '0 22 * * 1,5')
51
+ })
52
+
53
+ test('weekly:反解多星期表达式按 UI 序(一..六、日)归位', () => {
54
+ const state = parseSchedule('0 22 * * 6,0,3')
55
+ assert.equal(state.freq, 'weekly')
56
+ assert.deepEqual(state.weekdays, ['3', '6', '0'])
57
+ })
58
+
59
+ test('interval:每 N 分钟生成 */N 表达式', () => {
60
+ const state = createScheduleState('')
61
+ state.freq = 'interval'
62
+ state.intervalMinutes = 30
63
+ assert.equal(buildSchedule(state), '*/30 * * * *')
64
+ })
65
+
66
+ test('interval:反解 */N 分钟形态', () => {
67
+ const state = parseSchedule('*/15 * * * *')
68
+ assert.equal(state.freq, 'interval')
69
+ assert.equal(state.intervalMinutes, 15)
70
+ })
71
+
72
+ test('interval:非法步进(0 或超界)反解为 custom', () => {
73
+ assert.equal(parseSchedule('*/0 * * * *').freq, 'custom')
74
+ assert.equal(parseSchedule('*/61 * * * *').freq, 'custom')
75
+ })
76
+
77
+ test('指定日月的表达式不可表达,归 custom', () => {
78
+ const state = parseSchedule('0 0 1 * *')
79
+ assert.equal(state.freq, 'custom')
80
+ assert.equal(state.expression, '0 0 1 * *')
81
+ })
82
+
83
+ test('小时步进(0 */2 * * *)非构建器形态,归 custom', () => {
84
+ assert.equal(parseSchedule('0 */2 * * *').freq, 'custom')
85
+ })
86
+
87
+ test('非 5 段与乱文本归 custom 且原样保留', () => {
88
+ assert.equal(parseSchedule('not a cron').freq, 'custom')
89
+ const six = parseSchedule('0 9 * * * 2026')
90
+ assert.equal(six.freq, 'custom')
91
+ assert.equal(six.expression, '0 9 * * * 2026')
92
+ })
93
+
94
+ test('custom:构建直接透传手工表达式', () => {
95
+ const state = createScheduleState(' 10 1 1 1 * ')
96
+ state.freq = 'custom'
97
+ assert.equal(buildSchedule(state), '10 1 1 1 *')
98
+ })
99
+
100
+ test('字段越界数值反解归 custom,不猜修正', () => {
101
+ assert.equal(parseSchedule('99 25 * * *').freq, 'custom')
102
+ assert.equal(parseSchedule('* 9 * * *').freq, 'custom')
103
+ })
104
+
105
+ test('parity:client.js SBUILD 段与核心实现逐字镜像', () => {
106
+ // Given client 半区无模块系统,构建器逻辑必须内联(经典 script)
107
+ // When 从两侧源码提取函数体文本(SBUILD 标记段 vs schedule-builder.mjs 导出体)
108
+ // Then 除常量前缀(SB_ 命名空间)与导出关键词外逐字一致,防双实现漂移
109
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
110
+ const client = readFileSync(join(pkgRoot, 'src', 'client.js'), 'utf8')
111
+ const begin = client.indexOf('/* SBUILD-BEGIN */')
112
+ const end = client.indexOf('/* SBUILD-END */')
113
+ assert.ok(begin >= 0 && end > begin, 'client.js 缺少 SBUILD 标记段')
114
+ const block = client.slice(begin, end)
115
+ const core = readFileSync(join(pkgRoot, 'src', 'schedule-builder.mjs'), 'utf8')
116
+ // 逐函数对比:剥离 export 关键词后,核心源码的每个可执行行都应出现在 client 镜像段
117
+ const coreLines = core.split('\n')
118
+ .map((line) => line.replace(/^export /, '').replace(/\bFREQS\b/g, 'SB_FREQS').replace(/\bWEEKDAY_ORDER\b/g, 'SB_WEEKDAY_ORDER').replace(/\bWEEKDAY_LABELS\b/g, 'SB_WEEKDAY_LABELS').replace(/\bMINUTE_MAX\b/g, 'SB_MINUTE_MAX').replace(/\bHOUR_MAX\b/g, 'SB_HOUR_MAX').replace(/\bINTERVAL_MIN_MINUTES\b/g, 'SB_INTERVAL_MIN_MINUTES').replace(/\bclampInt\b/g, 'sbClampInt').trim())
119
+ .filter((line) => line !== '' && !line.startsWith('//'))
120
+ for (const line of coreLines) {
121
+ assert.ok(block.includes(line), 'client 镜像段缺少核心行: ' + line)
122
+ }
123
+ })
@@ -11,6 +11,7 @@ import { createStore } from '../src/store.mjs'
11
11
  import { createLogger } from '../src/logger.mjs'
12
12
  import { createExecutor } from '../src/executor.mjs'
13
13
  import { createScheduler } from '../src/scheduler.mjs'
14
+ import { nextRunAtOf } from '../src/cron.mjs'
14
15
 
15
16
  const TICK_MS = 30 * 1000
16
17
 
@@ -144,6 +145,52 @@ test('scheduler:recover 将遗留 queued/running 孤儿记录标 interrupted', a
144
145
  assert.equal(store.runs.get('r-done').status, 'success')
145
146
  })
146
147
 
148
+ test('scheduler:recover 自愈残留触发点——nextRunAt 非当前表达式触发点即重算', async (t) => {
149
+ // Given 旧版本编辑残留:工作日任务带着非工作日表达式的触发点(历史 bug 数据)
150
+ const { store, scheduler } = await makeScheduler(t)
151
+ const legal = nextRunAtOf('55 9 * * 1-5')
152
+ const stale = nextRunAtOf('0 9 * * *')
153
+ const jobStale = await store.jobs.create({
154
+ name: '残留', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
155
+ enabled: true, timeoutMs: 1000, nextRunAt: stale,
156
+ })
157
+ const jobStaleDisabled = await store.jobs.create({
158
+ name: '残留禁用', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
159
+ enabled: false, timeoutMs: 1000, nextRunAt: stale,
160
+ })
161
+ const jobLegal = await store.jobs.create({
162
+ name: '合法', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
163
+ enabled: true, timeoutMs: 1000, nextRunAt: legal,
164
+ })
165
+ const jobNull = await store.jobs.create({
166
+ name: '空值', kind: 'shell', command: 'x', schedule: '0 0 30 2 *',
167
+ enabled: true, timeoutMs: 1000, nextRunAt: null,
168
+ })
169
+ // When 重启恢复
170
+ await scheduler.recover()
171
+ // Then 残留值(启用与禁用)按当前 schedule 重算为合法触发点;合法值与空值不动
172
+ assert.equal(store.jobs.get(jobStale.id).nextRunAt, legal)
173
+ assert.equal(store.jobs.get(jobStaleDisabled.id).nextRunAt, legal)
174
+ assert.equal(store.jobs.get(jobLegal.id).nextRunAt, legal)
175
+ assert.equal(store.jobs.get(jobNull.id).nextRunAt, null)
176
+ })
177
+
178
+ test('scheduler:recover 自愈幂等——修复后的数据再次恢复不再改写', async (t) => {
179
+ const { store, scheduler } = await makeScheduler(t)
180
+ const legal = nextRunAtOf('55 9 * * 1-5')
181
+ const job = await store.jobs.create({
182
+ name: '残留', kind: 'shell', command: 'x', schedule: '55 9 * * 1-5',
183
+ enabled: true, timeoutMs: 1000, nextRunAt: nextRunAtOf('0 9 * * *'),
184
+ })
185
+ await scheduler.recover()
186
+ assert.equal(store.jobs.get(job.id).nextRunAt, legal)
187
+ // When 时钟推进后再次恢复
188
+ await new Promise((resolve) => setTimeout(resolve, 1100))
189
+ await scheduler.recover()
190
+ // Then 已合法的触发点不被重算推迟
191
+ assert.equal(store.jobs.get(job.id).nextRunAt, legal)
192
+ })
193
+
147
194
  test('scheduler:并发闸门——maxConcurrent=1 时第二个单元排队,首个完成后推进', async (t) => {
148
195
  // Given 两个任务同时到期,全局并发 1
149
196
  const { store, runner, scheduler, advance } = await makeScheduler(t, { maxConcurrent: 1 })
@@ -209,137 +256,6 @@ test('scheduler:status 报告活跃与队列深度', async (t) => {
209
256
  assert.deepEqual(scheduler.status(), { active: 0, queued: 0 })
210
257
  })
211
258
 
212
- test('scheduler:窗口内正常发起,窗口外 skip 记录且节奏不变', async (t) => {
213
- // Given 会话任务窗口 09:00-23:00,onMiss=skip,注入会话 runner 桩
214
- const dir = await mkdtemp(join(tmpdir(), 'cron-board-win-'))
215
- t.after(() => rm(dir, { recursive: true, force: true }))
216
- const store = await createStore({ dir })
217
- const logger = createLogger({ rootDir: join(dir, 'logs') })
218
- const sessionRuns = []
219
- const sessionRunner = { run: async () => { sessionRuns.push(1); return { status: 'success' } } }
220
- const executor = createExecutor({ store, logger, sessionRunner, readMaxConcurrent: () => 2 })
221
- // 虚拟时钟锚定今日 08:00(窗口外)
222
- const base = new Date(); base.setHours(8, 0, 0, 0)
223
- let nowMs = base.getTime()
224
- const scheduler = createScheduler({ store, executor, readTickMs: () => TICK_MS, now: () => nowMs })
225
- const job = await store.jobs.create({
226
- name: 'w', kind: 'session', prompt: '早报', schedule: '0 8 * * *', enabled: true,
227
- timeoutMs: 60 * 1000, session: { mode: 'fresh', windowStart: '09:00', windowEnd: '23:00', onMiss: 'skip' },
228
- })
229
- await store.jobs.update(job.id, { nextRunAt: nowMs })
230
- // When tick(触发点 08:00,窗口外)
231
- await scheduler.tick()
232
- // Then skipped(窗口外跳过),未发起,节奏正常推进(明日 08:00)
233
- const rows = store.runs.list(job.id)
234
- assert.equal(rows.length, 1)
235
- assert.equal(rows[0].status, 'skipped')
236
- assert.equal(rows[0].message, '窗口外跳过')
237
- assert.equal(sessionRuns.length, 0)
238
- const tomorrow8 = new Date(base); tomorrow8.setDate(tomorrow8.getDate() + 1)
239
- assert.equal(store.jobs.get(job.id).nextRunAt, tomorrow8.getTime())
240
- })
241
-
242
- test('scheduler:窗口外 defer 挂起至窗口起点后发起', async (t) => {
243
- const dir = await mkdtemp(join(tmpdir(), 'cron-board-defer-'))
244
- t.after(() => rm(dir, { recursive: true, force: true }))
245
- const store = await createStore({ dir })
246
- const logger = createLogger({ rootDir: join(dir, 'logs') })
247
- const sessionRuns = []
248
- const sessionRunner = { run: async () => { sessionRuns.push(1); return { status: 'success' } } }
249
- const executor = createExecutor({ store, logger, sessionRunner, readMaxConcurrent: () => 2 })
250
- // defer 定时桩:捕获 (at, fn),测试手动放行
251
- const deferred = []
252
- const base = new Date(); base.setHours(8, 0, 0, 0)
253
- let nowMs = base.getTime()
254
- const scheduler = createScheduler({
255
- store, executor, readTickMs: () => TICK_MS, now: () => nowMs,
256
- scheduleAt: (at, fn) => { deferred.push({ at, fn }) },
257
- })
258
- const job = await store.jobs.create({
259
- name: 'w', kind: 'session', prompt: '早报', schedule: '0 8 * * *', enabled: true,
260
- timeoutMs: 60 * 1000, session: { mode: 'fresh', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer' },
261
- })
262
- await store.jobs.update(job.id, { nextRunAt: nowMs })
263
- // When tick(窗口外 defer)
264
- await scheduler.tick()
265
- // Then 立即不发起,挂起点 = 今日 09:00
266
- assert.equal(sessionRuns.length, 0)
267
- assert.equal(deferred.length, 1)
268
- const at = new Date(deferred[0].at)
269
- assert.equal(at.getHours(), 9)
270
- assert.equal(at.getMinutes(), 0)
271
- // When 挂起到点放行(真实时钟推进到窗口起点,重读后窗口内才发起)
272
- nowMs = at.getTime()
273
- deferred[0].fn()
274
- await tickMillis(50)
275
- // Then 发起会话
276
- assert.equal(sessionRuns.length, 1)
277
- // 且 cron 节奏不变(明日 08:00)
278
- const tomorrow8 = new Date(base); tomorrow8.setDate(tomorrow8.getDate() + 1)
279
- assert.equal(store.jobs.get(job.id).nextRunAt, tomorrow8.getTime())
280
- })
281
-
282
- test('scheduler:窗口外 defer 到点重读任务——已禁用任务放弃发起(不吃旧快照)', async (t) => {
283
- const dir = await mkdtemp(join(tmpdir(), 'cron-board-defer2-'))
284
- t.after(() => rm(dir, { recursive: true, force: true }))
285
- const store = await createStore({ dir })
286
- const logger = createLogger({ rootDir: join(dir, 'logs') })
287
- const sessionRuns = []
288
- const sessionRunner = { run: async () => { sessionRuns.push(1); return { status: 'success' } } }
289
- const executor = createExecutor({ store, logger, sessionRunner, readMaxConcurrent: () => 2 })
290
- const deferred = []
291
- const base = new Date(); base.setHours(8, 0, 0, 0)
292
- let nowMs = base.getTime()
293
- const scheduler = createScheduler({
294
- store, executor, readTickMs: () => TICK_MS, now: () => nowMs,
295
- scheduleAt: (at, fn) => { deferred.push({ at, fn }); return () => {} },
296
- })
297
- const job = await store.jobs.create({
298
- name: 'w', kind: 'session', prompt: '早报', schedule: '0 8 * * *', enabled: true,
299
- timeoutMs: 60 * 1000, session: { mode: 'fresh', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer' },
300
- })
301
- await store.jobs.update(job.id, { nextRunAt: nowMs })
302
- await scheduler.tick()
303
- assert.equal(deferred.length, 1)
304
- // When 挂起期间任务被禁用,到点放行
305
- await store.jobs.update(job.id, { enabled: false })
306
- deferred[0].fn()
307
- await tickMillis(50)
308
- // Then 不发起
309
- assert.equal(sessionRuns.length, 0)
310
- })
311
-
312
- test('scheduler:dispose 清除未到点的 defer 挂起(生命周期外零触发)', async (t) => {
313
- const dir = await mkdtemp(join(tmpdir(), 'cron-board-defer3-'))
314
- t.after(() => rm(dir, { recursive: true, force: true }))
315
- const store = await createStore({ dir })
316
- const logger = createLogger({ rootDir: join(dir, 'logs') })
317
- const sessionRuns = []
318
- const sessionRunner = { run: async () => { sessionRuns.push(1); return { status: 'success' } } }
319
- const executor = createExecutor({ store, logger, sessionRunner, readMaxConcurrent: () => 2 })
320
- const deferred = []
321
- const cancels = []
322
- const base = new Date(); base.setHours(8, 0, 0, 0)
323
- let nowMs = base.getTime()
324
- const scheduler = createScheduler({
325
- store, executor, readTickMs: () => TICK_MS, now: () => nowMs,
326
- scheduleAt: (at, fn) => { deferred.push({ at, fn }); cancels.push(0); return () => { cancels[cancels.length - 1] = 1 } },
327
- })
328
- const job = await store.jobs.create({
329
- name: 'w', kind: 'session', prompt: '早报', schedule: '0 8 * * *', enabled: true,
330
- timeoutMs: 60 * 1000, session: { mode: 'fresh', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer' },
331
- })
332
- await store.jobs.update(job.id, { nextRunAt: nowMs })
333
- await scheduler.tick()
334
- // When dispose 后再放行挂起回调
335
- scheduler.dispose()
336
- assert.equal(cancels[0], 1)
337
- deferred[0].fn()
338
- await tickMillis(50)
339
- // Then 不发起
340
- assert.equal(sessionRuns.length, 0)
341
- })
342
-
343
259
  test('scheduler:tick 重入闸门——上一轮未完成时下一轮直接跳过(防同点双触发)', async (t) => {
344
260
  const { store, runner, scheduler, advance } = await makeScheduler(t)
345
261
  await store.jobs.create({
@@ -1,26 +1,33 @@
1
1
  // session-driver 测试:HostApiDriver 会话编排语义(设计 §4.3、§7 BDD 会话组)。
2
- // 桩替 sessionController/agents/sessionQuery,验证 fresh/pinned/忙跳过/丢失自愈/超时/完成判定。
2
+ // 桩替 sessionController/agents/sessionQuery/workspaceRegistry,验证 fresh/pinned/忙跳过/未绑定与丢失自愈/投递即终态/分组挂载。
3
3
 
4
4
  import { test } from 'node:test'
5
5
  import assert from 'node:assert/strict'
6
- import { setTimeout as delay } from 'node:timers/promises'
6
+ import { mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
7
9
 
8
10
  import { createSessionDriver } from '../src/session-driver.mjs'
9
11
 
10
- // 可控桩:会话注册表 + 控制器(autoIdle 模拟宿主投递后处理完成转 idle;超时场景关掉)
12
+ // 可控桩:会话注册表 + 控制器(autoIdle 模拟宿主投递后处理完成转 idle;投递即终态下仅影响忙判定)
11
13
  function makeStubs({ autoIdle = true } = {}) {
12
14
  const sessions = new Map()
13
15
  let nextId = 0
14
16
  const created = []
17
+ const createRequests = []
15
18
  const prompts = []
16
19
  const sessionController = {
17
- async create({ cwd } = {}) {
18
- const sessionId = 's-' + (++nextId)
20
+ async create(request) {
21
+ const sessionId = 'sess-' + (++nextId)
22
+ const cwd = request && request.cwd
19
23
  sessions.set(sessionId, { id: sessionId, cwd, status: 'running' })
20
24
  created.push({ sessionId, cwd })
25
+ createRequests.push(request)
21
26
  return { sessionId }
22
27
  },
23
- async prompt(args) {
28
+ // 镜像宿主 facade 契约(dsh-api-session-controller):prompt 准入首行非可选链校验 signal
29
+ async prompt(args, signal) {
30
+ signal.throwIfAborted()
24
31
  prompts.push(args)
25
32
  if (autoIdle) {
26
33
  queueMicrotask(() => {
@@ -35,24 +42,43 @@ function makeStubs({ autoIdle = true } = {}) {
35
42
  get: (id) => sessions.get(id),
36
43
  }
37
44
  const sessionQuery = {
38
- async listSessions() {
45
+ // 镜像宿主最严准入契约:守护 driver 总是传信号(真实宿主各版本为可选链,将来收紧不崩)
46
+ async listSessions(signal) {
47
+ signal.throwIfAborted()
39
48
  return [...sessions.values()].map((entry) => ({ id: entry.id }))
40
49
  },
41
50
  }
42
- return { sessions, created, prompts, sessionController, agents, sessionQuery }
51
+ return { sessions, created, createRequests, prompts, sessionController, agents, sessionQuery }
43
52
  }
44
53
 
45
- function makeDriver(stubs, overrides = {}) {
54
+ // workspaceRegistry 桩:resolveByPath 按规范化 path 命中,createCanonical 建新组
55
+ function makeRegistry({ paths = {}, archived = [] } = {}) {
56
+ const workspaces = new Map(Object.entries(paths))
57
+ let nextId = 0
58
+ return {
59
+ archivedSessionIds: archived,
60
+ async resolveByPath(path) {
61
+ const id = workspaces.get(path)
62
+ return id ? { id, path } : undefined
63
+ },
64
+ async createCanonical(canonical) {
65
+ const id = 'w-' + (++nextId)
66
+ workspaces.set(canonical, id)
67
+ return { id, path: canonical }
68
+ },
69
+ }
70
+ }
71
+
72
+ function makeDriver(stubs) {
46
73
  return createSessionDriver({
47
74
  getSessionController: () => stubs.sessionController,
75
+ getWorkspaceRegistry: stubs.getWorkspaceRegistry,
48
76
  agents: stubs.agents,
49
77
  sessionQuery: stubs.sessionQuery,
50
- pollIntervalMs: 5,
51
- ...overrides,
52
78
  })
53
79
  }
54
80
 
55
- const BASE_JOB = { id: 'j1', kind: 'session', prompt: '跑日报', session: { mode: 'fresh' }, timeoutMs: 60 * 1000 }
81
+ const BASE_JOB = { id: 'j1', kind: 'session', prompt: '跑日报', session: { mode: 'fresh' } }
56
82
  const PINNED_S1 = { ...BASE_JOB, session: { mode: 'pinned', pinnedSessionId: 's-1' } }
57
83
  const PINNED_GONE = { ...BASE_JOB, session: { mode: 'pinned', pinnedSessionId: 'gone' } }
58
84
 
@@ -103,7 +129,7 @@ test('session-driver:pinned 会话丢失自愈:新建并回写', async () => {
103
129
  const outcome = await driver.run({ job: PINNED_GONE, env: {} })
104
130
  // Then 新建会话顶替,message 含「原会话丢失已重建」
105
131
  assert.equal(outcome.status, 'success')
106
- assert.equal(outcome.pinnedNewId, 's-1')
132
+ assert.equal(outcome.pinnedNewId, outcome.sessionId)
107
133
  assert.ok(String(outcome.message).includes('原会话丢失已重建'))
108
134
  assert.equal(stubs.created.length, 1)
109
135
  })
@@ -127,26 +153,14 @@ test('session-driver:发起成功即写 RunRecord.sessionId', async () => {
127
153
  assert.ok(updates.some((patch) => typeof patch.sessionId === 'string' && patch.sessionId !== ''))
128
154
  })
129
155
 
130
- test('session-driver:会话 idle 即完成(success)', async () => {
131
- const stubs = makeStubs()
156
+ test('session-driver:投递即终态,会话持续 running 也不等待', async () => {
157
+ // 会话持续 running(autoIdle 关):投递成功立即返回,不等执行
158
+ const stubs = makeStubs({ autoIdle: false })
132
159
  const driver = makeDriver(stubs)
133
- // When 发起后把会话置 idle(模拟完成)
134
- const pending = driver.run({ job: BASE_JOB, env: {} })
135
- await delay(30)
136
- const sessionId = stubs.created[0].sessionId
137
- stubs.sessions.get(sessionId).status = 'idle'
138
- const outcome = await pending
139
- // Then success 且 RunRecord.sessionId 已写
160
+ const outcome = await driver.run({ job: BASE_JOB, env: {} })
140
161
  assert.equal(outcome.status, 'success')
141
- assert.equal(outcome.sessionId, sessionId)
142
- })
143
-
144
- test('session-driver:timeoutMs 到点记 timeout', async () => {
145
- // 会话持续 running(autoIdle 关),轮询到超时
146
- const stubs = makeStubs({ autoIdle: false })
147
- const driver = makeDriver(stubs, { pollIntervalMs: 5 })
148
- const outcome = await driver.run({ job: { ...BASE_JOB, timeoutMs: 40 }, env: {} })
149
- assert.equal(outcome.status, 'timeout')
162
+ assert.equal(outcome.message, '已投递会话')
163
+ assert.equal(stubs.prompts.length, 1)
150
164
  })
151
165
 
152
166
  test('session-driver:prompt 携带变量折叠文本', async () => {
@@ -159,3 +173,125 @@ test('session-driver:prompt 携带变量折叠文本', async () => {
159
173
  assert.ok(text.includes('跑日报'))
160
174
  assert.ok(text.includes('CITY=hangzhou'))
161
175
  })
176
+
177
+ test('session-driver:prompt 必须携带非中止 AbortSignal(宿主 facade 准入契约)', async () => {
178
+ const seen = []
179
+ const stubs = makeStubs()
180
+ stubs.sessionController.prompt = (args, signal) => {
181
+ seen.push(signal)
182
+ queueMicrotask(() => {
183
+ const entry = stubs.sessions.get(args.sessionId)
184
+ if (entry) entry.status = 'idle'
185
+ })
186
+ return Promise.resolve({ accepted: true })
187
+ }
188
+ const driver = makeDriver(stubs)
189
+ // When 正常发起
190
+ const outcome = await driver.run({ job: BASE_JOB, env: {} })
191
+ // Then 传入 AbortSignal 且非中止(宿主首行 throwIfAborted 可通过)
192
+ assert.equal(outcome.status, 'success')
193
+ assert.equal(seen.length, 1)
194
+ assert.ok(seen[0] instanceof AbortSignal)
195
+ assert.equal(seen[0].aborted, false)
196
+ })
197
+
198
+ test('session-driver:pinned 首跑未绑定报「已创建并绑定」而非丢失', async () => {
199
+ const stubs = makeStubs()
200
+ const driver = makeDriver(stubs)
201
+ // When pinned 任务尚未绑定会话(pinnedSessionId 为空)
202
+ const job = { ...BASE_JOB, session: { mode: 'pinned', pinnedSessionId: '' } }
203
+ const outcome = await driver.run({ job, env: {} })
204
+ // Then 文案区分:首次绑定,非丢失重建;pinnedNewId 回写供 executor 落库
205
+ assert.equal(outcome.status, 'success')
206
+ assert.equal(outcome.message, '首次运行已创建并绑定会话')
207
+ assert.equal(outcome.pinnedNewId, outcome.sessionId)
208
+ })
209
+
210
+ test('session-driver:pinned 会话已归档视为丢失,重建并回写', async () => {
211
+ const stubs = makeStubs()
212
+ stubs.sessions.set('s-1', { id: 's-1', status: 'idle' })
213
+ stubs.getWorkspaceRegistry = () => makeRegistry({ archived: ['s-1'] })
214
+ const driver = makeDriver(stubs)
215
+ // When pinned 指向的会话存在于持久层但已归档
216
+ const outcome = await driver.run({ job: PINNED_S1, env: {} })
217
+ // Then 归档视同丢失:新建会话 + 丢失文案 + pinnedNewId 回写
218
+ assert.equal(outcome.status, 'success')
219
+ assert.notEqual(outcome.sessionId, 's-1')
220
+ assert.equal(outcome.message, '原会话丢失已重建')
221
+ assert.equal(outcome.pinnedNewId, outcome.sessionId)
222
+ assert.equal(stubs.created.length, 1)
223
+ })
224
+
225
+ test('session-driver:pinned 会话已删除视为丢失(既有语义守护)', async () => {
226
+ const stubs = makeStubs()
227
+ stubs.getWorkspaceRegistry = () => makeRegistry()
228
+ const driver = makeDriver(stubs)
229
+ const outcome = await driver.run({ job: PINNED_GONE, env: {} })
230
+ assert.equal(outcome.status, 'success')
231
+ assert.equal(outcome.message, '原会话丢失已重建')
232
+ })
233
+
234
+ test('session-driver:workdir 命中已有分组时以 workspaceId 建会话(dsh-im 同构)', async () => {
235
+ const stubs = makeStubs()
236
+ stubs.getWorkspaceRegistry = () => makeRegistry({ paths: { 'C:\\work\\demo': 'w-1' } })
237
+ const driver = makeDriver(stubs)
238
+ // When 任务 workdir 与已有分组 path 一致
239
+ const outcome = await driver.run({ job: { ...BASE_JOB, workdir: 'C:\\work\\demo' }, env: {} })
240
+ // Then create 收到 workspaceId 而非 cwd
241
+ assert.equal(outcome.status, 'success')
242
+ assert.deepEqual(stubs.createRequests[0], { workspaceId: 'w-1' })
243
+ })
244
+
245
+ test('session-driver:agentPreset 非空时并入 create 请求', async () => {
246
+ const stubs = makeStubs()
247
+ const driver = makeDriver(stubs)
248
+ // When 任务配置了执行预设
249
+ await driver.run({ job: { ...BASE_JOB, session: { mode: 'fresh', agentPreset: 'coder' } }, env: {} })
250
+ // Then create 请求携带 agentPreset
251
+ assert.deepEqual(stubs.createRequests[0], { agentPreset: 'coder' })
252
+ })
253
+
254
+ test('session-driver:agentPreset 缺省时请求不带该字段(跟随宿主默认)', async () => {
255
+ const stubs = makeStubs()
256
+ const driver = makeDriver(stubs)
257
+ // When 任务未配置执行预设
258
+ await driver.run({ job: BASE_JOB, env: {} })
259
+ // Then create 请求无 agentPreset 键(空载时请求体整体缺省)
260
+ const request = stubs.createRequests[0] || {}
261
+ assert.ok(!('agentPreset' in request))
262
+ })
263
+
264
+ test('session-driver:pinned 复用已有会话不触发 create(preset 不改写)', async () => {
265
+ const stubs = makeStubs()
266
+ stubs.sessions.set('s-1', { id: 's-1', status: 'idle' })
267
+ const driver = makeDriver(stubs)
268
+ // When pinned 命中可用会话且配置了执行预设
269
+ await driver.run({ job: { ...PINNED_S1, session: { mode: 'pinned', pinnedSessionId: 's-1', agentPreset: 'coder' } }, env: {} })
270
+ // Then preset 是 create 语义,复用路径零 create
271
+ assert.equal(stubs.createRequests.length, 0)
272
+ assert.equal(stubs.prompts[0].sessionId, 's-1')
273
+ })
274
+
275
+ test('session-driver:workdir 未命中分组时建组再挂载', async (t) => {
276
+ // createCanonical 前有 realpath 校验,须用真实存在的目录
277
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-ws-'))
278
+ t.after(() => rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
279
+ const stubs = makeStubs()
280
+ stubs.getWorkspaceRegistry = () => makeRegistry()
281
+ const driver = makeDriver(stubs)
282
+ // When 任务 workdir 无对应分组
283
+ const outcome = await driver.run({ job: { ...BASE_JOB, workdir: dir }, env: {} })
284
+ // Then create 收到新建分组的 workspaceId
285
+ assert.equal(outcome.status, 'success')
286
+ assert.deepEqual(stubs.createRequests[0], { workspaceId: 'w-1' })
287
+ })
288
+
289
+ test('session-driver:registry 缺失时降级 cwd 建会话', async () => {
290
+ const stubs = makeStubs()
291
+ const driver = makeDriver(stubs)
292
+ // When 未注入 workspaceRegistry
293
+ const outcome = await driver.run({ job: { ...BASE_JOB, workdir: 'C:\\work\\x' }, env: {} })
294
+ // Then 回落 cwd 形态(旧行为),不影响可用性
295
+ assert.equal(outcome.status, 'success')
296
+ assert.deepEqual(stubs.createRequests[0], { cwd: 'C:\\work\\x' })
297
+ })
@@ -1,37 +0,0 @@
1
- // session-window:会话任务时间窗口判定(设计 §4.3)。start>=end 视为跨零点(如 22:00-06:00)。
2
-
3
- const MINUTES_PER_DAY = 24 * 60
4
-
5
- function parseMinutes(hhmm) {
6
- const [hours, minutes] = String(hhmm).split(':').map((part) => parseInt(part, 10))
7
- return hours * 60 + minutes
8
- }
9
-
10
- function minutesOfDay(date) {
11
- return date.getHours() * 60 + date.getMinutes()
12
- }
13
-
14
- // 触发时刻是否落在窗口内;start>=end 即跨零点区间。session = job.session 子对象
15
- export function inWindow(session, date) {
16
- const start = parseMinutes(session.windowStart)
17
- const end = parseMinutes(session.windowEnd)
18
- const nowMinutes = minutesOfDay(date)
19
- if (start <= end) return nowMinutes >= start && nowMinutes < end
20
- return nowMinutes >= start || nowMinutes < end
21
- }
22
-
23
- // 最近的窗口起点时刻(defer 挂起目标):起点未过用今日,已过用明日
24
- export function nextWindowStart(session, date) {
25
- const start = parseMinutes(session.windowStart)
26
- const at = new Date(date)
27
- at.setSeconds(0, 0)
28
- at.setHours(Math.floor(start / 60), start % 60)
29
- if (at.getTime() <= date.getTime()) at.setDate(at.getDate() + 1)
30
- return at
31
- }
32
-
33
- export function hasWindow(session) {
34
- return Boolean(session && session.windowStart && session.windowEnd)
35
- }
36
-
37
- export { MINUTES_PER_DAY }