@v587d/capital-generation 2.11.1 → 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.
- package/CHANGELOG.md +25 -0
- package/README.md +14 -14
- package/automation/builtins.js +147 -0
- package/automation/calendar.js +66 -0
- package/automation/client.js +68 -0
- package/automation/client.src.cjs +777 -0
- package/automation/defaults.js +71 -0
- package/automation/index.js +183 -0
- package/automation/owner.js +222 -0
- package/automation/package.json +50 -0
- package/automation/policy.js +105 -0
- package/automation/redact.js +25 -0
- package/automation/route.js +241 -0
- package/automation/runner.js +858 -0
- package/automation/scheduler.js +495 -0
- package/automation/schema.js +359 -0
- package/automation/service.js +338 -0
- package/automation/settings.js +48 -0
- package/automation/store.js +707 -0
- package/automation/time.js +220 -0
- package/automation/workspaces.js +154 -0
- package/chart-ui/package.json +1 -1
- package/config/client.js +4 -2
- package/config/client.src.cjs +90 -1
- package/config/index.js +15 -0
- package/config/package.json +1 -1
- package/cordis.patch.yml +8 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +16 -0
- package/lib/web-retriever/local-fetch.d.ts +1 -1
- package/lib/web-retriever/local-fetch.js +1 -1
- package/lib/web-retriever/wind-client.d.ts +1 -1
- package/lib/web-retriever/wind-client.js +1 -1
- package/package.json +9 -7
- package/preset/capital-generation/README.md +1 -1
- package/watchlist/package.json +1 -1
|
@@ -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
|
+
}
|