@v587d/capital-generation 2.12.0 → 2.13.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.
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Capital-owned scheduler. One process per profile wins the filesystem lease; the tick reads
3
+ * configuration, computes the latest due occurrence, claims it, and hands one fresh session to
4
+ * the runner. It never waits for a model turn and never reads market data.
5
+ */
6
+ import { DEFAULT_CATCH_UP_MS, DEFAULT_OWNER_LEASE_MS } from './defaults.js'
7
+ import { createOwnerLease } from './owner.js'
8
+ import { sanitizeMessage } from './redact.js'
9
+ import { formatInZone, occurrenceKeyOf, previousOccurrenceAtOrBefore, scheduleVersionOf } from './time.js'
10
+
11
+ function iso(now) {
12
+ return new Date(now).toISOString()
13
+ }
14
+
15
+ export function createScheduler(options) {
16
+ const {
17
+ store,
18
+ runner,
19
+ owner,
20
+ calendar,
21
+ profile,
22
+ getSettings,
23
+ now = () => Date.now(),
24
+ logger,
25
+ random,
26
+ } = options
27
+ const ownerId = owner?.ownerId ?? `owner-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
28
+ const lease = options.lease ?? owner?.lease ?? createOwnerLease({
29
+ directory: profile.ownerDir,
30
+ profileKey: store.profileKey,
31
+ ownerId,
32
+ leaseMs: DEFAULT_OWNER_LEASE_MS,
33
+ now,
34
+ random,
35
+ })
36
+ const state = {
37
+ state: 'starting',
38
+ ownerId,
39
+ fencingToken: undefined,
40
+ leaseExpiresAt: null,
41
+ heartbeatAt: null,
42
+ storageError: null,
43
+ recoveredRuns: 0,
44
+ lastTickAt: null,
45
+ lastError: null,
46
+ }
47
+ let timer
48
+ let heartbeatTimer
49
+ let currentTickMs
50
+ let stopped = false
51
+ let tickInFlight
52
+ let stopPromise
53
+ let manualAdmission = Promise.resolve()
54
+ let recovered = false
55
+ let lastStandbyNoticeAt = 0
56
+
57
+ function settings() {
58
+ return getSettings()
59
+ }
60
+
61
+ async function ownerIsCurrent() {
62
+ if (lease.held !== true) return false
63
+ if (typeof lease.isCurrent === 'function') {
64
+ try {
65
+ return await lease.isCurrent()
66
+ } catch {
67
+ return false
68
+ }
69
+ }
70
+ return lease.fencingToken !== undefined
71
+ }
72
+
73
+ async function requireCurrentOwner() {
74
+ if (await ownerIsCurrent()) return true
75
+ await standby('调度租约已失效:本进程不再投递新运行。')
76
+ return false
77
+ }
78
+
79
+ async function publishOwner() {
80
+ const at = now()
81
+ const record = {
82
+ schemaVersion: 1,
83
+ profileKey: store.profileKey,
84
+ ownerId,
85
+ fencingToken: lease.fencingToken ?? ownerId,
86
+ leaseExpiresAt: iso(at + (settings().ownerLeaseMs ?? DEFAULT_OWNER_LEASE_MS)),
87
+ heartbeatAt: iso(at),
88
+ schedulerState: state.state === 'active' ? 'active' : 'standby',
89
+ updatedAt: iso(at),
90
+ }
91
+ state.fencingToken = record.fencingToken
92
+ state.leaseExpiresAt = record.leaseExpiresAt
93
+ state.heartbeatAt = record.heartbeatAt
94
+ await store.setOwnerMeta(record).catch((error) => {
95
+ state.storageError = String(error)
96
+ })
97
+ }
98
+
99
+ async function standby(message) {
100
+ if (state.state !== 'standby') logger?.warn?.(`capital-automation: ${message}`)
101
+ state.state = 'standby'
102
+ state.leaseExpiresAt = null
103
+ const at = now()
104
+ if (at - lastStandbyNoticeAt < 10 * 60_000) return
105
+ lastStandbyNoticeAt = at
106
+ if (settings().inAppNotifications !== true) return
107
+ await store.addNotification({
108
+ kind: 'scheduler_standby',
109
+ title: '调度器未取得运行权',
110
+ message: message.slice(0, 500),
111
+ }).catch(() => {})
112
+ }
113
+
114
+ async function tryAcquire() {
115
+ if (lease.held) {
116
+ const renewed = await lease.renew()
117
+ if (!renewed) {
118
+ await standby('调度租约已失效:本进程不再投递新运行。')
119
+ return false
120
+ }
121
+ } else {
122
+ const acquired = await lease.acquire()
123
+ if (!acquired) {
124
+ await standby('另一个进程持有当前 profile 的调度租约;本页面只读,不投递运行。')
125
+ return false
126
+ }
127
+ }
128
+ if (!(await ownerIsCurrent())) {
129
+ await standby('调度租约未通过当前 owner/token 校验,本进程不再投递新运行。')
130
+ return false
131
+ }
132
+ state.state = 'active'
133
+ startHeartbeat()
134
+ await publishOwner()
135
+ if (!(await ownerIsCurrent())) {
136
+ await standby('调度租约在状态发布期间已被接管,本进程不再投递新运行。')
137
+ return false
138
+ }
139
+ if (!recovered) {
140
+ recovered = true
141
+ try {
142
+ state.recoveredRuns = await store.recoverNonTerminalRuns()
143
+ if (state.recoveredRuns > 0) logger?.warn?.(`capital-automation: ${state.recoveredRuns} 个上次进程的运行已标记 unknown_after_restart`)
144
+ } catch (error) {
145
+ state.lastError = String(error)
146
+ }
147
+ }
148
+ return true
149
+ }
150
+
151
+ async function createSkipped(task, occurrenceKey, status, scheduledFor, error) {
152
+ const run = await store.createRun({
153
+ taskId: task.id,
154
+ taskTitle: task.title,
155
+ trigger: 'scheduled',
156
+ occurrenceKey,
157
+ ...(scheduledFor === undefined ? {} : { scheduledFor }),
158
+ status,
159
+ })
160
+ await store.updateRun(run.id, {
161
+ status,
162
+ finishedAt: iso(now()),
163
+ durationMs: 0,
164
+ ...(error === undefined ? {} : { errors: [error] }),
165
+ }).catch(() => {})
166
+ await store.setLastHandled(task.id, occurrenceKey, scheduledFor)
167
+ if (status === 'skipped_calendar_unavailable' && settings().inAppNotifications === true && task.notifications.inApp === true) {
168
+ await store.addNotification({
169
+ kind: 'calendar_unavailable',
170
+ title: `交易日历不可用:${task.title}`.slice(0, 120),
171
+ message: '任务已按 fail-closed 跳过;请更新交易日历表后再启用。',
172
+ runId: run.id,
173
+ taskId: task.id,
174
+ }).catch(() => {})
175
+ }
176
+ return run
177
+ }
178
+
179
+ async function dueForTask(task) {
180
+ if (!(await requireCurrentOwner())) return
181
+ const latest = previousOccurrenceAtOrBefore(task.trigger, now(), { calendar })
182
+ if (latest.kind === 'none') return
183
+ if (latest.kind === 'calendar_unavailable') {
184
+ const key = `${store.profileKey}:calendar:${latest.localDate}`
185
+ if (task.lastHandledOccurrenceKey === key) return
186
+ await createSkipped(task, key, 'skipped_calendar_unavailable', undefined, {
187
+ at: iso(now()),
188
+ phase: 'schedule',
189
+ severity: 'warning',
190
+ code: 'calendar_unavailable',
191
+ message: `交易日历不可用(${latest.localDate}):按 fail-closed 跳过,不按周一至周五猜测运行。`,
192
+ })
193
+ return
194
+ }
195
+ const key = occurrenceKeyOf(store.profileKey, task.id, latest.scheduleVersion, latest.scheduledFor)
196
+ if (task.lastHandledOccurrenceKey === key) return
197
+ // A clock rollback must not re-run a claimed instant: compare the high-water mark, not just keys.
198
+ if (task.lastScheduledFor !== undefined && latest.instant <= Date.parse(task.lastScheduledFor)) return
199
+ const anchor = Date.parse(task.scheduleAnchorAt)
200
+ if (Number.isFinite(anchor) && latest.instant < anchor) {
201
+ await store.setLastHandled(task.id, key, latest.scheduledFor)
202
+ return
203
+ }
204
+ const catchUpMs = settings().catchUpMs ?? DEFAULT_CATCH_UP_MS
205
+ if (now() - latest.instant > catchUpMs) {
206
+ await createSkipped(task, key, 'missed', latest.scheduledFor, {
207
+ at: iso(now()),
208
+ phase: 'schedule',
209
+ severity: 'warning',
210
+ code: 'missed_occurrence',
211
+ message: '进程未在计划时刻运行;按设计不补跑,可在页面手动发起一次新运行。',
212
+ })
213
+ return
214
+ }
215
+ if (await store.activeRunForTask(task.id) !== undefined) {
216
+ await createSkipped(task, key, 'skipped_overlap', latest.scheduledFor)
217
+ return
218
+ }
219
+ if (runner.canDispatch?.() === false) {
220
+ await createSkipped(task, key, 'skipped_overlap', latest.scheduledFor)
221
+ return
222
+ }
223
+ if (await store.hasAttempt(key)) {
224
+ await store.setLastHandled(task.id, key, latest.scheduledFor)
225
+ return
226
+ }
227
+ const run = await store.createRun({
228
+ taskId: task.id,
229
+ taskTitle: task.title,
230
+ trigger: 'scheduled',
231
+ occurrenceKey: key,
232
+ scheduleVersion: latest.scheduleVersion,
233
+ scheduledFor: latest.scheduledFor,
234
+ status: 'queued',
235
+ })
236
+ if (!(await requireCurrentOwner())) {
237
+ await store.updateRun(run.id, {
238
+ status: 'unknown_after_restart',
239
+ finishedAt: iso(now()),
240
+ durationMs: 0,
241
+ errors: [{
242
+ at: iso(now()),
243
+ phase: 'schedule',
244
+ severity: 'warning',
245
+ code: 'owner_lost_before_dispatch',
246
+ message: '调度租约在投递前失效;本次运行未自动重试。',
247
+ }],
248
+ }).catch(() => {})
249
+ await store.setLastHandled(task.id, key, latest.scheduledFor).catch(() => {})
250
+ return
251
+ }
252
+ const claimed = await store.claimOccurrence({ taskId: task.id, runId: run.id, occurrenceKey: key })
253
+ if (!claimed) {
254
+ await store.updateRun(run.id, { status: 'skipped_overlap', finishedAt: iso(now()), durationMs: 0 })
255
+ await store.setLastHandled(task.id, key, latest.scheduledFor)
256
+ return
257
+ }
258
+ if (!(await requireCurrentOwner())) {
259
+ await store.setAttemptState(key, 'unknown').catch(() => {})
260
+ await store.updateRun(run.id, {
261
+ status: 'unknown_after_restart',
262
+ finishedAt: iso(now()),
263
+ durationMs: 0,
264
+ errors: [{
265
+ at: iso(now()),
266
+ phase: 'schedule',
267
+ severity: 'warning',
268
+ code: 'owner_lost_before_dispatch',
269
+ message: '调度租约在投递前失效;本次运行未自动重试。',
270
+ }],
271
+ }).catch(() => {})
272
+ return
273
+ }
274
+ await store.setLastHandled(task.id, key, latest.scheduledFor)
275
+ if (runner.canDispatch?.() === false) {
276
+ await store.setAttemptState(key, 'unknown').catch(() => {})
277
+ await store.updateRun(run.id, {
278
+ status: 'skipped_overlap',
279
+ finishedAt: iso(now()),
280
+ durationMs: Math.max(0, now() - Date.parse(run.queuedAt)),
281
+ }).catch(() => {})
282
+ return
283
+ }
284
+ void Promise.resolve().then(() => runner.dispatch(run, task, 'scheduled')).catch((error) => {
285
+ logger?.warn?.(`capital-automation: scheduled dispatch failed: ${String(error)}`)
286
+ })
287
+ }
288
+
289
+ async function tick() {
290
+ if (stopped) return
291
+ if (tickInFlight !== undefined) return tickInFlight
292
+ tickInFlight = (async () => {
293
+ try {
294
+ if (!(await tryAcquire())) return
295
+ if (timer !== undefined && currentTickMs !== Math.max(30_000, settings().tickMs)) armTick()
296
+ state.lastTickAt = iso(now())
297
+ const tasks = await store.listTasks()
298
+ for (const task of tasks) {
299
+ if (stopped) return
300
+ if (task.state !== 'active') continue
301
+ await dueForTask(task)
302
+ }
303
+ } catch (error) {
304
+ state.lastError = String(error)
305
+ logger?.warn?.(`capital-automation: tick 失败:${String(error)}`)
306
+ } finally {
307
+ tickInFlight = undefined
308
+ }
309
+ })()
310
+ return tickInFlight
311
+ }
312
+
313
+ function armTick() {
314
+ if (timer !== undefined) clearInterval(timer)
315
+ currentTickMs = Math.max(30_000, settings().tickMs)
316
+ timer = setInterval(() => { void tick() }, currentTickMs)
317
+ timer.unref?.()
318
+ }
319
+
320
+ async function start() {
321
+ if (stopped) return
322
+ await tick()
323
+ if (stopped) return
324
+ armTick()
325
+ startHeartbeat()
326
+ }
327
+
328
+ /**
329
+ * The tick may be delayed by a long model-independent storage write; renew the lease on its
330
+ * own clock so another process cannot legitimately take over between ticks.
331
+ */
332
+ function startHeartbeat() {
333
+ if (heartbeatTimer !== undefined || stopped) return
334
+ const heartbeatMs = Math.max(15_000, Math.min(settings().tickMs, DEFAULT_OWNER_LEASE_MS / 3))
335
+ heartbeatTimer = setInterval(() => {
336
+ if (stopped || state.state !== 'active') return
337
+ void lease.renew().then(async (renewed) => {
338
+ if (renewed && await ownerIsCurrent()) {
339
+ await publishOwner()
340
+ return
341
+ }
342
+ await standby('调度租约已失效:本进程不再投递新运行。')
343
+ }).catch(() => {})
344
+ }, heartbeatMs)
345
+ heartbeatTimer.unref?.()
346
+ }
347
+
348
+ async function stopInternal() {
349
+ stopped = true
350
+ if (timer !== undefined) clearInterval(timer)
351
+ if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer)
352
+ timer = undefined
353
+ heartbeatTimer = undefined
354
+ currentTickMs = undefined
355
+ await manualAdmission.catch(() => {})
356
+ await tickInFlight?.catch(() => {})
357
+ if (lease.held && await ownerIsCurrent()) {
358
+ await store.setOwnerMeta({
359
+ schemaVersion: 1,
360
+ profileKey: store.profileKey,
361
+ ownerId,
362
+ fencingToken: lease.fencingToken ?? ownerId,
363
+ leaseExpiresAt: iso(now()),
364
+ heartbeatAt: iso(now()),
365
+ schedulerState: 'standby',
366
+ updatedAt: iso(now()),
367
+ }).catch(() => {})
368
+ }
369
+ await lease.release().catch(() => {})
370
+ state.state = 'stopped'
371
+ }
372
+
373
+ function stop() {
374
+ if (stopPromise !== undefined) return stopPromise
375
+ stopPromise = stopInternal()
376
+ return stopPromise
377
+ }
378
+
379
+ /** Manual runs require the same owner as scheduled ones; standby never bypasses it. */
380
+ async function runNowInternal(task) {
381
+ if (stopped) return { ok: false, code: 'scheduler_unavailable', message: '调度器已停止。' }
382
+ if (state.state !== 'active') {
383
+ const acquired = await tryAcquire()
384
+ if (!acquired || state.state !== 'active') {
385
+ return { ok: false, code: 'scheduler_unavailable', message: '调度器未取得运行权,无法手动运行。' }
386
+ }
387
+ }
388
+ if (!(await requireCurrentOwner())) {
389
+ return { ok: false, code: 'scheduler_unavailable', message: '调度器未取得当前运行权,无法手动运行。' }
390
+ }
391
+ const active = await store.activeRunForTask(task.id)
392
+ if (active !== undefined) {
393
+ return { ok: false, code: 'run_in_progress', message: '该任务仍在运行,请先取消或等待终态。', runId: active.id }
394
+ }
395
+ if (runner.canDispatch?.() === false) {
396
+ return { ok: false, code: 'run_capacity', message: '当前 profile 已达到自动化并发上限,请等待已有运行结束。' }
397
+ }
398
+ const runId = `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
399
+ const run = await store.createRun({
400
+ id: runId,
401
+ taskId: task.id,
402
+ taskTitle: task.title,
403
+ trigger: 'manual',
404
+ occurrenceKey: `${store.profileKey}:manual:${runId}`,
405
+ status: 'queued',
406
+ })
407
+ if (!(await requireCurrentOwner())) {
408
+ await store.updateRun(run.id, {
409
+ status: 'unknown_after_restart',
410
+ finishedAt: iso(now()),
411
+ durationMs: 0,
412
+ errors: [{
413
+ at: iso(now()),
414
+ phase: 'schedule',
415
+ severity: 'warning',
416
+ code: 'owner_lost_before_dispatch',
417
+ message: '调度租约在投递前失效;本次运行未自动重试。',
418
+ }],
419
+ }).catch(() => {})
420
+ return { ok: false, code: 'scheduler_unavailable', message: '调度器未取得当前运行权,无法手动运行。', runId: run.id }
421
+ }
422
+ const claimed = await store.claimOccurrence({ taskId: task.id, runId: run.id, occurrenceKey: run.occurrenceKey })
423
+ if (!claimed) {
424
+ await store.updateRun(run.id, { status: 'skipped_overlap', finishedAt: iso(now()), durationMs: 0 }).catch(() => {})
425
+ return { ok: false, code: 'duplicate_run', message: '这次手动运行已经被登记。', runId: run.id }
426
+ }
427
+ if (!(await requireCurrentOwner())) {
428
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
429
+ await store.updateRun(run.id, {
430
+ status: 'unknown_after_restart',
431
+ finishedAt: iso(now()),
432
+ durationMs: 0,
433
+ errors: [{
434
+ at: iso(now()),
435
+ phase: 'schedule',
436
+ severity: 'warning',
437
+ code: 'owner_lost_before_dispatch',
438
+ message: '调度租约在投递前失效;本次运行未自动重试。',
439
+ }],
440
+ }).catch(() => {})
441
+ return { ok: false, code: 'scheduler_unavailable', message: '调度器未取得当前运行权,无法手动运行。', runId: run.id }
442
+ }
443
+ if (runner.canDispatch?.() === false) {
444
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
445
+ await store.updateRun(run.id, {
446
+ status: 'skipped_overlap',
447
+ finishedAt: iso(now()),
448
+ durationMs: Math.max(0, now() - Date.parse(run.queuedAt)),
449
+ }).catch(() => {})
450
+ return { ok: false, code: 'run_capacity', message: '当前 profile 已达到自动化并发上限,请等待已有运行结束。', runId: run.id }
451
+ }
452
+ void Promise.resolve().then(() => runner.dispatch(run, task, 'manual')).catch((error) => {
453
+ logger?.warn?.(`capital-automation: manual dispatch failed: ${String(error)}`)
454
+ })
455
+ return { ok: true, run }
456
+ }
457
+
458
+ function runNow(task) {
459
+ const next = manualAdmission.then(() => runNowInternal(task), () => runNowInternal(task))
460
+ manualAdmission = next.then(() => {}, () => {})
461
+ return next
462
+ }
463
+
464
+ function status() {
465
+ return {
466
+ state: state.state,
467
+ profileKey: store.profileKey,
468
+ leaseExpiresAt: state.leaseExpiresAt,
469
+ heartbeatAt: state.heartbeatAt,
470
+ held: lease.held,
471
+ tickMs: settings().tickMs,
472
+ recoveredRuns: state.recoveredRuns,
473
+ lastTickAt: state.lastTickAt,
474
+ storageError: state.storageError === null ? null : sanitizeMessage(state.storageError),
475
+ lastError: state.lastError === null ? null : sanitizeMessage(state.lastError),
476
+ calendar: calendar?.status?.(new Date(now())) ?? { available: false, source: 'none' },
477
+ }
478
+ }
479
+
480
+ return {
481
+ start,
482
+ stop,
483
+ tick,
484
+ runNow,
485
+ status,
486
+ ownerId,
487
+ lease,
488
+ /** Next occurrence for display; the scheduler itself works from the previous occurrence. */
489
+ displayNext(task) {
490
+ return previousOccurrenceAtOrBefore(task.trigger, now(), { calendar })
491
+ },
492
+ formatInZone,
493
+ scheduleVersionOf,
494
+ }
495
+ }