dsh-task-dispatch-table 0.0.0 → 0.1.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.
- package/README.md +101 -31
- package/README.zh.md +119 -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,102 @@
|
|
|
1
|
+
import type { InstanceStatus, TaskStore } from './store.js';
|
|
2
|
+
import { type TaskDefinition } from './tasks.js';
|
|
3
|
+
/** 一条任务的运行态(派生)。 */
|
|
4
|
+
export interface TaskRuntimeEntry {
|
|
5
|
+
taskId: string;
|
|
6
|
+
running: boolean;
|
|
7
|
+
/** 本次运行开始(该实例的计划时刻)。 */
|
|
8
|
+
runningSince: string | null;
|
|
9
|
+
lastStatus: InstanceStatus | null;
|
|
10
|
+
lastScheduledAt: string | null;
|
|
11
|
+
lastFinishedAt: string | null;
|
|
12
|
+
nextSlotAt: string | null;
|
|
13
|
+
/**
|
|
14
|
+
* 「这一槽被什么挡住了」的人话原因(决策 54,P3b):由 Loop A 判定阻塞时顺手写入
|
|
15
|
+
* (`markBlocked`),只为展示;放行时清空。不进 `overviewKeyOf`(运行态,靠显式 rev 边沿)。
|
|
16
|
+
*/
|
|
17
|
+
blockedReason?: string | null;
|
|
18
|
+
/** 展示指纹(`overviewKeyOf`):变了 ⇒ rev 自增 ⇒ 客户端必拿到新数据。 */
|
|
19
|
+
overviewKey?: string;
|
|
20
|
+
/** 排期指纹(`scheduleKeyOf`):变了才重算 nextSlotAt。 */
|
|
21
|
+
scheduleKey?: string;
|
|
22
|
+
}
|
|
23
|
+
/** 主界面一行(定义投影 + 运行态),客户端拿它直接渲染卡片(含展开区)。 */
|
|
24
|
+
export interface TaskOverviewRow {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
code: string | null;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
workspace: string;
|
|
30
|
+
createdAt: string | null;
|
|
31
|
+
model: string | null;
|
|
32
|
+
retryMax: number;
|
|
33
|
+
/** 排期原值(卡片「执行方式」人话由客户端按它生成,服务端不做 i18n)。 */
|
|
34
|
+
schedule: {
|
|
35
|
+
cron: string | null;
|
|
36
|
+
once: string | null;
|
|
37
|
+
timezone: string | null;
|
|
38
|
+
start: string | null;
|
|
39
|
+
everyNWeeks: number | null;
|
|
40
|
+
window: string;
|
|
41
|
+
/**
|
|
42
|
+
* 结构化排期(新建 / 编辑时双写的 `schedule.ui`,2026-09-30 用户拍板):
|
|
43
|
+
* 卡片的「执行方式」人话与编辑器「预计执行」**同源**(都走 `client/schedule-text.ts`),
|
|
44
|
+
* 不再各处各写一份 ⇒ 老任务为 null,客户端退回从 cron 反解。
|
|
45
|
+
*/
|
|
46
|
+
ui: Record<string, unknown> | null;
|
|
47
|
+
};
|
|
48
|
+
/** 提示词首段(展开区展示用,服务端截断,不传全文)。 */
|
|
49
|
+
promptHead: string;
|
|
50
|
+
/**
|
|
51
|
+
* 附件展示行。`path` / `anchorSessionId` 由 HTTP 层(`attachmentsWithPaths`)补:
|
|
52
|
+
* 绝对路径 + 预览锚点会话;拿不到就不带(前端保持不可点)。
|
|
53
|
+
*/
|
|
54
|
+
attachments: Array<{
|
|
55
|
+
name: string;
|
|
56
|
+
kind: 'link' | 'upload';
|
|
57
|
+
path?: string;
|
|
58
|
+
anchorSessionId?: string;
|
|
59
|
+
}>;
|
|
60
|
+
depends: Array<{
|
|
61
|
+
id: string;
|
|
62
|
+
title: string;
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
}>;
|
|
65
|
+
running: boolean;
|
|
66
|
+
runningSince: string | null;
|
|
67
|
+
lastStatus: InstanceStatus | null;
|
|
68
|
+
lastScheduledAt: string | null;
|
|
69
|
+
lastFinishedAt: string | null;
|
|
70
|
+
nextSlotAt: string | null;
|
|
71
|
+
/** 「这一槽被什么挡住了」的人话原因(决策 54 · P3b):客户端在「延期」悬浮说明里补全。 */
|
|
72
|
+
blockedReason?: string | null;
|
|
73
|
+
}
|
|
74
|
+
export interface RuntimeIndex {
|
|
75
|
+
/** 启动 / 定义整批变更后重建(一条聚合 SQL + 逐任务算下一刻度);`nowMs` 供确定性测试。 */
|
|
76
|
+
rebuild(tasks: readonly TaskDefinition[], store: TaskStore | null, nowMs?: number): void;
|
|
77
|
+
/** Loop A 落库 dispatched(scheduler.ts)。 */
|
|
78
|
+
markDispatched(taskId: string, scheduledAt: string): void;
|
|
79
|
+
/** Loop B 实例进终态(reconcile.ts)。 */
|
|
80
|
+
markTerminal(taskId: string, status: InstanceStatus, scheduledAt: string, finishedAt: string): void;
|
|
81
|
+
/**
|
|
82
|
+
* 记录 / 清除「这一槽被什么挡住」(决策 54 · P3b:延期悬浮说明用;只展示,不参与调度)。
|
|
83
|
+
* **返回「是否真的变了」**(边沿)——调用方据此决定要不要广播;若不给这个信号,事件层只能
|
|
84
|
+
* 无条件发,每个 tick 每任务都会发一条无谓事件(2026-10-06 审计实证)。
|
|
85
|
+
*/
|
|
86
|
+
markBlocked(taskId: string, reason: string | null): boolean;
|
|
87
|
+
/** 实例行被删(窗口外 pending / 附件缺失)⇒ 该任务不再算在飞。**返回「是否真的变了」**。 */
|
|
88
|
+
clearRunning(taskId: string): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* **定义被改动的统一入口**(2026-09-30 抽象统一):任何写路径改完任务定义后调它一次即可——
|
|
91
|
+
* 重算展示指纹与下一刻度、按需 bump rev。调用方**不需要**再各自去碰内存条目。
|
|
92
|
+
*/
|
|
93
|
+
markDefinitionsChanged(tasks: readonly TaskDefinition[]): void;
|
|
94
|
+
/** 组装主界面数据(就地重算过期刻度,剔除已删任务的残留)。 */
|
|
95
|
+
overview(tasks: readonly TaskDefinition[], nowMs: number): {
|
|
96
|
+
rev: number;
|
|
97
|
+
rows: TaskOverviewRow[];
|
|
98
|
+
};
|
|
99
|
+
/** 内容版本:客户端带上一次的值做比对,未变即回 unchanged,省掉整份传输。 */
|
|
100
|
+
revision(): number;
|
|
101
|
+
}
|
|
102
|
+
export declare function createRuntimeIndex(): RuntimeIndex;
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { durationMs, nextSlotAfter, scheduledSlotsFor, titleOf } from './tasks.js';
|
|
2
|
+
/** 影响「下一刻度」的定义指纹(改了才重算;不动则每轮零成本)。 */
|
|
3
|
+
function scheduleKeyOf(task) {
|
|
4
|
+
const s = task.schedule;
|
|
5
|
+
return [
|
|
6
|
+
task.enabled ? '1' : '0',
|
|
7
|
+
s.cron ?? '',
|
|
8
|
+
s.once ?? '',
|
|
9
|
+
s.timezone ?? '',
|
|
10
|
+
s.start ?? '',
|
|
11
|
+
s.everyNWeeks === undefined ? '' : String(s.everyNWeeks),
|
|
12
|
+
// ⚠️ `window`(允许延迟)**必须在这里**(2026-10-07 审计 🟡):它是「该冻还是该前移」与候选槽
|
|
13
|
+
// 搜索的核心输入,改了它 nextSlotAt 就得重算。原先漏了它 ⇒ `overviewKey` 会 bump(界面拿到新行)
|
|
14
|
+
// 但 `nextSlotAt` 仍按**旧 window** 算出来 ⇒ 卡片「下次执行」与调度器实际判定分叉,直到刻度过期才自愈。
|
|
15
|
+
s.window ?? '',
|
|
16
|
+
].join('|');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* **展示指纹**(2026-09-30 抽象统一):主界面卡片上**看得见的任何字段**变了 ⇒ 指纹变 ⇒
|
|
20
|
+
* `rev` 自增 ⇒ 客户端下一次轮询必定拿到新数据。
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ 历史 bug:rev 过去只由排期 + 启停驱动(`scheduleKeyOf`),改标题 / 提示词 / 附件 / 工作区
|
|
23
|
+
* 等一律不 bump ⇒ 服务端一直回 `unchanged` ⇒ 界面永远不刷新。现在两个指纹**分工**:
|
|
24
|
+
* - `overviewKeyOf`(本函数):管 rev —— 覆盖面 = 卡片全部展示字段;
|
|
25
|
+
* - `scheduleKeyOf`:只管「要不要重算 nextSlotAt」—— 覆盖面 = 排期 + 启停(算刻度只依赖这些)。
|
|
26
|
+
*/
|
|
27
|
+
function overviewKeyOf(task) {
|
|
28
|
+
const s = task.schedule;
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
title: task.title ?? '',
|
|
31
|
+
code: task.code ?? '',
|
|
32
|
+
enabled: task.enabled,
|
|
33
|
+
createdAt: task.createdAt ?? '',
|
|
34
|
+
workspace: task.target.workspace,
|
|
35
|
+
provider: task.target.provider ?? '',
|
|
36
|
+
model: task.target.model ?? '',
|
|
37
|
+
prompt: task.target.prompt,
|
|
38
|
+
schedule: { cron: s.cron ?? '', once: s.once ?? '', timezone: s.timezone ?? '', start: s.start ?? '', everyNWeeks: s.everyNWeeks ?? 0, window: s.window, ui: s.ui ?? null },
|
|
39
|
+
retry: task.retry?.maxAttempts ?? 1,
|
|
40
|
+
attachments: (task.attachments ?? []).map(a => `${a.name}:${a.kind}:${a.ref}`),
|
|
41
|
+
depends: (task.depends_on ?? []).map(d => d.task),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** 提示词首段(按字符截断,中文不按字节切)。 */
|
|
45
|
+
const PROMPT_HEAD_CHARS = 120;
|
|
46
|
+
const headOf = (text) => (text.length <= PROMPT_HEAD_CHARS ? text : `${text.slice(0, PROMPT_HEAD_CHARS)}…`);
|
|
47
|
+
export function createRuntimeIndex() {
|
|
48
|
+
const entries = new Map();
|
|
49
|
+
let rev = 1;
|
|
50
|
+
const entryOf = (taskId) => {
|
|
51
|
+
const hit = entries.get(taskId);
|
|
52
|
+
if (hit !== undefined)
|
|
53
|
+
return hit;
|
|
54
|
+
const created = {
|
|
55
|
+
taskId,
|
|
56
|
+
running: false,
|
|
57
|
+
runningSince: null,
|
|
58
|
+
lastStatus: null,
|
|
59
|
+
lastScheduledAt: null,
|
|
60
|
+
lastFinishedAt: null,
|
|
61
|
+
nextSlotAt: null,
|
|
62
|
+
};
|
|
63
|
+
entries.set(taskId, created);
|
|
64
|
+
return created;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* 单个任务的刻度计算异常(非法 cron 等)不能连累整份列表。
|
|
68
|
+
* 停用任务**没有下次执行时刻** ⇒ 不给刻度(用户 2026-09-30:关掉了还在算几点跑是错的)。
|
|
69
|
+
*/
|
|
70
|
+
const computeNext = (task, nowMs) => {
|
|
71
|
+
if (task.enabled === false)
|
|
72
|
+
return null;
|
|
73
|
+
try {
|
|
74
|
+
const next = nextSlotAfter(task, new Date(nowMs));
|
|
75
|
+
return next === undefined ? null : next.toISOString();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* 窗口内「最晚且已到点」的那一槽(**纯计算、不查库**):重启 / 改任务之后用来找回「本该被冻住的刻度」。
|
|
83
|
+
* 水位(`runningSince` / `lastScheduledAt`)已由 `rebuild` 的两条聚合 SQL 填好 ⇒ 这里只做 cron 数学。
|
|
84
|
+
* 口径与 `scheduler.dueSlot` 一致(`[now - window, now]` 里取最晚一条);没有候选(未来才到点 /
|
|
85
|
+
* 停用 / 解析失败)⇒ `null`。
|
|
86
|
+
*/
|
|
87
|
+
const latestDueSlotIso = (task, nowMs) => {
|
|
88
|
+
if (task.enabled === false)
|
|
89
|
+
return null;
|
|
90
|
+
// `once`(决策 18):那一刻不随窗口前移 ⇒ 直接问 `computeNext`(过去 / 未来都可能拿到)。
|
|
91
|
+
if (task.schedule.once !== undefined) {
|
|
92
|
+
const onceIso = computeNext(task, nowMs);
|
|
93
|
+
if (onceIso === null)
|
|
94
|
+
return null;
|
|
95
|
+
const onceMs = Date.parse(onceIso);
|
|
96
|
+
return Number.isFinite(onceMs) && onceMs <= nowMs ? onceIso : null;
|
|
97
|
+
}
|
|
98
|
+
// window 防御取值(对齐本文件「畸形定义也不能让它崩」的约定):解析失败 ⇒ 0 ⇒ 视作无窗口。
|
|
99
|
+
let windowMs = 0;
|
|
100
|
+
try {
|
|
101
|
+
windowMs = durationMs(task.schedule.window);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
windowMs = 0;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
let latest;
|
|
108
|
+
for (const s of scheduledSlotsFor(task, new Date(nowMs - windowMs), new Date(nowMs + 1_000))) {
|
|
109
|
+
if (s.getTime() > nowMs)
|
|
110
|
+
continue;
|
|
111
|
+
latest = s;
|
|
112
|
+
}
|
|
113
|
+
return latest === undefined ? null : latest.toISOString();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* **「这一槽处理了吗」闸门抽成单点,三处复用**(2026-09-30 复核 P1)。
|
|
121
|
+
*
|
|
122
|
+
* 语义:`entry.nextSlotAt` 若**已到点、尚未处理、且仍在窗口内** ⇒ **冻结**(返回 `true`,刻度不动 ⇒
|
|
123
|
+
* 卡片显示「到点未派发 / 延期」,且它是过去时刻 ⇒ 在该组「按 `nextSlotAt` 升序」里**自然排最前**,
|
|
124
|
+
* 不再需要客户端那套本地「到点钳位」);否则前移到下一个**未来**刻度(`once` ⇒ `null`),
|
|
125
|
+
* 刻度真变了才 `rev++`(边沿触发,`unchanged` 优化不受影响)。
|
|
126
|
+
*
|
|
127
|
+
* 判定走**内存水位、不查库**:`runningSince`(在飞那一槽)/ `lastScheduledAt`(最近一条非在飞行的槽,
|
|
128
|
+
* **含 pending / skipped / unknown**)任一 ≥ 该槽 ⇒ 已处理。
|
|
129
|
+
*
|
|
130
|
+
* 上界论证(为什么不会永久冻结):冻结持续 ⟺ `¬handled ∧ now ≤ 槽 + window` ⇒ **最长一个 window**。
|
|
131
|
+
* 少了这道闸门就是**排序抖动的根源**:读时无脑前移会让排序键从「刚过去的时刻」跳到「+一个间隔」
|
|
132
|
+
* ⇒ 卡片在组内掉到后面,等 `running` 翻转又跳回最前(真机「先掉下去、再砰地跳回来」)。
|
|
133
|
+
*
|
|
134
|
+
* 三处调用点差别只在「进来之前 `entry.nextSlotAt` 是什么」:`rebuild` / `markDefinitionsChanged`
|
|
135
|
+
* 先用 `latestDueSlotIso` 找回候选,`overview` 走现值的懒前移。
|
|
136
|
+
*/
|
|
137
|
+
const freezeOrAdvance = (entry, task, nowMs) => {
|
|
138
|
+
const slotIso = entry.nextSlotAt;
|
|
139
|
+
if (slotIso === null)
|
|
140
|
+
return false;
|
|
141
|
+
const slotMs = Date.parse(slotIso);
|
|
142
|
+
if (!Number.isFinite(slotMs) || slotMs > nowMs)
|
|
143
|
+
return false; // 还没到点(或刻度坏)⇒ 没有可冻的东西
|
|
144
|
+
const watermarks = [entry.runningSince, entry.lastScheduledAt]
|
|
145
|
+
.map(iso => (iso === null ? Number.NEGATIVE_INFINITY : Date.parse(iso)))
|
|
146
|
+
.filter(ms => Number.isFinite(ms));
|
|
147
|
+
const handled = watermarks.some(ms => ms >= slotMs);
|
|
148
|
+
let windowMs = 0;
|
|
149
|
+
try {
|
|
150
|
+
windowMs = durationMs(task.schedule.window);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
windowMs = 0;
|
|
154
|
+
}
|
|
155
|
+
if (!handled && nowMs <= slotMs + windowMs)
|
|
156
|
+
return true; // 冻结:未处理且仍在窗口内
|
|
157
|
+
// 已处理 / 已出窗口 ⇒ 前移。`once` 且已收尾 ⇒ **没有下次了**(`nextSlotAfter` 对 once 恒返回那个
|
|
158
|
+
// 过去时刻,不置 null 就会永远钉在组 1 最前、永远显示「即将执行」)。
|
|
159
|
+
const next = task.schedule.once !== undefined ? null : computeNext(task, nowMs);
|
|
160
|
+
if (next === entry.nextSlotAt)
|
|
161
|
+
return false;
|
|
162
|
+
entry.nextSlotAt = next;
|
|
163
|
+
rev++;
|
|
164
|
+
return false;
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
rebuild(tasks, store, nowMs = Date.now()) {
|
|
168
|
+
entries.clear();
|
|
169
|
+
if (store !== null) {
|
|
170
|
+
for (const [taskId, last] of store.lastRunByTask()) {
|
|
171
|
+
const entry = entryOf(taskId);
|
|
172
|
+
entry.lastStatus = last.status;
|
|
173
|
+
entry.lastScheduledAt = last.scheduledAt;
|
|
174
|
+
entry.lastFinishedAt = last.finishedAt;
|
|
175
|
+
}
|
|
176
|
+
// 启动时**用库里在飞行的行重建**:这些是重启后的真实孤儿(U1),一次扫描几十行;
|
|
177
|
+
// 真卡死的由 unknown 宽限收口,届时 markTerminal 会把 running 清掉。
|
|
178
|
+
for (const [taskId, since] of store.inFlightByTask()) {
|
|
179
|
+
const entry = entryOf(taskId);
|
|
180
|
+
entry.running = true;
|
|
181
|
+
entry.runningSince = since;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const task of tasks) {
|
|
185
|
+
const entry = entryOf(task.id);
|
|
186
|
+
entry.overviewKey = overviewKeyOf(task);
|
|
187
|
+
entry.scheduleKey = scheduleKeyOf(task);
|
|
188
|
+
// ⚠️ 2026-09-30 复核 P1:这里原先**无条件**取「下一个未来刻度」⇒ 重启后卡片上「到点未派发 /
|
|
189
|
+
// 延期」会整段消失(与 Loop A 的实际派发不一致)。先纯计算找回「窗口内最晚且已到点」那一槽
|
|
190
|
+
// (水位上面已填好,零新增查询),再交给同一道闸门判「该冻还是该前移」。
|
|
191
|
+
entry.nextSlotAt = latestDueSlotIso(task, nowMs) ?? computeNext(task, nowMs);
|
|
192
|
+
freezeOrAdvance(entry, task, nowMs);
|
|
193
|
+
}
|
|
194
|
+
rev++; // 整批重建一律 bump(闸门内部那次边沿 bump 不必区分)
|
|
195
|
+
},
|
|
196
|
+
markDispatched(taskId, scheduledAt) {
|
|
197
|
+
const entry = entryOf(taskId);
|
|
198
|
+
entry.running = true;
|
|
199
|
+
entry.runningSince = entry.runningSince ?? scheduledAt;
|
|
200
|
+
// ⚠️ **不覆盖「上次执行」**(用户 2026-09-30):上次 = 最近一次**出了结果**的执行,
|
|
201
|
+
// 该成功还是成功、该失败还是失败;正在跑的这一趟要等 finishTerminal 出结果后才覆盖。
|
|
202
|
+
rev++;
|
|
203
|
+
},
|
|
204
|
+
markTerminal(taskId, status, scheduledAt, finishedAt) {
|
|
205
|
+
const entry = entryOf(taskId);
|
|
206
|
+
entry.running = false;
|
|
207
|
+
entry.runningSince = null;
|
|
208
|
+
entry.lastStatus = status;
|
|
209
|
+
entry.lastScheduledAt = scheduledAt;
|
|
210
|
+
entry.lastFinishedAt = finishedAt;
|
|
211
|
+
// 终态 ⇒ **这一槽已收尾,不再「被挡住」**(2026-09-30 复核 P1 兜底)。调用顺序安全:
|
|
212
|
+
// `recordTaskError` 内部先写行再 `markTerminal`,而 `markBlocked` 一律排在其后 ⇒ 原因不会被误清。
|
|
213
|
+
entry.blockedReason = null;
|
|
214
|
+
rev++;
|
|
215
|
+
},
|
|
216
|
+
clearRunning(taskId) {
|
|
217
|
+
const entry = entries.get(taskId);
|
|
218
|
+
if (entry === undefined || !entry.running)
|
|
219
|
+
return false;
|
|
220
|
+
entry.running = false;
|
|
221
|
+
entry.runningSince = null;
|
|
222
|
+
rev++;
|
|
223
|
+
return true;
|
|
224
|
+
},
|
|
225
|
+
/**
|
|
226
|
+
* 记录 / 清除「这一槽被什么挡住」(决策 54 · P3b):Loop A 判阻塞时写人话原因,放行时传 `null` 清。
|
|
227
|
+
* **边沿触发** rev:值真变了才 bump(否则每 tick 都整份重发,`unchanged` 优化报废)。
|
|
228
|
+
* 值变化随下一份完整 overview 送达客户端(`unchanged` 响应不带行)。
|
|
229
|
+
*/
|
|
230
|
+
markBlocked(taskId, reason) {
|
|
231
|
+
const entry = entryOf(taskId);
|
|
232
|
+
const next = reason === null || reason === '' ? null : reason;
|
|
233
|
+
if ((entry.blockedReason ?? null) === next)
|
|
234
|
+
return false;
|
|
235
|
+
entry.blockedReason = next;
|
|
236
|
+
rev++;
|
|
237
|
+
return true;
|
|
238
|
+
},
|
|
239
|
+
markDefinitionsChanged(tasks) {
|
|
240
|
+
const nowMs = Date.now();
|
|
241
|
+
for (const task of tasks) {
|
|
242
|
+
const entry = entryOf(task.id);
|
|
243
|
+
const overviewKey = overviewKeyOf(task);
|
|
244
|
+
const scheduleKey = scheduleKeyOf(task);
|
|
245
|
+
if (entry.overviewKey !== overviewKey) {
|
|
246
|
+
entry.overviewKey = overviewKey;
|
|
247
|
+
rev++;
|
|
248
|
+
}
|
|
249
|
+
if (entry.scheduleKey !== scheduleKey) {
|
|
250
|
+
entry.scheduleKey = scheduleKey;
|
|
251
|
+
// 改排期 / 启停之后同样先找回「窗口内最晚且已到点」的刻度(新启用的任务往往当场就有该跑的槽),
|
|
252
|
+
// 再交给同一道闸门判「该冻还是该前移」(2026-09-30 复核 P1)。
|
|
253
|
+
entry.nextSlotAt = latestDueSlotIso(task, nowMs) ?? computeNext(task, nowMs);
|
|
254
|
+
freezeOrAdvance(entry, task, nowMs);
|
|
255
|
+
rev++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
overview(tasks, nowMs) {
|
|
260
|
+
const byId = new Map(tasks.map(task => [task.id, task]));
|
|
261
|
+
const rows = [];
|
|
262
|
+
for (const task of tasks) {
|
|
263
|
+
const entry = entryOf(task.id);
|
|
264
|
+
// ① 展示指纹变了(标题 / 提示词 / 附件 / 工作区 … 任何看得见的字段)⇒ 必须让客户端拿到新数据。
|
|
265
|
+
const overviewKey = overviewKeyOf(task);
|
|
266
|
+
if (entry.overviewKey !== overviewKey) {
|
|
267
|
+
entry.overviewKey = overviewKey;
|
|
268
|
+
rev++;
|
|
269
|
+
}
|
|
270
|
+
// ② 排期 / 启停变了 ⇒ 重算下一刻度(用户改完必须立刻看到新时间)。
|
|
271
|
+
const scheduleKey = scheduleKeyOf(task);
|
|
272
|
+
if (entry.scheduleKey !== scheduleKey) {
|
|
273
|
+
entry.scheduleKey = scheduleKey;
|
|
274
|
+
entry.nextSlotAt = latestDueSlotIso(task, nowMs) ?? computeNext(task, nowMs);
|
|
275
|
+
freezeOrAdvance(entry, task, nowMs);
|
|
276
|
+
rev++;
|
|
277
|
+
}
|
|
278
|
+
else if (entry.nextSlotAt !== null && Date.parse(entry.nextSlotAt) <= nowMs) {
|
|
279
|
+
// 刻度已过(时间自然推进)⇒ 交给**统一闸门**:未处理且仍在窗口内 ⇒ 冻结不前移(键稳定 ⇒ 不抖,
|
|
280
|
+
// 且它是过去时刻 ⇒ 组内自然排最前);已处理 / 出窗口 ⇒ 前移(`once` ⇒ `null`)。
|
|
281
|
+
// 判定细节、为什么不查库、以及「少了这道闸门就是排序抖动根源」的完整论证,
|
|
282
|
+
// 全部收在 `freezeOrAdvance` 的文档注释里(三处共用同一份口径,别再就地复刻)。
|
|
283
|
+
freezeOrAdvance(entry, task, nowMs);
|
|
284
|
+
}
|
|
285
|
+
rows.push({
|
|
286
|
+
id: task.id,
|
|
287
|
+
title: titleOf(task),
|
|
288
|
+
code: task.code === undefined || task.code.trim() === '' ? null : task.code.trim(),
|
|
289
|
+
enabled: task.enabled,
|
|
290
|
+
workspace: task.target.workspace,
|
|
291
|
+
createdAt: task.createdAt ?? null,
|
|
292
|
+
model: task.target.model ?? null,
|
|
293
|
+
// 防御取值:overview 是展示面,畸形定义也不能让它崩(zod 有默认值的字段仍按可选读)。
|
|
294
|
+
retryMax: task.retry?.maxAttempts ?? 1,
|
|
295
|
+
schedule: {
|
|
296
|
+
cron: task.schedule.cron ?? null,
|
|
297
|
+
once: task.schedule.once ?? null,
|
|
298
|
+
timezone: task.schedule.timezone ?? null,
|
|
299
|
+
start: task.schedule.start ?? null,
|
|
300
|
+
everyNWeeks: task.schedule.everyNWeeks ?? null,
|
|
301
|
+
// 防御取值:overview 是展示面,畸形定义也不能让它崩(zod 有默认值的字段仍按可选读)。
|
|
302
|
+
window: task.schedule.window ?? 'PT0S',
|
|
303
|
+
ui: task.schedule.ui ?? null,
|
|
304
|
+
},
|
|
305
|
+
promptHead: headOf(task.target.prompt),
|
|
306
|
+
attachments: (task.attachments ?? []).map(item => ({ name: item.name, kind: item.kind })),
|
|
307
|
+
depends: (task.depends_on ?? []).map(dep => {
|
|
308
|
+
const upstream = byId.get(dep.task);
|
|
309
|
+
return {
|
|
310
|
+
id: dep.task,
|
|
311
|
+
title: upstream === undefined ? dep.task : titleOf(upstream),
|
|
312
|
+
enabled: upstream?.enabled ?? false,
|
|
313
|
+
};
|
|
314
|
+
}),
|
|
315
|
+
running: entry.running,
|
|
316
|
+
runningSince: entry.runningSince,
|
|
317
|
+
lastStatus: entry.lastStatus,
|
|
318
|
+
lastScheduledAt: entry.lastScheduledAt,
|
|
319
|
+
lastFinishedAt: entry.lastFinishedAt,
|
|
320
|
+
nextSlotAt: entry.nextSlotAt,
|
|
321
|
+
// 「被什么挡住」(决策 54 · P3b):客户端只在「延期」悬浮说明里用它补全原因。
|
|
322
|
+
blockedReason: entry.blockedReason ?? null,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
// 已删任务的残留条目:任务表里没了就别再占着内存。
|
|
326
|
+
if (entries.size > byId.size) {
|
|
327
|
+
for (const taskId of [...entries.keys()]) {
|
|
328
|
+
if (byId.has(taskId))
|
|
329
|
+
continue;
|
|
330
|
+
entries.delete(taskId);
|
|
331
|
+
rev++;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { rev, rows };
|
|
335
|
+
},
|
|
336
|
+
revision() {
|
|
337
|
+
return rev;
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 这些函数实际只读 `task.schedule` 的这五个字段(服务端定义 / 客户端草稿组装物都可喂)。
|
|
3
|
+
* ⚠️ 字段可选性照 `taskDefinitionSchema` 抄:`everyNWeeks` 可空。
|
|
4
|
+
*/
|
|
5
|
+
export interface ScheduleNextSchedule {
|
|
6
|
+
cron?: string;
|
|
7
|
+
timezone?: string;
|
|
8
|
+
once?: string;
|
|
9
|
+
start?: string;
|
|
10
|
+
everyNWeeks?: number | null;
|
|
11
|
+
}
|
|
12
|
+
/** 最小任务形状:只要求带 `schedule`。 */
|
|
13
|
+
export interface ScheduleNextTask {
|
|
14
|
+
schedule: ScheduleNextSchedule;
|
|
15
|
+
}
|
|
16
|
+
/** 取某时刻在指定时区的日历日(logical date 归属用计划时刻,state-machine §9)。 */
|
|
17
|
+
export declare function logicalDateOf(date: Date, timeZone: string | undefined): string;
|
|
18
|
+
/**
|
|
19
|
+
* cron 在 `[from, to)` 区间内的**全部刻度**(决策 25 的核心改动)。
|
|
20
|
+
*
|
|
21
|
+
* 旧实现 `scheduledAtFor` 按「天」只取第一个匹配 ⇒ **每小时 / 每几分钟的 cron 一天只能出
|
|
22
|
+
* 一条**,是功能缺陷。刻度**由 cron 表达式决定**,不是「上一次 + 间隔」⇒ 迟到 / 重启 /
|
|
23
|
+
* 多跑几轮都不会漂移(8:01 才跑,记的仍是 `08:00` 这个槽)。
|
|
24
|
+
*
|
|
25
|
+
* @param cap 迭代上限(防御 cron 表达成极小间隔导致死循环);超出即截断。
|
|
26
|
+
*/
|
|
27
|
+
export declare function scheduledSlotsFor(task: ScheduleNextTask, from: Date, to: Date, cap?: number): Date[];
|
|
28
|
+
/**
|
|
29
|
+
* 周期任务的锚点 / 步长过滤(「开始时间」+「每 N 周」):
|
|
30
|
+
* - `start`:首跑下界(不早于此时);也是「每 N 周」取模的参考周(start 所在周 = 第 0 周)。
|
|
31
|
+
* - `everyNWeeks`:每周档的重复步长。cron 没有「第几周」位 ⇒ 用「刻度与 start 的整周差 % N」
|
|
32
|
+
* 过滤,天然覆盖用户诉求:开始时间设下周一、每 2 周 ⇒ 下周一跑、下下周跳过、再下一周跑。
|
|
33
|
+
*/
|
|
34
|
+
export declare function filterSlotsBySchedule(task: ScheduleNextTask, slots: Date[]): Date[];
|
|
35
|
+
/** 给定时刻之后的下一个刻度(面板展示「下次执行」用)。 */
|
|
36
|
+
export declare function nextSlotAfter(task: ScheduleNextTask, from: Date): Date | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* 一次性任务的计划时刻(决策 18):`once` = "YYYY-MM-DDTHH:mm",
|
|
39
|
+
* 按 schedule.timezone 的墙上时间解释(缺省 = 宿主时区)。
|
|
40
|
+
* 仅当 once 的日期部分 == day 时返回该时刻,其余日期返回 undefined ——
|
|
41
|
+
* 实例身份 = task_id + logical_date,once 只有一个日期 ⇒ 只生成一条实例,
|
|
42
|
+
* 跑完(终态)后行已存在、日期已过 ⇒ 天然「跑完自动停」,次年不再触发。
|
|
43
|
+
*/
|
|
44
|
+
export declare function onceScheduledAt(task: ScheduleNextTask, day: string): Date | undefined;
|