dsh-task-dispatch-table 0.0.0 → 0.1.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/README.md +95 -31
- package/README.zh.md +113 -0
- package/assets/icon-scheduler.svg +1 -0
- package/cordis.patch.yml +9 -0
- package/dist/attachment-allowlist.d.ts +24 -0
- package/dist/attachment-allowlist.js +41 -0
- package/dist/calendar-plan.d.ts +55 -0
- package/dist/calendar-plan.js +104 -0
- package/dist/client.js +72655 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +80 -0
- package/dist/deps.d.ts +25 -0
- package/dist/deps.js +54 -0
- package/dist/dispatch.d.ts +133 -0
- package/dist/dispatch.js +509 -0
- package/dist/event-bus.d.ts +16 -0
- package/dist/event-bus.js +67 -0
- package/dist/event-catalog.d.ts +42 -0
- package/dist/event-catalog.js +60 -0
- package/dist/host.d.ts +299 -0
- package/dist/host.js +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +0 -0
- package/dist/receipt.d.ts +38 -0
- package/dist/receipt.js +236 -0
- package/dist/reconcile.d.ts +105 -0
- package/dist/reconcile.js +641 -0
- package/dist/runtime-index.d.ts +102 -0
- package/dist/runtime-index.js +340 -0
- package/dist/schedule-next.d.ts +44 -0
- package/dist/schedule-next.js +216 -0
- package/dist/scheduler.d.ts +82 -0
- package/dist/scheduler.js +639 -0
- package/dist/store.d.ts +461 -0
- package/dist/store.js +845 -0
- package/dist/submit.d.ts +1 -0
- package/dist/submit.js +60 -0
- package/dist/task-assets.d.ts +124 -0
- package/dist/task-assets.js +380 -0
- package/dist/task-sort.d.ts +27 -0
- package/dist/task-sort.js +59 -0
- package/dist/tasks.d.ts +191 -0
- package/dist/tasks.js +527 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -6
- package/index.js +0 -7
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
// scheduler.ts — 派发循环(决策 31 懒建行 + 决策 41 两层循环彻底解耦)。
|
|
2
|
+
//
|
|
3
|
+
// Loop A(本文件 dispatchNewSlots)只管「写执行记录」:
|
|
4
|
+
// 每 tick 只判「现在这一刻该不该跑」——取满足 scheduled_at <= now <= scheduled_at + window
|
|
5
|
+
// 且无实例行的「最晚」刻度;依赖 / 工作区不过 ⇒ 只记 task_log、不建行;过了 ⇒ 把要执行的
|
|
6
|
+
// 任务连同**派发快照**(决策 41)以 dispatched 落库,**落库即止**——不发动会话、不解析模型。
|
|
7
|
+
// `enabled` 只在这里起作用(挡新行);已落库实例由 Loop B 收口,与本循环无关。
|
|
8
|
+
// Loop B(reconcile.ts sweep):只读执行记录 + 快照——发动执行 / 失败重试 / 追问 /
|
|
9
|
+
// 契约回收 / 重试行重派(窗口内转 dispatched 并发动、窗口外删行记日志),全程不读任务表。
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { attachmentAbsPath } from './task-assets.js';
|
|
14
|
+
import { parseInlineTasks } from './tasks.js';
|
|
15
|
+
import { displayNameOf, durationMs, logicalDateOf, onceScheduledAt, scheduledSlotsFor } from './tasks.js';
|
|
16
|
+
import { parseInstanceSnapshot } from './store.js';
|
|
17
|
+
import { resolveWorkspace } from './dispatch.js';
|
|
18
|
+
/** 阻塞原因 → task_log 的 kind 与文案(用户一眼能分清「没跑成」和「永远不会跑」)。 */
|
|
19
|
+
const BLOCK_KIND = {
|
|
20
|
+
'upstream-not-succeeded': { kind: 'dep_blocked', message: '被依赖卡住,等待前置任务成功(下轮再判)' },
|
|
21
|
+
'upstream-missing': { kind: 'dep_missing', message: '前置任务已不存在(被删除),永远不会放行(除非移除该依赖)' },
|
|
22
|
+
};
|
|
23
|
+
// 串行互斥只认**真正在飞**的状态(决策 8:同任务不并发)。
|
|
24
|
+
// 不含 'unknown':unknown 只由重启扫描产生(会话句柄已随进程消失、不定态),它应在 sweep 里
|
|
25
|
+
// 被快速收口为终态;若还把它当「在飞」参与互斥,会在老库卡死的孤儿实例上把同任务永久挡死
|
|
26
|
+
// (本次真机 bug:老库一条卡 running 的历史实例 → 重启转 unknown → 同 cron 任务再也写不出新行)。
|
|
27
|
+
const IN_FLIGHT_STATUSES = ['dispatched', 'running'];
|
|
28
|
+
// ── 调度输入加载(inline JSON 或目录,按配置择一)──
|
|
29
|
+
function loadTasks(logger, config, includeDisabled = false) {
|
|
30
|
+
if (config.tasksInline.trim() !== '') {
|
|
31
|
+
const parsed = parseInlineTasks(logger, config.tasksInline, { includeDisabled });
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
return readTasksDir(logger, config.tasksDir);
|
|
35
|
+
}
|
|
36
|
+
/** 目录模式:读取 tasksDir 下的 .json / .jsonc 任务文件(每文件可含数组或 {tasks:[...]})。 */
|
|
37
|
+
function readTasksDir(logger, dir) {
|
|
38
|
+
if (!dir || !existsSync(dir))
|
|
39
|
+
return [];
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const file of readdirSync(dir)) {
|
|
42
|
+
if (!file.endsWith('.json') && !file.endsWith('.jsonc'))
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
const text = readFileSync(join(dir, file), 'utf8');
|
|
46
|
+
out.push(...parseInlineTasks(logger, stripJsoncComments(text)));
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
logger.warn(`读取任务文件失败 ${file}:${e instanceof Error ? e.message : String(e)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** 去掉 JSONC 注释(// 与 /* */),便于目录模式直接吃 .jsonc。 */
|
|
55
|
+
function stripJsoncComments(text) {
|
|
56
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 上游实例 → 已解析依赖(决策 43):固化 id / 计划时刻 / 会话 / 上游工作区 / 产出。
|
|
60
|
+
* 产出取上游实例的 `outputs` 列(回执声明并经 checkReceipt 校验的 JSON 数组,决策 32 修订);
|
|
61
|
+
* 旧行 / 坏 JSON / 未声明 ⇒ 空数组(消息里如实写「未声明产出」)。
|
|
62
|
+
*/
|
|
63
|
+
function resolvedOf(dep, upstream) {
|
|
64
|
+
let outputs = [];
|
|
65
|
+
try {
|
|
66
|
+
const parsed = upstream.outputs === null ? undefined : JSON.parse(upstream.outputs);
|
|
67
|
+
if (Array.isArray(parsed))
|
|
68
|
+
outputs = parsed.filter((x) => typeof x === 'string');
|
|
69
|
+
}
|
|
70
|
+
catch { /* 旧行 / 异常 ⇒ 视为未声明 */ }
|
|
71
|
+
const upstreamSnap = parseInstanceSnapshot(upstream.snapshot);
|
|
72
|
+
return {
|
|
73
|
+
task: dep.task,
|
|
74
|
+
semantics: dep.semantics,
|
|
75
|
+
instanceId: upstream.id,
|
|
76
|
+
scheduledAt: upstream.scheduled_at,
|
|
77
|
+
sessionId: upstream.session_id,
|
|
78
|
+
workspacePath: upstreamSnap?.workspacePath ?? null,
|
|
79
|
+
outputs,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ── 依赖判定(决策 8/9):同周期 / 最近成功。只查已存在的实例(无行即视为缺失)。──
|
|
83
|
+
export function judgeDependencies(store, task, logicalDate, scheduledAt,
|
|
84
|
+
/**
|
|
85
|
+
* 上游任务定义表(可选):给了就能区分「上游还没成功 / 上游已删除」两种阻塞,
|
|
86
|
+
* 日志里不再混成一句 dep_blocked(2026-09-30 评审 P4;2026-10-06 起**不看 enabled**)。
|
|
87
|
+
*/
|
|
88
|
+
upstreams) {
|
|
89
|
+
const deps = task.depends_on;
|
|
90
|
+
if (!deps || deps.length === 0)
|
|
91
|
+
return { ready: true, staleNotes: [], resolved: [] };
|
|
92
|
+
const staleNotes = [];
|
|
93
|
+
const resolved = [];
|
|
94
|
+
// 「复用旧产出」告警判据:下游上次执行时刻(首次运行无 ⇒ 不告警)。只提示,不拦。
|
|
95
|
+
const lastRun = store.getLatestInstance(task.id);
|
|
96
|
+
const lastRunMs = lastRun === undefined ? undefined : Date.parse(lastRun.scheduled_at);
|
|
97
|
+
for (const dep of deps) {
|
|
98
|
+
// 阻塞原因先按「上游定义还在不在」定性(2026-10-06 起**不看 enabled**:停用的上游
|
|
99
|
+
// 只要最近一次执行成功就放行),再看实例状态。
|
|
100
|
+
if (upstreams !== undefined) {
|
|
101
|
+
const upstreamDef = upstreams.get(dep.task);
|
|
102
|
+
if (upstreamDef === undefined)
|
|
103
|
+
return { ready: false, staleNotes, resolved: [], reason: 'upstream-missing' };
|
|
104
|
+
}
|
|
105
|
+
if (dep.semantics === 'same_period') {
|
|
106
|
+
// 同周期:同 logical_date 取最新一条,必须 succeeded(决策 33 #2)
|
|
107
|
+
const upstream = store.getSamePeriod(dep.task, logicalDate);
|
|
108
|
+
if (upstream === undefined || upstream.status !== 'succeeded') {
|
|
109
|
+
return { ready: false, staleNotes, resolved: [], reason: 'upstream-not-succeeded' };
|
|
110
|
+
}
|
|
111
|
+
// 决策 43:命中即固化——写库那一刻是哪条上游实例放的行,就此定死
|
|
112
|
+
resolved.push(resolvedOf(dep, upstream));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// latest_success(决策 33):上游**最近一条**(不分状态)必须正好是 succeeded;
|
|
116
|
+
// 在跑 / 失败 / 无记录 ⇒ 阻塞,绝不拿更早的旧成功放行。
|
|
117
|
+
const latest = store.getLatestInstance(dep.task);
|
|
118
|
+
if (latest === undefined || latest.status !== 'succeeded') {
|
|
119
|
+
return { ready: false, staleNotes, resolved: [], reason: 'upstream-not-succeeded' };
|
|
120
|
+
}
|
|
121
|
+
const upstreamMs = Date.parse(latest.scheduled_at);
|
|
122
|
+
// 上游这份成功不晚于下游上次执行 ⇒ 下游上次跑时它已存在 ⇒ 复用旧产出,告警
|
|
123
|
+
if (lastRunMs !== undefined && upstreamMs <= lastRunMs) {
|
|
124
|
+
staleNotes.push(`上游 ${dep.task} 无新产出,本次复用 ${latest.scheduled_at} 的旧产出`);
|
|
125
|
+
}
|
|
126
|
+
resolved.push(resolvedOf(dep, latest));
|
|
127
|
+
}
|
|
128
|
+
return { ready: true, staleNotes, resolved };
|
|
129
|
+
}
|
|
130
|
+
function isOnce(task) {
|
|
131
|
+
return task.schedule.once !== undefined;
|
|
132
|
+
}
|
|
133
|
+
// 原先这里有个本地口径的 `onceDate`(`new Date(task.schedule.once)` = **宿主本地时区**),
|
|
134
|
+
// 2026-09-30 二轮评审确认它三处都会与「按任务 timezone」的 `onceScheduledAt` 分歧 ⇒ **整删**,
|
|
135
|
+
// 一次性时刻一律走 `onceScheduledAt`(派发 / 面板 / 过期记录 / 启动诊断同口径)。
|
|
136
|
+
/** 取「当前该跑的那一下」:最晚满足 scheduled_at <= now <= scheduled_at+window 且无实例行的刻度。 */
|
|
137
|
+
function dueSlot(task, nowMs, store) {
|
|
138
|
+
let chosen;
|
|
139
|
+
let previous;
|
|
140
|
+
if (isOnce(task)) {
|
|
141
|
+
// 一次性任务**同样受窗口约束**(用户 2026-09-30 拍板 A):`到期时刻 + window` 之内还算数(迟到也补跑),
|
|
142
|
+
// 出了窗口就当**过期不跑**。此前这里完全不看窗口 ⇒ 面板已经显示「没有下次了」,调度器却仍会派发
|
|
143
|
+
// (两侧口径分裂)。window 解析失败 ⇒ 0 ⇒ 只有「正好那一刻」算数,绝不无限期补跑。
|
|
144
|
+
// ⚠️ 必须用 `onceScheduledAt`(**按 `schedule.timezone` 的墙上时间**解释,2026-09-30 修):此前用
|
|
145
|
+
// 本地 `onceDate` 解读 ⇒ 任务时区与宿主本地不一致时(宿主 UTC+8、任务写 UTC 之类)「到点 / 过期」
|
|
146
|
+
// 整体偏掉一个时差,而面板走的是时区正确那条路 ⇒ 两侧对同一任务给出不同结论。
|
|
147
|
+
const onceStr = task.schedule.once ?? '';
|
|
148
|
+
const od = onceScheduledAt(task, onceStr.slice(0, 10));
|
|
149
|
+
if (od !== undefined && od.getTime() <= nowMs) {
|
|
150
|
+
let windowMs = 0;
|
|
151
|
+
try {
|
|
152
|
+
windowMs = durationMs(task.schedule.window);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
windowMs = 0;
|
|
156
|
+
}
|
|
157
|
+
if (nowMs <= od.getTime() + windowMs)
|
|
158
|
+
chosen = od;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
const windowMs = durationMs(task.schedule.window);
|
|
163
|
+
const from = new Date(nowMs - windowMs);
|
|
164
|
+
const to = new Date(nowMs + 1000); // 容差 1s
|
|
165
|
+
try {
|
|
166
|
+
// 升序遍历:每次选中更晚的刻度时,被顶掉的那一个就是「前一槽」。
|
|
167
|
+
for (const s of scheduledSlotsFor(task, from, to)) {
|
|
168
|
+
if (s.getTime() > nowMs)
|
|
169
|
+
continue;
|
|
170
|
+
if (chosen === undefined || s.getTime() > chosen.getTime()) {
|
|
171
|
+
previous = chosen;
|
|
172
|
+
chosen = s;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (chosen === undefined)
|
|
181
|
+
return undefined;
|
|
182
|
+
const iso = chosen.toISOString();
|
|
183
|
+
if (store.findBySlot(task.id, iso) !== undefined)
|
|
184
|
+
return undefined; // 已有实例(幂等/此前已处理)
|
|
185
|
+
// 窗口里只有一条刻度(间隔 > 窗口,如日更 + PT4H)⇒ 前一槽在窗口之外,单独**有界回溯**一次。
|
|
186
|
+
// 只在「窗口内 ≤1 条」时才走这条路 ⇒ 稀疏任务才付这个成本,分钟级任务不会。
|
|
187
|
+
if (previous === undefined && !isOnce(task)) {
|
|
188
|
+
const lookbackMs = Math.max(durationMs(task.schedule.window) * 2, 8 * 86_400_000);
|
|
189
|
+
try {
|
|
190
|
+
const earlier = scheduledSlotsFor(task, new Date(chosen.getTime() - lookbackMs), new Date(chosen.getTime() + 1000));
|
|
191
|
+
const last = earlier[earlier.length - 1];
|
|
192
|
+
if (last !== undefined && last.getTime() < chosen.getTime())
|
|
193
|
+
previous = last;
|
|
194
|
+
}
|
|
195
|
+
catch { /* 回溯失败 ⇒ 当作没有前一槽,不补记(绝不猜) */ }
|
|
196
|
+
}
|
|
197
|
+
return { logicalDate: logicalDateOf(chosen, task.schedule.timezone), scheduledAtIso: iso, previous };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 阻塞日志(2026-09-30 改为**结论变化才记**):同一任务 + 同一结论只记一次,
|
|
201
|
+
* 一直卡着不再重复写(旧行为 5 分钟一条 ⇒ 一天 288 条,会把日志淹掉)。
|
|
202
|
+
* 放行时调用方 `delete` 掉该任务的签名 ⇒ 下次再卡住能重新记一条。
|
|
203
|
+
*/
|
|
204
|
+
function logBlocked(verdictLog, store, taskId, scheduledAt, reason) {
|
|
205
|
+
const fallback = { kind: 'dep_blocked', message: '被依赖卡住,等待上游成功(下轮再判)' };
|
|
206
|
+
const mapped = reason === undefined ? fallback : (BLOCK_KIND[reason] ?? fallback);
|
|
207
|
+
if (verdictLog.get(taskId) === mapped.kind)
|
|
208
|
+
return;
|
|
209
|
+
verdictLog.set(taskId, mapped.kind);
|
|
210
|
+
store.appendLog({ taskId, scheduledAt, level: 'info', kind: mapped.kind, message: mapped.message });
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* **任务级错误 ⇒ 当场写一条执行记录**(用户 2026-09-30 拍板,纠正我此前「等下一刻度再补记」的说法)。
|
|
214
|
+
*
|
|
215
|
+
* 用户的分层口径(与「延期」严格分开,按**谁的责任**分):
|
|
216
|
+
* - **前置没跑完 / 上一轮还在跑 / 服务停机** ⇒ **延期**:等得起、会自愈 ⇒ **不写行**(只上徽标 + 日志);
|
|
217
|
+
* - **附件找不到 / 工作区不存在** ⇒ **任务级错误**:不修就永远跑不了 ⇒ **当场写行**(卡片立刻红)。
|
|
218
|
+
*
|
|
219
|
+
* 判定的先后正是用户说的顺序:**先判前置**(被挡住 ⇒ 延期,根本走不到这里),
|
|
220
|
+
* **前置过了才判工作区 / 附件**(错 ⇒ 写行)—— 与 `dispatchNewSlots` 里的分支顺序一致。
|
|
221
|
+
*
|
|
222
|
+
* 写法:复用终态 `skipped` + `attempt=0`。**不需要另加「不必重试」字段** —— `skipped` 是终态,
|
|
223
|
+
* 第二个循环只巡检 pending/dispatched/running/unknown(`NON_TERMINAL_STATUSES`)⇒ **天然不重试**。
|
|
224
|
+
* 主键用**本槽自己的时刻**;同一槽被多轮 tick 看到由 `UNIQUE(task_id, scheduled_at)` + `OR IGNORE` 幂等。
|
|
225
|
+
* 原因挂在该行的事件上(实例行没有文案列)⇒ 执行记录展开即可看到「为什么没跑」。
|
|
226
|
+
*/
|
|
227
|
+
function recordTaskError(store, runtime, task, slot, detail) {
|
|
228
|
+
const instanceId = randomUUID();
|
|
229
|
+
// **原子**(2026-09-30 复核 P2):行 + 原因事件**要么都在、要么都不在** —— 此前是两步独立写库,
|
|
230
|
+
// 中途被杀会留下「执行记录里有这条 skipped、展开却看不到为什么」的半截记录。
|
|
231
|
+
const written = store.transaction(() => {
|
|
232
|
+
if (!store.ensureSkipped(instanceId, task.id, slot.logicalDate, slot.scheduledAtIso))
|
|
233
|
+
return false;
|
|
234
|
+
store.appendEvent(instanceId, 'task-error', { scheduledAt: slot.scheduledAtIso, reason: detail });
|
|
235
|
+
return true;
|
|
236
|
+
});
|
|
237
|
+
// ⚠️ 内存运行态**放在事务外**:事务里改了内存而库又回滚 ⇒ 两边不一致;放外面语义也更清楚
|
|
238
|
+
//(库提交成功了才认这条记录)。
|
|
239
|
+
if (written)
|
|
240
|
+
runtime?.markTerminal(task.id, 'skipped', slot.scheduledAtIso, new Date().toISOString());
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* **一次性任务的「过期未执行」记录**(用户 2026-09-30 拍板 A 的配套)。
|
|
244
|
+
*
|
|
245
|
+
* 一次性任务加了窗口闸门(`dueSlot`)之后「出窗口就再也不跑」;若那一槽从来没跑过(典型:那一刻它
|
|
246
|
+
* 被上游堵着 / 附件缺失,一直拖到窗口过完),它会**无声无息地消失** —— 面板显示「没有下次了」、
|
|
247
|
+
* 执行记录里一条都没有。这里补一条终态 `skipped`(`attempt=0` ⇒ 天然不重试),把「该跑没跑、而且
|
|
248
|
+
* 再也不会跑」**留在用户看得见的地方**:卡片标红 + 执行记录一条 + 原因挂在该行事件上。
|
|
249
|
+
*
|
|
250
|
+
* 门禁与「补记前一个槽」**完全一致**(`gateMs` = 本进程启动 / 任务创建 的较晚者):**停机期间**跨过
|
|
251
|
+
* 那一刻的不补(用户口径:服务没跑的那段时间不用管),否则用户新建一个「上一刻已过期」的一次性任务
|
|
252
|
+
* 会被凭空标红。返回「本槽是否按过期处理」(调用方据此决定要不要清掉悬浮原因)。
|
|
253
|
+
*/
|
|
254
|
+
export function recordExpiredOnce(store, runtime, task, nowMs, gateMs) {
|
|
255
|
+
const onceStr = task.schedule.once;
|
|
256
|
+
if (onceStr === undefined)
|
|
257
|
+
return false;
|
|
258
|
+
const once = onceScheduledAt(task, onceStr.slice(0, 10)); // 与 `dueSlot` / 面板同口径(按任务时区)
|
|
259
|
+
if (once === undefined)
|
|
260
|
+
return false;
|
|
261
|
+
const onceMs = once.getTime();
|
|
262
|
+
let windowMs = 0;
|
|
263
|
+
try {
|
|
264
|
+
windowMs = durationMs(task.schedule.window);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
windowMs = 0;
|
|
268
|
+
}
|
|
269
|
+
// 门禁(2026-09-30 二轮评审修正):只跳过「**整个窗口都在本进程启动之前**」的槽 —— 那就是停机期间
|
|
270
|
+
// 错过的(用户口径:服务没跑的那段时间不用管)。若本进程**在窗口内接管过**它、却一直没跑成
|
|
271
|
+
//(被上游堵 / 附件缺失),出窗时必须留痕,所以判据是 `once + window < gateMs`,**不是** `once < gateMs`
|
|
272
|
+
//(后者会把「窗口内重启后被堵到出窗」这种**能跑没跑成**的情况静默漏记)。
|
|
273
|
+
if (onceMs > nowMs || onceMs + windowMs < gateMs)
|
|
274
|
+
return false;
|
|
275
|
+
if (nowMs <= onceMs + windowMs)
|
|
276
|
+
return false; // 还在窗口内 ⇒ 下一步会派发,不算「过期」
|
|
277
|
+
const iso = once.toISOString();
|
|
278
|
+
const detail = '一次性任务已过期未执行(超过补跑窗口)';
|
|
279
|
+
const existing = store.findBySlot(task.id, iso);
|
|
280
|
+
if (existing !== undefined) {
|
|
281
|
+
// 已经有行:是本函数写的那条 ⇒ 继续保留悬浮原因(移上去能看见为什么);真跑过 ⇒ 不打扰。
|
|
282
|
+
if (existing.status === 'skipped') {
|
|
283
|
+
runtime?.markBlocked(task.id, detail);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const instanceId = randomUUID();
|
|
289
|
+
const recorded = store.transaction(() => {
|
|
290
|
+
if (!store.ensureSkipped(instanceId, task.id, logicalDateOf(once, task.schedule.timezone), iso))
|
|
291
|
+
return false;
|
|
292
|
+
store.appendEvent(instanceId, 'expired-once', { scheduledAt: iso, reason: detail });
|
|
293
|
+
store.appendLog({ taskId: task.id, scheduledAt: iso, level: 'error', kind: 'expired-once', message: detail });
|
|
294
|
+
return true;
|
|
295
|
+
});
|
|
296
|
+
if (!recorded)
|
|
297
|
+
return false;
|
|
298
|
+
runtime?.markTerminal(task.id, 'skipped', iso, new Date().toISOString());
|
|
299
|
+
runtime?.markBlocked(task.id, detail); // 悬浮看得见原因(卡片已红 + 执行记录里有一条)
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 附加文件存在性校验(2026-09-30):返回**缺失**的展示名。
|
|
304
|
+
* upload 型按任务目录绝对路径;link 型按**附件来源工作区**(item.workspace)解析——
|
|
305
|
+
* 附件可以选自任意工作区,拿任务目标工作区的 path 去判会误报(评审 P1#8)。
|
|
306
|
+
* 拿不到基准(资产根未就绪 / 来源工作区不存在)⇒ 跳过不判,**绝不猜**。
|
|
307
|
+
*/
|
|
308
|
+
export function missingAttachments(ctx, task, workspacePath, assets) {
|
|
309
|
+
const list = task.attachments;
|
|
310
|
+
if (list === undefined || list.length === 0)
|
|
311
|
+
return [];
|
|
312
|
+
const out = [];
|
|
313
|
+
for (const item of list) {
|
|
314
|
+
let abs = null;
|
|
315
|
+
if (item.kind === 'upload') {
|
|
316
|
+
abs = assets === null ? null : attachmentAbsPath(assets, task.id, item.ref);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const source = item.workspace !== undefined && item.workspace.trim() !== '' ? item.workspace : task.target.workspace;
|
|
320
|
+
try {
|
|
321
|
+
abs = join(resolveWorkspace(ctx, source).path, item.ref);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
abs = null; // 来源工作区不存在 ⇒ 不判(由工作区解析那道预条件另行报错)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (abs === null || abs.includes('..'))
|
|
328
|
+
continue;
|
|
329
|
+
if (!existsSync(abs))
|
|
330
|
+
out.push(item.name);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
// 工作区解析用 dispatch.js 的 resolveWorkspace(Loop A 落库前的前置检查,决策 41:
|
|
335
|
+
// Loop B 发动走 resolveWorkspaceByPath——快照里存的是 path)。
|
|
336
|
+
/** 派发快照组装(决策 41 + 决策 43):落库时把执行所需字段(含依赖解析)固化进实例行,此后与任务设置无关。 */
|
|
337
|
+
function snapshotOf(task, workspace, resolvedDeps) {
|
|
338
|
+
return {
|
|
339
|
+
title: displayNameOf(task),
|
|
340
|
+
prompt: task.target.prompt,
|
|
341
|
+
manual: task.target.manual ?? null,
|
|
342
|
+
workspacePath: workspace.path,
|
|
343
|
+
provider: task.target.provider ?? '',
|
|
344
|
+
model: task.target.model ?? '',
|
|
345
|
+
validStatuses: task.contract.validStatuses.length > 0 ? [...task.contract.validStatuses] : ['ok'],
|
|
346
|
+
// 决策 48/49:goal / agentTeam 随快照固化(此前 goal 漏快照 = goal:false 不生效的缺陷,一并修)。
|
|
347
|
+
goal: task.target.goal !== false,
|
|
348
|
+
agentTeam: task.target.agentTeam === true,
|
|
349
|
+
permission: task.target.permission ?? 'default',
|
|
350
|
+
maxAttempts: task.retry.maxAttempts,
|
|
351
|
+
window: task.schedule.window,
|
|
352
|
+
resolvedDeps,
|
|
353
|
+
// 2026-09-30:附件清单随快照冻结 ⇒ Loop B 校验「附件还在不在」不必回头读任务定义
|
|
354
|
+
// (决策 41:Loop B 只读快照)。
|
|
355
|
+
attachments: (task.attachments ?? []).map(item => ({
|
|
356
|
+
name: item.name,
|
|
357
|
+
kind: item.kind,
|
|
358
|
+
ref: item.ref,
|
|
359
|
+
...(item.workspace === undefined ? {} : { workspace: item.workspace }),
|
|
360
|
+
})),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function dispatchNewSlots(ctx, logger, store, tasks, verdictLog, upstreams, assets,
|
|
364
|
+
/** 主界面运行态内存索引(2026-09-30):落库即顺手标记「运行中」,不额外查库。 */
|
|
365
|
+
runtime,
|
|
366
|
+
/** 本进程启动时刻(决策 54):补记「未执行」只补**启动之后**的槽 ⇒ 停机期间漏的不补。 */
|
|
367
|
+
startedAtMs) {
|
|
368
|
+
const nowMs = Date.now();
|
|
369
|
+
for (const task of tasks) {
|
|
370
|
+
// 停用 ⇒ **顺手清掉「被挡住」的原因**(2026-09-30 复核 P1):否则一张**已关掉**的卡片悬停时
|
|
371
|
+
// 还在喊「工作区未找到…|已过计划时刻但还没开始执行」—— 它根本没在等。
|
|
372
|
+
if (task.enabled === false) {
|
|
373
|
+
runtime?.markBlocked(task.id, null);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
// 串行语义(§8):同任务已有在飞实例则跳过本次新槽
|
|
377
|
+
if (store.listByStatus(IN_FLIGHT_STATUSES).some((o) => o.task_id === task.id))
|
|
378
|
+
continue;
|
|
379
|
+
// 门禁(2026-09-30 评审 P0):不补「本进程启动之前」**以及「任务创建之前」**的槽;一次性任务的
|
|
380
|
+
// 「过期未执行」记录**共用同一道门禁**。少了后半句就会:宿主已跑了一天,用户 10:03 新建一个每 10
|
|
381
|
+
// 分钟的任务(默认 window PT4H)⇒ 第一个 tick 就把 09:50 补成「未执行」,卡片立刻标红(假记录)。
|
|
382
|
+
// ⚠️ 提到 `dueSlot` 之前:下面「无到期槽」那个分支也要用它(一次性任务的过期记录)。
|
|
383
|
+
// 门禁 = **任务创建时刻**(用户 2026-09-30 拍板**放宽**,推翻先前"插件启动也算门禁"):
|
|
384
|
+
// 重启 / 停机期间被吃掉的那一条**也要补** —— 用户原话:「时间输错了,前面漏了多少次,也补一条,
|
|
385
|
+
// 让人有个明确交代」。仍保留「任务创建之前不补」(否则刚建的任务会被凭空标红:宿主已跑一天、
|
|
386
|
+
// 用户 10:03 新建一个每 10 分钟的任务、窗口 PT4H ⇒ 第一个 tick 就把 09:50 记成「未执行」=假记录)。
|
|
387
|
+
// `startedAtMs` 不再参与门禁,只用于在原因里标注「这条是重启 / 停机期间漏的」。
|
|
388
|
+
const createdMs = task.createdAt === undefined ? Number.NEGATIVE_INFINITY : Date.parse(task.createdAt);
|
|
389
|
+
const gateMs = Number.isFinite(createdMs) ? createdMs : Number.NEGATIVE_INFINITY;
|
|
390
|
+
const slot = dueSlot(task, nowMs, store);
|
|
391
|
+
// 本 tick 无到期槽(没到点 / 已处理 / **窗口已过**)⇒ 清掉「被挡住」的原因:出窗后卡片会转成
|
|
392
|
+
// 「下一次执行」的倒计时,留着旧原因就变成「倒计时 + 延期说明」自相矛盾(2026-09-30 复核 P1)。
|
|
393
|
+
// **例外**:一次性任务过期是「再也不会跑」⇒ 补一条「过期未执行」的记录(用户拍板 A 的配套,
|
|
394
|
+
// 否则它会无声无息地消失),并保留悬浮原因。
|
|
395
|
+
if (slot === undefined) {
|
|
396
|
+
if (!recordExpiredOnce(store, runtime, task, nowMs, gateMs))
|
|
397
|
+
runtime?.markBlocked(task.id, null);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
// 补记「未执行」(决策 54,用户拍板):**等到下一个该执行的时刻**才判——紧邻的前一槽若始终没有
|
|
401
|
+
// 实例行,说明它彻底没戏了(窗口里已经出现更晚的刻度 ⇒ 调度器再也不会选它),补记**一条** `skipped`。
|
|
402
|
+
// 规则:只补紧邻那一条(中间漏掉的 N 条不补);停机期间不补(`startedAtMs` 门禁);once 不适用。
|
|
403
|
+
// ⚠️ 主键必须用**被漏那一槽自己的时刻**:用当前槽会撞当前槽真实执行行的唯一键,
|
|
404
|
+
// `INSERT OR IGNORE` 静默丢弃 ⇒ 任务永久不再执行。
|
|
405
|
+
if (slot.previous !== undefined && !isOnce(task) && slot.previous.getTime() >= gateMs) {
|
|
406
|
+
const prevIso = slot.previous.toISOString();
|
|
407
|
+
if (store.findBySlot(task.id, prevIso) === undefined) {
|
|
408
|
+
const missedId = randomUUID();
|
|
409
|
+
const prevLogical = logicalDateOf(slot.previous, task.schedule.timezone);
|
|
410
|
+
// 原因取「本轮该任务上一次记录的阻塞结论」(签名表)——现成文案复用 BLOCK_KIND,认不出就如实说。
|
|
411
|
+
const signature = verdictLog.get(task.id);
|
|
412
|
+
const base = signature === undefined
|
|
413
|
+
? '上一刻度未执行(该轮未记录到原因)'
|
|
414
|
+
: (BLOCK_KIND[signature]?.message ?? `上一刻度未执行(${signature})`);
|
|
415
|
+
// 标清楚"这条是本次插件启动**之前**漏的"(重启 / 停机期间)—— 门禁已放宽,这类也会补,
|
|
416
|
+
// 标注出来你就一眼能分辨"是它自己没跑成" 还是 "那段插件没在跑"。
|
|
417
|
+
const detail = slot.previous.getTime() < startedAtMs ? `${base}(发生在本次插件启动之前)` : base;
|
|
418
|
+
// **原子**(2026-09-30 复核 P2):行 + 事件 + 诊断日志**一次提交**,中途被杀不会只剩半截。
|
|
419
|
+
const recorded = store.transaction(() => {
|
|
420
|
+
if (!store.ensureSkipped(missedId, task.id, prevLogical, prevIso))
|
|
421
|
+
return false;
|
|
422
|
+
store.appendEvent(missedId, 'missed-slot', { scheduledAt: prevIso, reason: detail });
|
|
423
|
+
store.appendLog({
|
|
424
|
+
taskId: task.id,
|
|
425
|
+
scheduledAt: prevIso,
|
|
426
|
+
level: 'error',
|
|
427
|
+
kind: 'missed-slot',
|
|
428
|
+
message: `上一刻度未执行,已补记一条记录:${detail}`,
|
|
429
|
+
});
|
|
430
|
+
return true;
|
|
431
|
+
});
|
|
432
|
+
// 内存运行态顺手更新(否则卡片要等重启 rebuild 才显示这条)——**放事务外**,提交成功才认。
|
|
433
|
+
if (recorded)
|
|
434
|
+
runtime?.markTerminal(task.id, 'skipped', prevIso, new Date().toISOString());
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// 同步预条件:依赖 + 工作区(不过 ⇒ 不建行、记日志)
|
|
438
|
+
const depVerdict = judgeDependencies(store, task, slot.logicalDate, slot.scheduledAtIso, upstreams);
|
|
439
|
+
if (!depVerdict.ready) {
|
|
440
|
+
logBlocked(verdictLog, store, task.id, slot.scheduledAtIso, depVerdict.reason);
|
|
441
|
+
// 顺手把「被什么挡住」透给运行态(决策 54 · P3b:延期悬浮说明要能说出**具体原因**)。
|
|
442
|
+
// 只加展示数据、**不改调度语义**;放行时(下面 verdictLog.delete 处)清空。
|
|
443
|
+
runtime?.markBlocked(task.id, depVerdict.reason === undefined
|
|
444
|
+
? '被前置任务挡住'
|
|
445
|
+
: (BLOCK_KIND[depVerdict.reason]?.message ?? '被前置任务挡住'));
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
// 复用旧产出:只告警,不拦(决策 33 已知风险)
|
|
449
|
+
for (const note of depVerdict.staleNotes) {
|
|
450
|
+
store.appendLog({ taskId: task.id, scheduledAt: slot.scheduledAtIso, level: 'warn', kind: 'stale-upstream', message: note });
|
|
451
|
+
}
|
|
452
|
+
let workspace;
|
|
453
|
+
try {
|
|
454
|
+
workspace = resolveWorkspace(ctx, task.target.workspace);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
const workspaceMissing = `工作区未找到:${task.target.workspace}`;
|
|
458
|
+
// 任务级错误 ⇒ **当场写一条执行记录**(同附件分支)。日志也收进「结论变化才记」——
|
|
459
|
+
// 2026-09-30 评审 P0:本分支此前**每 tick 一条**、且漏了 markBlocked。
|
|
460
|
+
if (verdictLog.get(task.id) !== 'workspace-missing') {
|
|
461
|
+
verdictLog.set(task.id, 'workspace-missing');
|
|
462
|
+
recordTaskError(store, runtime, task, slot, workspaceMissing);
|
|
463
|
+
store.appendLog({ taskId: task.id, scheduledAt: slot.scheduledAtIso, level: 'error', kind: 'precondition', message: workspaceMissing });
|
|
464
|
+
}
|
|
465
|
+
// 透出原因(决策 54 · P3b):工作区找不到也是**任务级错误**,用户必须知道是哪一个工作区。
|
|
466
|
+
runtime?.markBlocked(task.id, workspaceMissing);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
// 附加文件校验(2026-09-30):缺失 ⇒ **不建行、不执行**,只记 error。
|
|
470
|
+
// 与「预条件不过不建行」同源;且不判 failed——重试也不会自己长出来,白耗重试额度。
|
|
471
|
+
// 日志走「结论变化才记」的同一张签名表,缺着不修就不会每 tick 刷一条。
|
|
472
|
+
const missing = missingAttachments(ctx, task, workspace.path, assets);
|
|
473
|
+
if (missing.length > 0) {
|
|
474
|
+
const detail = `附加文件不存在:${missing.join('、')}`;
|
|
475
|
+
if (verdictLog.get(task.id) !== 'attachment-missing') {
|
|
476
|
+
verdictLog.set(task.id, 'attachment-missing');
|
|
477
|
+
// 任务级错误 ⇒ **当场写一条执行记录**(用户 2026-09-30 拍板)。放在「结论变化才记」里,
|
|
478
|
+
// 同一阻塞段只写一次;同一槽被多轮 tick 看到也由唯一键幂等。
|
|
479
|
+
recordTaskError(store, runtime, task, slot, detail);
|
|
480
|
+
store.appendLog({
|
|
481
|
+
taskId: task.id,
|
|
482
|
+
scheduledAt: slot.scheduledAtIso,
|
|
483
|
+
level: 'error',
|
|
484
|
+
kind: 'attachment-missing',
|
|
485
|
+
message: `${detail}(请重新上传或移除该附件)`,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// 透出原因(决策 54 · P3b):附件找不到属**任务级错误**,用户必须知道是哪一个文件。
|
|
489
|
+
runtime?.markBlocked(task.id, detail);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
// 全部预条件通过 ⇒ 清掉阻塞 / 缺附件签名,下次再卡住能重新记一条;顺带清掉展示用的阻塞原因。
|
|
493
|
+
verdictLog.delete(task.id);
|
|
494
|
+
runtime?.markBlocked(task.id, null);
|
|
495
|
+
// 懒建行(同步):直接 dispatched + 派发快照,杜绝提前 pending / 未来预建。
|
|
496
|
+
// 决策 41:**落库即止**——发动执行是 Loop B(reconciler.sweep)的事,本循环到此为止。
|
|
497
|
+
const id = randomUUID();
|
|
498
|
+
if (!store.ensureInstance(id, task.id, slot.logicalDate, slot.scheduledAtIso, 'dispatched', snapshotOf(task, workspace, depVerdict.resolved)))
|
|
499
|
+
continue; // 撞唯一索引(极少)
|
|
500
|
+
// 主界面运行态(内存,非真源):刚落库 ⇒ 该任务在飞。Loop B 收口时会由 markTerminal 清除。
|
|
501
|
+
runtime?.markDispatched(task.id, slot.scheduledAtIso);
|
|
502
|
+
logger.info(`已落库执行记录 ${id}(任务 ${task.id} · ${slot.scheduledAtIso}),发动由执行循环接管(决策 41)`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
export function createScheduler(opts) {
|
|
506
|
+
const { ctx, logger, store, reconciler, config, assets, runtime } = opts;
|
|
507
|
+
const verdictLog = new Map();
|
|
508
|
+
let taskMap = new Map();
|
|
509
|
+
/** 进程启动时刻(决策 54):补记「未执行」的门禁 —— 停机期间漏掉的槽**不补**。 */
|
|
510
|
+
const startedAtMs = Date.now();
|
|
511
|
+
return {
|
|
512
|
+
tick() {
|
|
513
|
+
const cfg = config();
|
|
514
|
+
// 全量(含停用)解析一份 ⇒ 依赖判定才能分清「上游停用」和「上游已删除」
|
|
515
|
+
// (enabled 的 taskMap 里根本没有停用任务,误把停用报成"已删除",评审 P1#4)。
|
|
516
|
+
const allTasks = loadTasks(logger, cfg, true);
|
|
517
|
+
const tasks = allTasks.filter((t) => t.enabled);
|
|
518
|
+
const upstreams = new Map(allTasks.map((t) => [t.id, t]));
|
|
519
|
+
// 快照(getTasks)必须含停用任务:停用只是「暂不开跑」,仍是合法前置候选
|
|
520
|
+
// (设计约定:设前置不受启停影响)。派发只用 `tasks`(已按 enabled 过滤),
|
|
521
|
+
// 故这里把全量写进 taskMap,让前端前置列表能选到停用任务。
|
|
522
|
+
taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
|
523
|
+
// Loop A:先处理新刻度(懒建行 + 不回看 + 不补跑 + 落库即止,决策 41)
|
|
524
|
+
dispatchNewSlots(ctx, logger, store, tasks, verdictLog, upstreams, assets === undefined ? null : assets(), runtime ?? null, startedAtMs);
|
|
525
|
+
// Loop B:再收口全部执行记录(发动本 tick 新落库的行 + 追问 / 重试 / 租约——
|
|
526
|
+
// 只读执行记录 + 快照,决策 41)。放在 Loop A 之后 = 新行当 tick 即被发动,时延不退化。
|
|
527
|
+
reconciler.sweep();
|
|
528
|
+
// 保留期清除:诊断日志 30 天;执行记录**默认不清**(cfg.historyRetentionDays = 0 ⇒ 直接返回)
|
|
529
|
+
store.purgeLog(cfg.logRetentionDays ?? 30);
|
|
530
|
+
// 插件整体日志同策略(2026-10-07 新增 `plugin_log`,见 design/data-model.md §六)
|
|
531
|
+
store.purgePluginLog(cfg.logRetentionDays ?? 30);
|
|
532
|
+
// ⚠️ 执行记录**不清除**(用户 2026-10-08):历史是用来查的,`purgeHistory` 已删除。
|
|
533
|
+
// 上传临时区清理**不在这里**:index.ts 的 6 小时清道夫按自然日跑(purgeTmp 要遍历目录)。
|
|
534
|
+
},
|
|
535
|
+
getTasks() {
|
|
536
|
+
return taskMap;
|
|
537
|
+
},
|
|
538
|
+
/**
|
|
539
|
+
* 立即执行(2026-10-03 用户拍板)——**提前触发一次调度**,不改动任何既有语义:
|
|
540
|
+
*
|
|
541
|
+
* - **绕过 enabled**:按 id 从全量定义(含停用)取,不看开关;也不看「上次是否失败」。
|
|
542
|
+
* - **预条件与 Loop A 逐项同口径**(顺序一致):串行互斥 → 前置依赖 → 工作区 → 附件。
|
|
543
|
+
* 不过就**不建记录**(同正常调度),只是额外把原因回给前端(手动触发看不到日志)。
|
|
544
|
+
* - 落库形态与 `dispatchNewSlots` 一致:`dispatched` + 派发快照,**落库即止**;
|
|
545
|
+
* 发动交给下一轮 Loop B 的「发动②」(无会话的 dispatched 行)。
|
|
546
|
+
* ⚠️ 不用 `pending`:Loop B 的 `pending` 分支是「重试回退」专用(窗口外会被删行 +
|
|
547
|
+
* 记 stray_pending),手动行用当前时刻当刻度、窗口可能为 0 ⇒ 会被误删。
|
|
548
|
+
* - 打 `run_type='manual'`,并记一条诊断日志 + 审计。
|
|
549
|
+
*/
|
|
550
|
+
runNow(taskId) {
|
|
551
|
+
const allTasks = loadTasks(logger, config(), true);
|
|
552
|
+
const task = allTasks.find((t) => t.id === taskId);
|
|
553
|
+
if (task === undefined)
|
|
554
|
+
return { ok: false, error: 'task-not-found' };
|
|
555
|
+
// 串行互斥(与 Loop A 同集):同任务已有真正在飞实例 ⇒ 拒绝,防双跑(用户拍板)。
|
|
556
|
+
if (store.listByStatus(IN_FLIGHT_STATUSES).some((o) => o.task_id === task.id)) {
|
|
557
|
+
return { ok: false, error: 'already-running' };
|
|
558
|
+
}
|
|
559
|
+
const now = new Date();
|
|
560
|
+
const scheduledAtIso = now.toISOString();
|
|
561
|
+
const logicalDate = logicalDateOf(now, task.schedule.timezone);
|
|
562
|
+
// 先判前置(与 dispatchNewSlots 同顺序):未达标 ⇒ 不建记录,只把原因回给前端。
|
|
563
|
+
const upstreams = new Map(allTasks.map((t) => [t.id, t]));
|
|
564
|
+
const depVerdict = judgeDependencies(store, task, logicalDate, scheduledAtIso, upstreams);
|
|
565
|
+
if (!depVerdict.ready)
|
|
566
|
+
return { ok: false, error: depVerdict.reason ?? 'upstream-not-succeeded' };
|
|
567
|
+
let workspace;
|
|
568
|
+
try {
|
|
569
|
+
workspace = resolveWorkspace(ctx, task.target.workspace);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return { ok: false, error: 'workspace-missing', detail: task.target.workspace };
|
|
573
|
+
}
|
|
574
|
+
const missing = missingAttachments(ctx, task, workspace.path, assets === undefined ? null : assets());
|
|
575
|
+
if (missing.length > 0)
|
|
576
|
+
return { ok: false, error: 'attachment-missing', detail: missing.join('、') };
|
|
577
|
+
// 全部预条件通过 ⇒ 与懒建行同形态落库(dispatched + 快照),打 manual 标记。
|
|
578
|
+
const id = randomUUID();
|
|
579
|
+
if (!store.ensureInstance(id, task.id, logicalDate, scheduledAtIso, 'dispatched', snapshotOf(task, workspace, depVerdict.resolved), 'manual')) {
|
|
580
|
+
return { ok: false, error: 'duplicate' };
|
|
581
|
+
}
|
|
582
|
+
for (const note of depVerdict.staleNotes) {
|
|
583
|
+
store.appendLog({ taskId: task.id, scheduledAt: scheduledAtIso, level: 'warn', kind: 'stale-upstream', message: note });
|
|
584
|
+
}
|
|
585
|
+
runtime?.markDispatched(task.id, scheduledAtIso);
|
|
586
|
+
store.appendLog({
|
|
587
|
+
taskId: task.id, scheduledAt: scheduledAtIso, level: 'info', kind: 'manual-run',
|
|
588
|
+
message: '用户手动「立即执行」,已写入执行记录,等待执行循环发动',
|
|
589
|
+
});
|
|
590
|
+
store.appendAudit({ taskId: task.id, action: 'task_run_now', detail: { scheduledAt: scheduledAtIso } });
|
|
591
|
+
logger.info(`手动「立即执行」已落库执行记录 ${id}(任务 ${task.id} · ${scheduledAtIso}),发动由执行循环接管`);
|
|
592
|
+
return { ok: true, instanceId: id };
|
|
593
|
+
},
|
|
594
|
+
startupDiagnostics() {
|
|
595
|
+
const cfg = config();
|
|
596
|
+
const tasks = loadTasks(logger, cfg);
|
|
597
|
+
const nowMs = Date.now();
|
|
598
|
+
for (const task of tasks) {
|
|
599
|
+
if (task.enabled === false)
|
|
600
|
+
continue;
|
|
601
|
+
// 一次性任务**也读它自己的窗口**(2026-09-30 验收评审):原先这里写死 0 ⇒「窗口内、还没轮到
|
|
602
|
+
// 第一次 tick」的一次性任务会被误报成「错过 1 个刻度(不补跑)」。与 `dueSlot` 同口径:**出窗**才算错过。
|
|
603
|
+
let windowMs = 0;
|
|
604
|
+
try {
|
|
605
|
+
windowMs = durationMs(task.schedule.window);
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
windowMs = 0;
|
|
609
|
+
}
|
|
610
|
+
const lookback = Math.max(windowMs, 2 * 3600_000);
|
|
611
|
+
const from = new Date(nowMs - lookback);
|
|
612
|
+
const to = new Date(nowMs - windowMs);
|
|
613
|
+
let missed = 0;
|
|
614
|
+
try {
|
|
615
|
+
const slots = isOnce(task)
|
|
616
|
+
// 2026-09-30 二轮评审:这里原先用**宿主本地**的 `onceDate` ⇒ 任务时区与宿主不同时,
|
|
617
|
+
// 启动诊断会把「六小时后才该跑」的一次性任务误报成「错过 1 个刻度」。改用与派发/面板同口径。
|
|
618
|
+
? ((onceScheduledAt(task, (task.schedule.once ?? '').slice(0, 10))?.getTime() ?? Number.POSITIVE_INFINITY) < nowMs - windowMs
|
|
619
|
+
? [onceScheduledAt(task, (task.schedule.once ?? '').slice(0, 10))]
|
|
620
|
+
: [])
|
|
621
|
+
: scheduledSlotsFor(task, from, to);
|
|
622
|
+
for (const s of slots) {
|
|
623
|
+
if (store.findBySlot(task.id, s.toISOString()) === undefined)
|
|
624
|
+
missed++;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (missed > 0) {
|
|
631
|
+
store.appendLog({
|
|
632
|
+
taskId: task.id, level: 'info', kind: 'startup_missed',
|
|
633
|
+
message: `自 ${from.toISOString()} 起错过 ${missed} 个刻度(不补跑)`,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
}
|