@v587d/capital-generation 2.12.0 → 2.13.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,513 @@
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
+ // A refresh already in flight can still answer today; burning the occurrence on a
185
+ // transient unknown would turn a slow fetch into a skipped run.
186
+ if (calendar?.pendingRefresh?.() === true) return
187
+ const key = `${store.profileKey}:calendar:${latest.localDate}`
188
+ if (task.lastHandledOccurrenceKey === key) return
189
+ await createSkipped(task, key, 'skipped_calendar_unavailable', undefined, {
190
+ at: iso(now()),
191
+ phase: 'schedule',
192
+ severity: 'warning',
193
+ code: 'calendar_unavailable',
194
+ message: `交易日历不可用(${latest.localDate}):按 fail-closed 跳过,不按周一至周五猜测运行。`,
195
+ })
196
+ return
197
+ }
198
+ const key = occurrenceKeyOf(store.profileKey, task.id, latest.scheduleVersion, latest.scheduledFor)
199
+ if (task.lastHandledOccurrenceKey === key) return
200
+ // A clock rollback must not re-run a claimed instant: compare the high-water mark, not just keys.
201
+ if (task.lastScheduledFor !== undefined && latest.instant <= Date.parse(task.lastScheduledFor)) return
202
+ const anchor = Date.parse(task.scheduleAnchorAt)
203
+ if (Number.isFinite(anchor) && latest.instant < anchor) {
204
+ await store.setLastHandled(task.id, key, latest.scheduledFor)
205
+ return
206
+ }
207
+ const catchUpMs = settings().catchUpMs ?? DEFAULT_CATCH_UP_MS
208
+ if (now() - latest.instant > catchUpMs) {
209
+ await createSkipped(task, key, 'missed', latest.scheduledFor, {
210
+ at: iso(now()),
211
+ phase: 'schedule',
212
+ severity: 'warning',
213
+ code: 'missed_occurrence',
214
+ message: '进程未在计划时刻运行;按设计不补跑,可在页面手动发起一次新运行。',
215
+ })
216
+ return
217
+ }
218
+ if (await store.activeRunForTask(task.id) !== undefined) {
219
+ await createSkipped(task, key, 'skipped_overlap', latest.scheduledFor)
220
+ return
221
+ }
222
+ if (runner.canDispatch?.() === false) {
223
+ await createSkipped(task, key, 'skipped_overlap', latest.scheduledFor)
224
+ return
225
+ }
226
+ if (await store.hasAttempt(key)) {
227
+ await store.setLastHandled(task.id, key, latest.scheduledFor)
228
+ return
229
+ }
230
+ const run = await store.createRun({
231
+ taskId: task.id,
232
+ taskTitle: task.title,
233
+ trigger: 'scheduled',
234
+ occurrenceKey: key,
235
+ scheduleVersion: latest.scheduleVersion,
236
+ scheduledFor: latest.scheduledFor,
237
+ status: 'queued',
238
+ })
239
+ if (!(await requireCurrentOwner())) {
240
+ await store.updateRun(run.id, {
241
+ status: 'unknown_after_restart',
242
+ finishedAt: iso(now()),
243
+ durationMs: 0,
244
+ errors: [{
245
+ at: iso(now()),
246
+ phase: 'schedule',
247
+ severity: 'warning',
248
+ code: 'owner_lost_before_dispatch',
249
+ message: '调度租约在投递前失效;本次运行未自动重试。',
250
+ }],
251
+ }).catch(() => {})
252
+ await store.setLastHandled(task.id, key, latest.scheduledFor).catch(() => {})
253
+ return
254
+ }
255
+ const claimed = await store.claimOccurrence({ taskId: task.id, runId: run.id, occurrenceKey: key })
256
+ if (!claimed) {
257
+ await store.updateRun(run.id, { status: 'skipped_overlap', finishedAt: iso(now()), durationMs: 0 })
258
+ await store.setLastHandled(task.id, key, latest.scheduledFor)
259
+ return
260
+ }
261
+ if (!(await requireCurrentOwner())) {
262
+ await store.setAttemptState(key, 'unknown').catch(() => {})
263
+ await store.updateRun(run.id, {
264
+ status: 'unknown_after_restart',
265
+ finishedAt: iso(now()),
266
+ durationMs: 0,
267
+ errors: [{
268
+ at: iso(now()),
269
+ phase: 'schedule',
270
+ severity: 'warning',
271
+ code: 'owner_lost_before_dispatch',
272
+ message: '调度租约在投递前失效;本次运行未自动重试。',
273
+ }],
274
+ }).catch(() => {})
275
+ return
276
+ }
277
+ await store.setLastHandled(task.id, key, latest.scheduledFor)
278
+ if (runner.canDispatch?.() === false) {
279
+ await store.setAttemptState(key, 'unknown').catch(() => {})
280
+ await store.updateRun(run.id, {
281
+ status: 'skipped_overlap',
282
+ finishedAt: iso(now()),
283
+ durationMs: Math.max(0, now() - Date.parse(run.queuedAt)),
284
+ }).catch(() => {})
285
+ return
286
+ }
287
+ void Promise.resolve().then(() => runner.dispatch(run, task, 'scheduled')).catch((error) => {
288
+ logger?.warn?.(`capital-automation: scheduled dispatch failed: ${String(error)}`)
289
+ })
290
+ }
291
+
292
+ async function tick() {
293
+ if (stopped) return
294
+ if (tickInFlight !== undefined) return tickInFlight
295
+ tickInFlight = (async () => {
296
+ try {
297
+ if (!(await tryAcquire())) return
298
+ // Calendar refresh rides the tick but never blocks it; occurrence math reads the
299
+ // last good snapshot and keeps failing closed while a fetch is in flight.
300
+ try {
301
+ calendar?.refreshIfStale?.()
302
+ } catch (error) {
303
+ state.lastError = String(error)
304
+ }
305
+ if (timer !== undefined && currentTickMs !== Math.max(30_000, settings().tickMs)) armTick()
306
+ state.lastTickAt = iso(now())
307
+ const tasks = await store.listTasks()
308
+ for (const task of tasks) {
309
+ if (stopped) return
310
+ if (task.state !== 'active') continue
311
+ await dueForTask(task)
312
+ }
313
+ } catch (error) {
314
+ state.lastError = String(error)
315
+ logger?.warn?.(`capital-automation: tick 失败:${String(error)}`)
316
+ } finally {
317
+ tickInFlight = undefined
318
+ }
319
+ })()
320
+ return tickInFlight
321
+ }
322
+
323
+ function armTick() {
324
+ if (timer !== undefined) clearInterval(timer)
325
+ currentTickMs = Math.max(30_000, settings().tickMs)
326
+ timer = setInterval(() => { void tick() }, currentTickMs)
327
+ timer.unref?.()
328
+ }
329
+
330
+ async function start() {
331
+ if (stopped) return
332
+ if (typeof calendar?.ready === 'function') {
333
+ // Never start the first tick on a calendar that cannot answer today: a network fetch
334
+ // gets one bounded chance before fail-closed would claim the day's occurrence.
335
+ await calendar.ready().catch((error) => {
336
+ logger?.warn?.(`capital-automation: 交易日历初始化失败:${sanitizeMessage(String(error))}`)
337
+ })
338
+ }
339
+ if (stopped) return
340
+ await tick()
341
+ if (stopped) return
342
+ armTick()
343
+ startHeartbeat()
344
+ }
345
+
346
+ /**
347
+ * The tick may be delayed by a long model-independent storage write; renew the lease on its
348
+ * own clock so another process cannot legitimately take over between ticks.
349
+ */
350
+ function startHeartbeat() {
351
+ if (heartbeatTimer !== undefined || stopped) return
352
+ const heartbeatMs = Math.max(15_000, Math.min(settings().tickMs, DEFAULT_OWNER_LEASE_MS / 3))
353
+ heartbeatTimer = setInterval(() => {
354
+ if (stopped || state.state !== 'active') return
355
+ void lease.renew().then(async (renewed) => {
356
+ if (renewed && await ownerIsCurrent()) {
357
+ await publishOwner()
358
+ return
359
+ }
360
+ await standby('调度租约已失效:本进程不再投递新运行。')
361
+ }).catch(() => {})
362
+ }, heartbeatMs)
363
+ heartbeatTimer.unref?.()
364
+ }
365
+
366
+ async function stopInternal() {
367
+ stopped = true
368
+ if (timer !== undefined) clearInterval(timer)
369
+ if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer)
370
+ timer = undefined
371
+ heartbeatTimer = undefined
372
+ currentTickMs = undefined
373
+ await manualAdmission.catch(() => {})
374
+ await tickInFlight?.catch(() => {})
375
+ if (lease.held && await ownerIsCurrent()) {
376
+ await store.setOwnerMeta({
377
+ schemaVersion: 1,
378
+ profileKey: store.profileKey,
379
+ ownerId,
380
+ fencingToken: lease.fencingToken ?? ownerId,
381
+ leaseExpiresAt: iso(now()),
382
+ heartbeatAt: iso(now()),
383
+ schedulerState: 'standby',
384
+ updatedAt: iso(now()),
385
+ }).catch(() => {})
386
+ }
387
+ await lease.release().catch(() => {})
388
+ state.state = 'stopped'
389
+ }
390
+
391
+ function stop() {
392
+ if (stopPromise !== undefined) return stopPromise
393
+ stopPromise = stopInternal()
394
+ return stopPromise
395
+ }
396
+
397
+ /** Manual runs require the same owner as scheduled ones; standby never bypasses it. */
398
+ async function runNowInternal(task) {
399
+ if (stopped) return { ok: false, code: 'scheduler_unavailable', message: '调度器已停止。' }
400
+ if (state.state !== 'active') {
401
+ const acquired = await tryAcquire()
402
+ if (!acquired || state.state !== 'active') {
403
+ return { ok: false, code: 'scheduler_unavailable', message: '调度器未取得运行权,无法手动运行。' }
404
+ }
405
+ }
406
+ if (!(await requireCurrentOwner())) {
407
+ return { ok: false, code: 'scheduler_unavailable', message: '调度器未取得当前运行权,无法手动运行。' }
408
+ }
409
+ const active = await store.activeRunForTask(task.id)
410
+ if (active !== undefined) {
411
+ return { ok: false, code: 'run_in_progress', message: '该任务仍在运行,请先取消或等待终态。', runId: active.id }
412
+ }
413
+ if (runner.canDispatch?.() === false) {
414
+ return { ok: false, code: 'run_capacity', message: '当前 profile 已达到自动化并发上限,请等待已有运行结束。' }
415
+ }
416
+ const runId = `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
417
+ const run = await store.createRun({
418
+ id: runId,
419
+ taskId: task.id,
420
+ taskTitle: task.title,
421
+ trigger: 'manual',
422
+ occurrenceKey: `${store.profileKey}:manual:${runId}`,
423
+ status: 'queued',
424
+ })
425
+ if (!(await requireCurrentOwner())) {
426
+ await store.updateRun(run.id, {
427
+ status: 'unknown_after_restart',
428
+ finishedAt: iso(now()),
429
+ durationMs: 0,
430
+ errors: [{
431
+ at: iso(now()),
432
+ phase: 'schedule',
433
+ severity: 'warning',
434
+ code: 'owner_lost_before_dispatch',
435
+ message: '调度租约在投递前失效;本次运行未自动重试。',
436
+ }],
437
+ }).catch(() => {})
438
+ return { ok: false, code: 'scheduler_unavailable', message: '调度器未取得当前运行权,无法手动运行。', runId: run.id }
439
+ }
440
+ const claimed = await store.claimOccurrence({ taskId: task.id, runId: run.id, occurrenceKey: run.occurrenceKey })
441
+ if (!claimed) {
442
+ await store.updateRun(run.id, { status: 'skipped_overlap', finishedAt: iso(now()), durationMs: 0 }).catch(() => {})
443
+ return { ok: false, code: 'duplicate_run', message: '这次手动运行已经被登记。', runId: run.id }
444
+ }
445
+ if (!(await requireCurrentOwner())) {
446
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
447
+ await store.updateRun(run.id, {
448
+ status: 'unknown_after_restart',
449
+ finishedAt: iso(now()),
450
+ durationMs: 0,
451
+ errors: [{
452
+ at: iso(now()),
453
+ phase: 'schedule',
454
+ severity: 'warning',
455
+ code: 'owner_lost_before_dispatch',
456
+ message: '调度租约在投递前失效;本次运行未自动重试。',
457
+ }],
458
+ }).catch(() => {})
459
+ return { ok: false, code: 'scheduler_unavailable', message: '调度器未取得当前运行权,无法手动运行。', runId: run.id }
460
+ }
461
+ if (runner.canDispatch?.() === false) {
462
+ await store.setAttemptState(run.occurrenceKey, 'unknown').catch(() => {})
463
+ await store.updateRun(run.id, {
464
+ status: 'skipped_overlap',
465
+ finishedAt: iso(now()),
466
+ durationMs: Math.max(0, now() - Date.parse(run.queuedAt)),
467
+ }).catch(() => {})
468
+ return { ok: false, code: 'run_capacity', message: '当前 profile 已达到自动化并发上限,请等待已有运行结束。', runId: run.id }
469
+ }
470
+ void Promise.resolve().then(() => runner.dispatch(run, task, 'manual')).catch((error) => {
471
+ logger?.warn?.(`capital-automation: manual dispatch failed: ${String(error)}`)
472
+ })
473
+ return { ok: true, run }
474
+ }
475
+
476
+ function runNow(task) {
477
+ const next = manualAdmission.then(() => runNowInternal(task), () => runNowInternal(task))
478
+ manualAdmission = next.then(() => {}, () => {})
479
+ return next
480
+ }
481
+
482
+ function status() {
483
+ return {
484
+ state: state.state,
485
+ profileKey: store.profileKey,
486
+ leaseExpiresAt: state.leaseExpiresAt,
487
+ heartbeatAt: state.heartbeatAt,
488
+ held: lease.held,
489
+ tickMs: settings().tickMs,
490
+ recoveredRuns: state.recoveredRuns,
491
+ lastTickAt: state.lastTickAt,
492
+ storageError: state.storageError === null ? null : sanitizeMessage(state.storageError),
493
+ lastError: state.lastError === null ? null : sanitizeMessage(state.lastError),
494
+ calendar: calendar?.status?.(new Date(now())) ?? { available: false, source: 'none' },
495
+ }
496
+ }
497
+
498
+ return {
499
+ start,
500
+ stop,
501
+ tick,
502
+ runNow,
503
+ status,
504
+ ownerId,
505
+ lease,
506
+ /** Next occurrence for display; the scheduler itself works from the previous occurrence. */
507
+ displayNext(task) {
508
+ return previousOccurrenceAtOrBefore(task.trigger, now(), { calendar })
509
+ },
510
+ formatInZone,
511
+ scheduleVersionOf,
512
+ }
513
+ }