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,641 @@
|
|
|
1
|
+
// 事件驱动对账(state-machine §1 判定树 + §3 转移表)——**Loop B(执行循环,决策 41)**:
|
|
2
|
+
// 只读执行记录 + 派发快照,全程不读任务表。职责:发动执行(无会话的 dispatched / 窗口内的
|
|
3
|
+
// pending 重试行)、session/created → running+租约、turn/end 与 session/disposed → 查回执收敛、
|
|
4
|
+
// tick 兜底扫描 sweep()。
|
|
5
|
+
// 回执机制(决策 19 + 24):agent 经插件注册的工具写 task_events 的 receipt 事件,对账只查库
|
|
6
|
+
// 不读文件;跑完信号后无回执 → 宽限 → 追问×2 → 按失败收敛。
|
|
7
|
+
// 唯一例外(决策 41 兼容口):旧库实例无快照列值时,按 legacyTask 当场合成快照并固化(一次性
|
|
8
|
+
// 兼容、带 warn)——兜底也不落库 ⇒ 无法发动 / 无法校验,如实按失败收敛,绝不静默。
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { displayNameOf, durationMs, sessionTitleOf } from './tasks.js';
|
|
12
|
+
import { dispatchTask, resolveWorkspace, resolveWorkspaceByPath, userNotice, DispatchPreconditionError, } from './dispatch.js';
|
|
13
|
+
import { receiptInstruction } from './receipt.js';
|
|
14
|
+
import { parseInstanceSnapshot } from './store.js';
|
|
15
|
+
import { EventType } from './event-catalog.js';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { attachmentAbsPath } from './task-assets.js';
|
|
18
|
+
/**
|
|
19
|
+
* 快照里的附加文件是否还在(Loop B 发动前兜底):返回**缺失**的展示名。
|
|
20
|
+
* upload 型按任务目录绝对路径(需 task_id),link 型按快照里冻结的工作区路径。
|
|
21
|
+
* 拿不到基准 ⇒ 跳过不判,绝不猜。
|
|
22
|
+
*/
|
|
23
|
+
export function missingSnapshotAttachments(ctx, taskId, snap, assets) {
|
|
24
|
+
const list = snap.attachments;
|
|
25
|
+
if (list === undefined || list.length === 0)
|
|
26
|
+
return [];
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const item of list) {
|
|
29
|
+
let abs = null;
|
|
30
|
+
if (item.kind === 'upload') {
|
|
31
|
+
abs = assets === null ? null : attachmentAbsPath(assets, taskId, item.ref);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// link 型按附件来源工作区解析(item.workspace 随快照冻结);来源解析不出 ⇒ 不判,绝不猜。
|
|
35
|
+
const source = item.workspace !== undefined && item.workspace.trim() !== '' ? item.workspace : null;
|
|
36
|
+
try {
|
|
37
|
+
abs = source === null ? join(snap.workspacePath, item.ref) : join(resolveWorkspace(ctx, source).path, item.ref);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
abs = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (abs === null || abs.includes('..'))
|
|
44
|
+
continue;
|
|
45
|
+
if (!existsSync(abs))
|
|
46
|
+
out.push(item.name);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** 跑完信号后允许补交回执的追问上限(写死不加配置,事件表可查次数)。 */
|
|
51
|
+
const NUDGE_LIMIT = 2;
|
|
52
|
+
/**
|
|
53
|
+
* 回执裁决(决策 19,替代旧契约文件三查):
|
|
54
|
+
* receipt 事件存在 + status ∈ validStatuses + outputs 里的每个路径**确实存在**。
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ **已去掉「mtime 新鲜度」闸**(用户 2026-10-03 拍板,原为「防旧产物冒充」):
|
|
57
|
+
* 那道闸会误伤「复用 / 检查已有文件」类任务 —— 真机案例:任务是判断 `uuid.txt`
|
|
58
|
+
* 是否存在(存在就不动它),agent 如实回执 `outputs:["uuid.txt"]`,但文件本来就在、
|
|
59
|
+
* 没被改写 ⇒ mtime 早于派发时刻 ⇒ 被判 `output-stale` 失败,用户看到的却是「文件明明在」。
|
|
60
|
+
*
|
|
61
|
+
* 用户口径(原话):「**只要他交出来的文件确实存在、格式是对的,就不用管**」;
|
|
62
|
+
* 「大模型是不是企图蒙混过关,你不用去管」——**任务做得怎么样是大模型的事,
|
|
63
|
+
* 插件只确认它确实执行了**。故只保留「存在性」一道闸,不再替模型判断产出新鲜度。
|
|
64
|
+
*
|
|
65
|
+
* status 仍必须如实 ∈ validStatuses(agent 自报不可信,决策 11)。
|
|
66
|
+
* 决策 41:workspacePath / validStatuses 来自派发快照,与任务设置无关。
|
|
67
|
+
*/
|
|
68
|
+
export function checkReceipt(workspacePath, validStatuses, receipt) {
|
|
69
|
+
if (receipt === undefined)
|
|
70
|
+
return { ok: false, reason: 'receipt-missing' };
|
|
71
|
+
let payload;
|
|
72
|
+
try {
|
|
73
|
+
payload = JSON.parse(receipt.detail ?? '{}');
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return { ok: false, reason: 'receipt-unreadable', detail: String(error) };
|
|
77
|
+
}
|
|
78
|
+
if (typeof payload.status !== 'string' || !validStatuses.includes(payload.status)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: 'receipt-status-invalid',
|
|
82
|
+
detail: { status: payload.status, validStatuses: [...validStatuses] },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const outputs = Array.isArray(payload.outputs) ? payload.outputs.filter((item) => typeof item === 'string') : [];
|
|
86
|
+
for (const output of outputs) {
|
|
87
|
+
const outputPath = resolve(workspacePath, output);
|
|
88
|
+
if (!existsSync(outputPath))
|
|
89
|
+
return { ok: false, reason: 'output-missing', detail: { output } };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, detail: payload };
|
|
92
|
+
}
|
|
93
|
+
function num(v) {
|
|
94
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 从会话事件里取 token 用量分量(决策 32 修订)。
|
|
98
|
+
*
|
|
99
|
+
* **权威来源已核实**(宿主 `@deepseek-ai/dsh-session` `lib/types/types.d.ts`):官方用量挂在
|
|
100
|
+
* `assistant/message` 事件的 `usage?: TokenUsage` 上(`@deepseek-ai/dsh-llm` `lib/types/types.d.ts:160`),
|
|
101
|
+
* 一次模型调用一条。**不需要我们自己推**——事件里带的就是官方算好的。
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ 官方 `TokenUsage` 的计数是**互斥的**(原文:*Counts are DISJOINT*):
|
|
104
|
+
* - `inputTokens` = **未缓存**输入(不含缓存);
|
|
105
|
+
* - 缓存单列 `cacheReadTokens` / `cacheWriteTokens`;
|
|
106
|
+
* - **计费输入 = inputTokens + cacheReadTokens + cacheWriteTokens**;
|
|
107
|
+
* - `totalTokens` = 这一次调用的完整总量(prompt + output)。
|
|
108
|
+
* ⇒ 只取 `inputTokens` 会**漏掉缓存**(大头)⇒ 面板数远小于会话。这就是之前对不上的根因。
|
|
109
|
+
*
|
|
110
|
+
* 取不到(事件不带 usage)返回 undefined(三列留 null,不阻塞链路)。
|
|
111
|
+
*/
|
|
112
|
+
export function extractTokenUsage(event) {
|
|
113
|
+
if (typeof event !== 'object' || event === null)
|
|
114
|
+
return undefined;
|
|
115
|
+
const root = event;
|
|
116
|
+
const holders = [
|
|
117
|
+
root.usage,
|
|
118
|
+
root.tokenUsage,
|
|
119
|
+
root.tokens,
|
|
120
|
+
root.data?.usage,
|
|
121
|
+
root.detail?.usage,
|
|
122
|
+
root.message?.usage,
|
|
123
|
+
];
|
|
124
|
+
for (const holder of holders) {
|
|
125
|
+
if (typeof holder !== 'object' || holder === null)
|
|
126
|
+
continue;
|
|
127
|
+
const u = holder;
|
|
128
|
+
const outOut = num(u.completionTokens) ?? num(u.outputTokens) ?? num(u.completion_tokens);
|
|
129
|
+
// 缓存读取(官方字段名 cacheReadTokens;沿用旧别名兜底)。
|
|
130
|
+
const cacheRead = num(u.cacheReadTokens) ?? num(u.cachedTokens) ?? num(u.cacheTokens) ?? num(u.cached_tokens)
|
|
131
|
+
?? num(u.promptTokensDetails?.cachedTokens)
|
|
132
|
+
?? num(u.prompt_tokens_details?.cached_tokens);
|
|
133
|
+
const cacheWrite = num(u.cacheWriteTokens);
|
|
134
|
+
const total = num(u.totalTokens);
|
|
135
|
+
// 输入(计费 prompt):
|
|
136
|
+
// ① `promptTokens` 这类命名通常**已含缓存** ⇒ 直接用;
|
|
137
|
+
// ② 否则按官方口径:**未缓存 inputTokens + cacheRead + cacheWrite**(互斥相加);
|
|
138
|
+
// ③ 都没有 ⇒ 用 `totalTokens − outputTokens` 兜底。
|
|
139
|
+
const legacyPrompt = num(u.promptTokens) ?? num(u.prompt_tokens);
|
|
140
|
+
const uncached = num(u.inputTokens) ?? num(u.uncachedInputTokens);
|
|
141
|
+
const inOut = legacyPrompt
|
|
142
|
+
?? (uncached !== undefined || cacheRead !== undefined || cacheWrite !== undefined
|
|
143
|
+
? (uncached ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0)
|
|
144
|
+
: undefined)
|
|
145
|
+
?? (total !== undefined && outOut !== undefined ? Math.max(0, total - outOut) : undefined);
|
|
146
|
+
// 三者任一有值才算取到(避免对空 usage 对象误报;只给总数无法归属则不记)
|
|
147
|
+
if (inOut !== undefined || outOut !== undefined || cacheRead !== undefined) {
|
|
148
|
+
return { in: inOut, out: outOut, cache: cacheRead };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
export function createReconciler({ ctx, logger, store, options, runtime, emit }) {
|
|
154
|
+
const handles = new Map();
|
|
155
|
+
/** token 用量分量累计(决策 32 修订):按 instance.id 累计,跨重试仍归同一实例;完成写回后清除。 */
|
|
156
|
+
const tokenTotals = new Map();
|
|
157
|
+
/** 发动在途去重(tick 1s 一次,发动是异步的——模型解析期间不能重复发动同一实例)。 */
|
|
158
|
+
const launching = new Set();
|
|
159
|
+
/** 已挂上「等 agent 空闲(`whenIdle`)」的会话:同一会话只挂一个,多次 `turn/end` 共享同一份等待。 */
|
|
160
|
+
const awaitingIdle = new Set();
|
|
161
|
+
/** 事件字段只打印一次(用于确认宿主把用量挂在哪,便于收紧取值逻辑)。 */
|
|
162
|
+
let eventShapeLogged = false;
|
|
163
|
+
const windowDeadline = (instance) => Date.parse(instance.scheduled_at) + durationMs(snapOf(instance)?.window ?? 'PT0S');
|
|
164
|
+
/**
|
|
165
|
+
* 取实例的派发快照(决策 41)。旧行无快照 ⇒ 按 legacyTask 当场合成并固化(一次性兼容);
|
|
166
|
+
* 兜底也不成立 ⇒ undefined(调用方如实按失败收敛,不静默、不猜)。
|
|
167
|
+
*/
|
|
168
|
+
function snapOf(instance) {
|
|
169
|
+
const parsed = parseInstanceSnapshot(instance.snapshot);
|
|
170
|
+
if (parsed !== undefined)
|
|
171
|
+
return parsed;
|
|
172
|
+
const task = options.legacyTask?.(instance.task_id);
|
|
173
|
+
if (task === undefined)
|
|
174
|
+
return undefined;
|
|
175
|
+
let workspacePath;
|
|
176
|
+
try {
|
|
177
|
+
workspacePath = resolveWorkspace(ctx, task.target.workspace).path;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
logger.warn(`旧实例 ${instance.id} 补快照失败:工作区解析不出(${task.target.workspace})`);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
// 防御:legacyTask 兜底路径可能拿到畸形定义(缺 contract/retry/schedule),绝不让 sweep 崩。
|
|
184
|
+
const contract = task.contract ?? { validStatuses: ['ok'] };
|
|
185
|
+
const retry = task.retry ?? { maxAttempts: 1 };
|
|
186
|
+
const schedule = task.schedule ?? { window: 'PT0S' };
|
|
187
|
+
const snap = {
|
|
188
|
+
title: displayNameOf(task),
|
|
189
|
+
prompt: task.target.prompt ?? '',
|
|
190
|
+
manual: task.target.manual ?? null,
|
|
191
|
+
workspacePath,
|
|
192
|
+
provider: task.target.provider ?? '',
|
|
193
|
+
model: task.target.model ?? '',
|
|
194
|
+
validStatuses: contract.validStatuses.length > 0 ? [...contract.validStatuses] : ['ok'],
|
|
195
|
+
goal: task.target.goal !== false,
|
|
196
|
+
agentTeam: task.target.agentTeam === true,
|
|
197
|
+
permission: task.target.permission ?? 'default',
|
|
198
|
+
maxAttempts: retry.maxAttempts >= 1 ? retry.maxAttempts : 1,
|
|
199
|
+
window: schedule.window ?? 'PT0S',
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
store.setSnapshot(instance.id, snap);
|
|
203
|
+
logger.warn(`旧实例 ${instance.id} 无派发快照,已按当前任务定义补快照(一次性兼容,决策 41)`);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
logger.warn(`旧实例 ${instance.id} 补快照写库失败: ${String(error)}`);
|
|
207
|
+
}
|
|
208
|
+
return snap;
|
|
209
|
+
}
|
|
210
|
+
function registerHandle(sessionId, handle) {
|
|
211
|
+
handles.set(sessionId, handle);
|
|
212
|
+
}
|
|
213
|
+
function forgetHandle(sessionId) {
|
|
214
|
+
if (sessionId !== null)
|
|
215
|
+
handles.delete(sessionId);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 实例行被删(窗口外残留 pending / 附件缺失过窗)后的运行态同步:
|
|
219
|
+
* 该任务若已无任何在飞行实例(一条聚合 SQL,删行是低频事件),主界面的转圈就该停。
|
|
220
|
+
*/
|
|
221
|
+
function syncRunningAfterDrop(taskId) {
|
|
222
|
+
if (runtime === undefined)
|
|
223
|
+
return;
|
|
224
|
+
if (store.inFlightByTask().has(taskId)) {
|
|
225
|
+
// 该任务**还有别的在飞实例** ⇒ 不该清 running;但**这一行已经从库里没了**(调用方刚 deleteInstance),
|
|
226
|
+
// 记录页 / 日历要少一行 ⇒ 仍必须通知(2026-10-06 审计:这条路径原来完全静默,删行前端看不见)。
|
|
227
|
+
emit?.({ type: EventType.TASK_RUN_CHANGED, payload: { taskId } });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
runtime.clearRunning(taskId);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 实例行在 **DB 层**的变化(不经 `RuntimeIndex` 的那些:重试退回 / unknown 复活 / 转 running /
|
|
234
|
+
* redispatch)也通知前端重读——`RuntimeIndex` 包裹层覆盖不到它们(design/event-push.md §六)。
|
|
235
|
+
*/
|
|
236
|
+
const notifyRow = (instance) => {
|
|
237
|
+
emit?.({ type: EventType.TASK_RUN_CHANGED, payload: { taskId: instance.task_id, instanceId: instance.id } });
|
|
238
|
+
};
|
|
239
|
+
function finishTerminal(instance, status, reason, detail, outputs) {
|
|
240
|
+
const tk = tokenTotals.get(instance.id);
|
|
241
|
+
if (tokenTotals.has(instance.id))
|
|
242
|
+
tokenTotals.delete(instance.id);
|
|
243
|
+
const finishedAt = new Date().toISOString();
|
|
244
|
+
store.transition(instance.id, { status, finished_at: finishedAt, detail: reason });
|
|
245
|
+
// 主界面运行态(内存,非真源):实例进终态 ⇒ 该任务不再在飞,并记录「上次执行」。
|
|
246
|
+
runtime?.markTerminal(instance.task_id, status, instance.scheduled_at, finishedAt);
|
|
247
|
+
if (detail !== undefined)
|
|
248
|
+
store.appendEvent(instance.id, 'receipt_check', { reason, detail });
|
|
249
|
+
// 决策 32 修订:完成瞬间写回产出与 token 三拆列到总表(冗余,task_events 仍为真源)
|
|
250
|
+
store.recordCompletion(instance.id, outputs ?? null, tk?.in ?? null, tk?.out ?? null, tk?.cache ?? null);
|
|
251
|
+
forgetHandle(instance.session_id);
|
|
252
|
+
// 会话已结束(turn/end / disposed 触发的收敛)→ 归档;租约误判的回收不归档。
|
|
253
|
+
if (instance.session_id !== null)
|
|
254
|
+
void ctx.workspaceRegistry.archiveSession(instance.session_id).catch((error) => {
|
|
255
|
+
logger.warn(`归档失败 ${instance.session_id}: ${String(error)}`);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** 重试判定(state-machine §6,决策 41:maxAttempts / 窗口都读快照):未耗尽且未超窗 → 回 pending;否则终态 failed。 */
|
|
259
|
+
function retryOrFail(instance, reason, detail) {
|
|
260
|
+
const snap = snapOf(instance);
|
|
261
|
+
const overWindow = Date.now() > windowDeadline(instance);
|
|
262
|
+
if (snap !== undefined && !overWindow && instance.attempt + 1 < snap.maxAttempts) {
|
|
263
|
+
forgetHandle(instance.session_id);
|
|
264
|
+
store.transition(instance.id, {
|
|
265
|
+
status: 'pending',
|
|
266
|
+
attempt: instance.attempt + 1,
|
|
267
|
+
session_id: null,
|
|
268
|
+
lease_until: null,
|
|
269
|
+
dispatched_at: null,
|
|
270
|
+
finished_at: null,
|
|
271
|
+
detail: `retry:${reason}`,
|
|
272
|
+
});
|
|
273
|
+
notifyRow(instance);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
finishTerminal(instance, 'failed', overWindow ? `${reason}:over-window` : reason, detail);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* 回执收敛(state-machine §1 判定树,决策 19 版;决策 41:工作区与合法值读快照)。
|
|
280
|
+
*
|
|
281
|
+
* 取**最新一条**回执裁决(`latestReceipt` = `ORDER BY seq DESC LIMIT 1`):
|
|
282
|
+
* agent 可能反复交回执(真机见过 66 秒内 4 次),**最后一次申报才是它的最终声明**,
|
|
283
|
+
* 也是本设计的前提 ——「agent 会撒谎」(once-dispatch.md),所以必须以它的最终声明去验产物。
|
|
284
|
+
*
|
|
285
|
+
* ⚠️ 2026-10-03 曾把这里改成「逐条校验、任一条通过即成功」,**是错的、已撤回**:
|
|
286
|
+
* 因为 `outputs:[]`(空申报)在 `checkReceipt` 里天然放行 ⇒ 那个改法等于
|
|
287
|
+
* 「agent 先交一次『我没有产出』就能绕过全部产出校验」= 给没干活的 agent 开后门。
|
|
288
|
+
*/
|
|
289
|
+
function settleByReceipt(instance) {
|
|
290
|
+
const snap = snapOf(instance);
|
|
291
|
+
if (snap === undefined) {
|
|
292
|
+
logger.warn(`实例 ${instance.id} 无派发快照,无法校验回执,按失败收敛(决策 41)`);
|
|
293
|
+
store.appendEvent(instance.id, 'receipt_check', { reason: 'no-snapshot' });
|
|
294
|
+
retryOrFail(instance, 'no-snapshot');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const receipt = store.latestReceipt(instance.id, instance.dispatched_at ?? undefined);
|
|
298
|
+
const verdict = checkReceipt(snap.workspacePath, snap.validStatuses, receipt);
|
|
299
|
+
if (verdict.ok) {
|
|
300
|
+
const payload = verdict.detail;
|
|
301
|
+
const outputsField = Array.isArray(payload?.outputs) ? JSON.stringify(payload.outputs) : null;
|
|
302
|
+
finishTerminal(instance, 'succeeded', 'receipt-pass', verdict.detail, outputsField);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
store.appendEvent(instance.id, 'receipt_check', { reason: verdict.reason, detail: verdict.detail });
|
|
306
|
+
retryOrFail(instance, verdict.reason ?? 'receipt-failed', verdict.detail);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** 追问(决策 19 第二层):跑完信号后无回执,对原会话再推一轮、重发回执命令。 */
|
|
310
|
+
function nudge(instance) {
|
|
311
|
+
const snap = snapOf(instance);
|
|
312
|
+
const handle = instance.session_id !== null ? handles.get(instance.session_id) : undefined;
|
|
313
|
+
store.appendEvent(instance.id, 'nudge', { at: new Date().toISOString() });
|
|
314
|
+
if (snap === undefined || handle === undefined) {
|
|
315
|
+
// 追问通道不可用(如插件重启后 handle 丢失)→ 直接按失败收敛。
|
|
316
|
+
retryOrFail(instance, 'receipt-missing-no-handle');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
handle.agent.send(userNotice(`任务实例 ${instance.id} 已结束但尚未收到回执。请立即按下面说明调用工具提交回执:\n`
|
|
321
|
+
+ receiptInstruction(snap.validStatuses), `[TASK] 回执追问 ${snap.title} · ${instance.logical_date}`), 'next-turn', true);
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
logger.warn(`追问发送失败 ${instance.id}: ${String(error)}`);
|
|
325
|
+
retryOrFail(instance, 'nudge-send-failed');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** 会话活动信号:unknown → running(续租,防双跑不重派),其余续心跳。 */
|
|
329
|
+
function markActivity(instance) {
|
|
330
|
+
if (instance.status === 'unknown') {
|
|
331
|
+
store.transition(instance.id, { status: 'running', lease_until: leaseUntil(), detail: 'unknown-revived' });
|
|
332
|
+
notifyRow(instance);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (instance.status === 'running')
|
|
336
|
+
store.renewLease(instance.id, options.leaseMs);
|
|
337
|
+
}
|
|
338
|
+
function leaseUntil() {
|
|
339
|
+
return new Date(Date.now() + options.leaseMs).toISOString();
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* 发动执行(决策 41:Loop B 独有动作)。只凭实例行 + 快照:
|
|
343
|
+
* 按 path 反查工作区 → 解析模型漏斗(①层 = 快照提示)→ 建会话挂 preset + 回执工具 → 归组 → 发消息。
|
|
344
|
+
* 任何前置失败(含模型解析不出、工作区消失、create 失败)⇒ **行保留**、走重试判定,原因落事件。
|
|
345
|
+
*/
|
|
346
|
+
async function launch(instance) {
|
|
347
|
+
if (launching.has(instance.id))
|
|
348
|
+
return;
|
|
349
|
+
const snap = snapOf(instance);
|
|
350
|
+
if (snap === undefined) {
|
|
351
|
+
retryOrFail(instance, 'no-snapshot');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// 附加文件兜底校验(2026-09-30):只凭快照判定,不读任务定义。
|
|
355
|
+
// 缺失 ⇒ **不发动**,只记一条实例事件(同一实例不重复记)。不判失败、不吃重试额度——
|
|
356
|
+
// 重试也不会把文件变回来;文件一旦恢复,下个 tick 自然放行。
|
|
357
|
+
const assets = options.assets === undefined ? null : options.assets();
|
|
358
|
+
const gone = missingSnapshotAttachments(ctx, instance.task_id, snap, assets);
|
|
359
|
+
if (gone.length > 0) {
|
|
360
|
+
if (store.countEvents(instance.id, 'attachment-missing') === 0) {
|
|
361
|
+
store.appendEvent(instance.id, 'attachment-missing', { files: gone });
|
|
362
|
+
}
|
|
363
|
+
logger.warn(`实例 ${instance.id} 未发动:附加文件不存在(${gone.join('、')})`);
|
|
364
|
+
// 超过窗口截止 ⇒ 删行(视为未执行,同 stray_pending 语义):否则行永久 dispatched,
|
|
365
|
+
// 串行互斥会把同任务后续刻度全部挡死(评审 P1#9)。文件没恢复是常态 ⇒ 不能无限等。
|
|
366
|
+
if (Date.now() > Date.parse(instance.scheduled_at) + durationMs(snap.window)) {
|
|
367
|
+
store.deleteInstance(instance.id);
|
|
368
|
+
syncRunningAfterDrop(instance.task_id);
|
|
369
|
+
store.appendLog({
|
|
370
|
+
taskId: instance.task_id,
|
|
371
|
+
scheduledAt: instance.scheduled_at,
|
|
372
|
+
level: 'error',
|
|
373
|
+
kind: 'attachment-missing',
|
|
374
|
+
message: `附加文件缺失且已过窗口截止,执行记录已删除:${gone.join('、')}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
launching.add(instance.id);
|
|
380
|
+
try {
|
|
381
|
+
const workspace = resolveWorkspaceByPath(ctx, snap.workspacePath);
|
|
382
|
+
// 随附文件段(2026-09-30):把快照里的附件 ref 解析成**绝对路径**随派发消息注入
|
|
383
|
+
// (用户要求:必须让模型明确知道文件在哪一层、在什么地方)。与上面存在性校验同款口径——
|
|
384
|
+
// link 按附件来源工作区、upload 按任务目录;解析不出 ⇒ path=null(如实标注,绝不猜)。
|
|
385
|
+
const attachments = (snap.attachments ?? []).map(item => {
|
|
386
|
+
let path = null;
|
|
387
|
+
if (item.kind === 'upload') {
|
|
388
|
+
path = assets === null ? null : attachmentAbsPath(assets, instance.task_id, item.ref);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const source = item.workspace !== undefined && item.workspace.trim() !== '' ? item.workspace : null;
|
|
392
|
+
try {
|
|
393
|
+
path = source === null ? join(snap.workspacePath, item.ref) : join(resolveWorkspace(ctx, source).path, item.ref);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
path = null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (path !== null && path.includes('..'))
|
|
400
|
+
path = null;
|
|
401
|
+
return { name: item.name, kind: item.kind, ref: item.ref, path };
|
|
402
|
+
});
|
|
403
|
+
const { sessionId, handle } = await dispatchTask({
|
|
404
|
+
ctx, logger, store,
|
|
405
|
+
instanceId: instance.id,
|
|
406
|
+
logicalDate: instance.logical_date,
|
|
407
|
+
scheduledAt: instance.scheduled_at,
|
|
408
|
+
snapshot: snap,
|
|
409
|
+
workspace,
|
|
410
|
+
attachments,
|
|
411
|
+
config: options.config(),
|
|
412
|
+
});
|
|
413
|
+
registerHandle(sessionId, handle);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
const reason = error instanceof DispatchPreconditionError ? error.reason : 'launch-error';
|
|
417
|
+
logger.warn(`发动失败(实例 ${instance.id},reason=${reason}):${error instanceof Error ? error.message : String(error)}`);
|
|
418
|
+
retryOrFail(store.get(instance.id) ?? instance, reason, String(error));
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
launching.delete(instance.id);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** 发动一个实例(fire-and-forget 包装:launch 内部已兜底,这里再拦异步余波)。 */
|
|
425
|
+
function launchAsyncFire(instance) {
|
|
426
|
+
void launch(instance).catch((error) => {
|
|
427
|
+
logger.warn(`发动异步余波异常(实例 ${instance.id}): ${String(error)}`);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/** 落一条「跑完信号」事件(补 running 兜底 + 写 session_event);**不含裁决**,返回当前实例行供调用方接着判。 */
|
|
431
|
+
function noteRunSignal(sessionId, signal) {
|
|
432
|
+
const instance = store.getBySession(sessionId);
|
|
433
|
+
if (instance === undefined)
|
|
434
|
+
return undefined;
|
|
435
|
+
if (instance.status === 'dispatched') {
|
|
436
|
+
// session/created 事件同步于 sessions.create 内发出,能进 dispatched 又收到跑完信号
|
|
437
|
+
// 说明 created 对账被跳过(如插件重启恢复),先补 running 语义再判定。
|
|
438
|
+
store.transition(instance.id, { status: 'running', lease_until: leaseUntil(), detail: signal });
|
|
439
|
+
notifyRow(instance);
|
|
440
|
+
}
|
|
441
|
+
const current = store.get(instance.id);
|
|
442
|
+
if (current === undefined || (current.status !== 'running' && current.status !== 'unknown'))
|
|
443
|
+
return undefined;
|
|
444
|
+
store.appendEvent(current.id, 'session_event', { type: signal });
|
|
445
|
+
return current;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* 等 agent **真正空闲**(会话不再执行)之后再裁决 —— **用户 2026-10-03 拍板**。
|
|
449
|
+
*
|
|
450
|
+
* ⚠️ 为什么不能收到 `turn/end` 就裁:`turn/end` 只是**一轮**结束。开了 `/goal`
|
|
451
|
+
* (本插件默认 `goal: true`)时 agent 会**自动续跑下一轮** —— 宿主 `dsh-agent-loop`
|
|
452
|
+
* 的 `kick()` 就是 `while (await this.turn())`(0.2.0-rc.2 `lib/index.js:886`),
|
|
453
|
+
* 所有轮次(含 goal 续跑)跑完、phase 转 `idle` 才算真正结束。在轮次间隙验收
|
|
454
|
+
* = 「人家还在干活,你就去收卷」(用户原话:「Session 正在进行的时候,你去验收个屁」)。
|
|
455
|
+
*
|
|
456
|
+
* ✅ 正解 = `agent.whenIdle()`:宿主语义是「**没有任何活动中的 driver / maintenance 任务**」
|
|
457
|
+
* (`dsh-agent` `lib/types/runtime-types.d.ts`:*fulfillment after no active driver or
|
|
458
|
+
* maintenance task remains*),实现是等 `activityDone` 稳定不再被替换
|
|
459
|
+
* (`index.js:870` 的 do/while);`kick()` 的 while 循环把全部轮次跑完才 resolve
|
|
460
|
+
* ⇒ **它就是「会话没有在跑了」这个信号**。
|
|
461
|
+
*
|
|
462
|
+
* 防重入:同一会话只挂一个等待(goal 续跑期间会有多次 `turn/end`,共享同一份 idle)。
|
|
463
|
+
* 拿不到 handle(如插件重启过)⇒ 不挂,交给 sweep 的租约兜底。
|
|
464
|
+
*/
|
|
465
|
+
function settleWhenIdle(sessionId) {
|
|
466
|
+
if (awaitingIdle.has(sessionId))
|
|
467
|
+
return;
|
|
468
|
+
const handle = handles.get(sessionId);
|
|
469
|
+
if (handle === undefined)
|
|
470
|
+
return;
|
|
471
|
+
awaitingIdle.add(sessionId);
|
|
472
|
+
void handle.agent.whenIdle().then(() => {
|
|
473
|
+
awaitingIdle.delete(sessionId);
|
|
474
|
+
settleBySessionId(sessionId, 'agent/idle');
|
|
475
|
+
}, (error) => {
|
|
476
|
+
awaitingIdle.delete(sessionId);
|
|
477
|
+
logger.warn(`等待会话空闲失败(${sessionId}):${String(error)}`);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/** 有回执就裁决(无回执交给 sweep 追究问)。`agent/idle` / `session/disposed` 走这里。 */
|
|
481
|
+
function settleBySessionId(sessionId, signal) {
|
|
482
|
+
const current = noteRunSignal(sessionId, signal);
|
|
483
|
+
if (current === undefined)
|
|
484
|
+
return;
|
|
485
|
+
if (store.latestReceipt(current.id, current.dispatched_at ?? undefined) !== undefined) {
|
|
486
|
+
settleByReceipt(current);
|
|
487
|
+
}
|
|
488
|
+
// 无回执:不立即判失败——agent 可能只是还没执行提交命令,等 sweep 按宽限期追问。
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
registerHandle,
|
|
492
|
+
retryOrFail,
|
|
493
|
+
/** dispatched → running + 起租约(state-machine §3);会话改名只凭快照(决策 41/42)。 */
|
|
494
|
+
onCreated(session) {
|
|
495
|
+
const instance = store.getBySession(session.id);
|
|
496
|
+
if (instance === undefined || instance.status !== 'dispatched')
|
|
497
|
+
return;
|
|
498
|
+
store.transition(instance.id, { status: 'running', lease_until: leaseUntil(), detail: 'session/created' });
|
|
499
|
+
notifyRow(instance);
|
|
500
|
+
const snap = snapOf(instance);
|
|
501
|
+
if (snap === undefined) {
|
|
502
|
+
logger.warn(`实例 ${instance.id} 无快照,会话保持默认名(决策 42 改名跳过)`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// 会话改名(会话列表治理,决策 42:[TASK] <260928-1600> · <标题>(· 第N次))。
|
|
506
|
+
// Session 对象只能在这里拿——agents.create 自建会话后 announce,handle 上没有 session。
|
|
507
|
+
// 改名失败只告警不影响对账。
|
|
508
|
+
try {
|
|
509
|
+
ctx.sessionTitle.rename(session, sessionTitleOf(instance.scheduled_at, snap.title, instance.attempt));
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
logger.warn(`会话改名失败 ${session.id}: ${String(error)}`);
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
onEvent(session, event) {
|
|
516
|
+
const instance = store.getBySession(session.id);
|
|
517
|
+
if (instance === undefined)
|
|
518
|
+
return;
|
|
519
|
+
// 累计 token 用量分量(决策 32 修订):宿主事件带用量则累加;不带则留 null,不阻塞链路
|
|
520
|
+
const used = extractTokenUsage(event);
|
|
521
|
+
if (used !== undefined) {
|
|
522
|
+
const cur = tokenTotals.get(instance.id) ?? {};
|
|
523
|
+
tokenTotals.set(instance.id, {
|
|
524
|
+
in: (cur.in ?? 0) + (used.in ?? 0),
|
|
525
|
+
out: (cur.out ?? 0) + (used.out ?? 0),
|
|
526
|
+
cache: (cur.cache ?? 0) + (used.cache ?? 0),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
else if (!eventShapeLogged) {
|
|
530
|
+
eventShapeLogged = true;
|
|
531
|
+
logger.info(`会话事件字段(确认 token 用量挂载位置用):${Object.keys(event).join(', ')}`);
|
|
532
|
+
}
|
|
533
|
+
if (event.type === 'turn/end') {
|
|
534
|
+
// 只**记信号**(sweep 的追问判定以它为据),**不在这里裁决**:`turn/end` 只是**一轮**结束,
|
|
535
|
+
// 开了 `/goal` 时 agent 会立刻续跑下一轮,此刻会话仍在执行。真正的裁决等 agent 空闲
|
|
536
|
+
// (`settleWhenIdle` → `whenIdle()`),见该函数注释(用户 2026-10-03 拍板)。
|
|
537
|
+
noteRunSignal(session.id, 'turn/end');
|
|
538
|
+
settleWhenIdle(session.id);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// 心跳语义(§4):该会话任何事件即续租。
|
|
542
|
+
markActivity(instance);
|
|
543
|
+
},
|
|
544
|
+
/** 会话结束 → 查回执收敛(§10:disposed 只是移出内存 store,日志仍在)。 */
|
|
545
|
+
onDisposed(session) {
|
|
546
|
+
settleBySessionId(session.id, 'session/disposed');
|
|
547
|
+
forgetHandle(session.id);
|
|
548
|
+
},
|
|
549
|
+
/** tick 兜底(重启后事件可能丢失,轮询只作兜底,§10)+ **发动执行**(决策 41)。 */
|
|
550
|
+
sweep() {
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
// 发动①:重试回退的 pending 行——窗口内转 dispatched 并发动;窗口外删行记日志(决策 31.6)。
|
|
553
|
+
// 依赖不复判:依赖在首次落库时已过(Loop A 职责),重试是同一份执行记录的再发动。
|
|
554
|
+
for (const instance of store.listByStatus(['pending'])) {
|
|
555
|
+
if (snapOf(instance) === undefined) {
|
|
556
|
+
retryOrFail(instance, 'no-snapshot');
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (now > windowDeadline(instance)) {
|
|
560
|
+
store.deleteInstance(instance.id);
|
|
561
|
+
syncRunningAfterDrop(instance.task_id);
|
|
562
|
+
store.appendLog({ taskId: instance.task_id, scheduledAt: instance.scheduled_at, level: 'warn', kind: 'stray_pending', message: '窗口外残留 pending,已删除(视为未执行)' });
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
store.transition(instance.id, { status: 'dispatched', detail: 'redispatch' });
|
|
566
|
+
notifyRow(instance);
|
|
567
|
+
launchAsyncFire(store.get(instance.id) ?? instance);
|
|
568
|
+
}
|
|
569
|
+
// 发动②:无会话的 dispatched 行(Loop A 新落库 / create-failed 撤回的)——发动之。
|
|
570
|
+
// 已发动(有 session_id)未 created 的走派发宽限期。
|
|
571
|
+
for (const instance of store.listByStatus(['dispatched'])) {
|
|
572
|
+
if (instance.session_id === null) {
|
|
573
|
+
launchAsyncFire(instance);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const dispatchedAtMs = Date.parse(instance.dispatched_at ?? instance.updated_at);
|
|
577
|
+
if (now > dispatchedAtMs + options.dispatchGraceMs) {
|
|
578
|
+
retryOrFail(instance, 'dispatch-grace-exceeded');
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
for (const instance of store.listByStatus(['running'])) {
|
|
582
|
+
const leaseExpired = instance.lease_until !== null && now > Date.parse(instance.lease_until);
|
|
583
|
+
// 决策 19:已收到跑完信号但无回执 → 宽限期后追问,追问 NUDGE_LIMIT 次仍无 → 失败收敛。
|
|
584
|
+
const signalType = parseEventType(store.latestEvent(instance.id, 'session_event')?.detail);
|
|
585
|
+
const signalAtMs = Date.parse(store.latestEvent(instance.id, 'session_event')?.ts ?? instance.updated_at);
|
|
586
|
+
// 追问的等待时长(用户 2026-09-30 拍板):**会话这一轮真的结束(`turn/end`)之后 30 秒**
|
|
587
|
+
// 还没回执就追问。不再借用 `dispatchGraceMs`(那是"等会话建立"的语义):真机实测 60 秒宽限
|
|
588
|
+
// + 一轮 tick 粒度 = 118 秒才追问。模型还在干活(没有 `turn/end`)时**绝不追问**,不打扰它。
|
|
589
|
+
const dueNudge = now > signalAtMs + 30_000
|
|
590
|
+
&& store.latestReceipt(instance.id, instance.dispatched_at ?? undefined) === undefined;
|
|
591
|
+
const nudgeOrFail = () => {
|
|
592
|
+
if (store.countEvents(instance.id, 'nudge') < NUDGE_LIMIT)
|
|
593
|
+
nudge(instance);
|
|
594
|
+
else
|
|
595
|
+
retryOrFail(instance, 'receipt-missing-after-nudge');
|
|
596
|
+
};
|
|
597
|
+
// ① 会话**真的不再执行**了(agent 空闲 / 会话销毁)⇒ 追问判定后即可收口,租约不再适用。
|
|
598
|
+
if (signalType === 'agent/idle' || signalType === 'session/disposed') {
|
|
599
|
+
if (dueNudge)
|
|
600
|
+
nudgeOrFail();
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
// ② 仅仅「一轮结束」(`turn/end`):goal 模式下 agent 可能**仍在续跑**(`turn/end` ≠ 会话结束)
|
|
604
|
+
// ⇒ 照常追究问,但**不能 `continue`**:要让下面的租约 / 失联兜底仍然生效。
|
|
605
|
+
// (改成「等空闲才裁决」后,旧版 turn/end 后的 `continue` 会把卡死实例永久挂在 running。)
|
|
606
|
+
if (signalType === 'turn/end' && dueNudge)
|
|
607
|
+
nudgeOrFail();
|
|
608
|
+
if (leaseExpired) {
|
|
609
|
+
// 租约超时回收(§3):会话可能仍在跑,不归档;走重试判定。
|
|
610
|
+
store.appendEvent(instance.id, 'session_event', { type: 'lease-expired' });
|
|
611
|
+
retryOrFail(instance, 'lease-expired');
|
|
612
|
+
}
|
|
613
|
+
else if (now > Date.parse(instance.updated_at) + options.unknownGraceMs) {
|
|
614
|
+
// 长期无活动(如漏掉 session/created 导致 lease_until 为 null、租约永不触发):
|
|
615
|
+
// 会话已事实上失联,走重试判定收口,避免卡死阻塞同任务(本次真机 bug 同类)。
|
|
616
|
+
retryOrFail(instance, 'running-stale');
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const instance of store.listByStatus(['unknown'])) {
|
|
620
|
+
// unknown 只可能由 startupScan 产生(= 重启孤儿,插件进程重建致 agent 句柄消失,会话已不可控)。
|
|
621
|
+
// 给 30s 短宽限:够活会话经 onEvent 复活为 running;超时则判失败收口——不再等 5+2 分钟,
|
|
622
|
+
// 也避免反复重启不断重置宽限导致永久卡死(且 unknown 已移出串行互斥集,不会挡新刻度)。
|
|
623
|
+
if (now > Date.parse(instance.updated_at) + Math.min(options.unknownGraceMs, 30_000)) {
|
|
624
|
+
retryOrFail(instance, 'unknown-orphan');
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/** 从 session_event 事件的 detail JSON 里取 type(sweep 判定跑完信号用)。 */
|
|
631
|
+
function parseEventType(detail) {
|
|
632
|
+
if (detail === null || detail === undefined)
|
|
633
|
+
return undefined;
|
|
634
|
+
try {
|
|
635
|
+
const parsed = JSON.parse(detail);
|
|
636
|
+
return typeof parsed?.type === 'string' ? parsed.type : undefined;
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return undefined;
|
|
640
|
+
}
|
|
641
|
+
}
|