@mokuyoaxis/dsh-iris 0.1.2 → 0.1.4
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/.dsh/skills/iris-compose-media/SKILL.md +23 -49
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
- package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
- package/CHANGELOG.md +57 -2
- package/README.en.md +43 -7
- package/README.md +43 -7
- package/bin/dsh-iris.js +23 -0
- package/docs/ARCHITECTURE.md +95 -0
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/FAULT_INJECTION.md +26 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
- package/docs/HOST_DOCTOR.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
- package/docs/ROADMAP.md +21 -11
- package/docs/SECURITY.md +57 -0
- package/docs/TASK_SEMANTICS.md +148 -0
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- package/docs/releases/0.1.4.md +59 -0
- package/lib/actions.js +860 -290
- package/lib/adapters.js +7 -7
- package/lib/api.js +110 -18
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +478 -68
- package/lib/config.js +192 -9
- package/lib/doctor.js +414 -0
- package/lib/dsh-host-adapter.js +189 -0
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +142 -157
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +371 -0
- package/lib/provider-adapters.js +279 -0
- package/lib/provider-contract.js +234 -0
- package/lib/provider-health.js +59 -0
- package/lib/render.js +43 -12
- package/lib/task-semantics.js +209 -0
- package/lib/tasks.js +500 -32
- package/lib/vision.js +23 -49
- package/package.json +19 -8
- package/user_guide.md +62 -2
- package/docs/assets/diagrams/iris-architecture.drawio +0 -1
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
- package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
- package/docs/releases/0.1.1.md +0 -51
- package/docs/releases/0.1.2.md +0 -26
package/lib/tasks.js
CHANGED
|
@@ -8,13 +8,18 @@
|
|
|
8
8
|
* - DSH 重启后 resumePending() 接管仍在服务端运行的远程任务(百炼结果 URL 存活 24h)
|
|
9
9
|
*
|
|
10
10
|
* 对前身的改进:
|
|
11
|
-
* -
|
|
11
|
+
* - 轮询容错:单次网络抖动不判死;v2 达到阈值后停止盯守并请求人工处理,旧任务维持兼容行为
|
|
12
12
|
* - 状态机增加 canceled(工具取消信号传播)
|
|
13
13
|
*/
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { irisHome } from './config.js';
|
|
17
17
|
import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
|
|
18
|
+
import { parseModelRef } from './models.js';
|
|
19
|
+
import { attentionDisposition, deriveLegacyStatus, deriveUserState, semanticViolations } from './task-semantics.js';
|
|
20
|
+
export { attentionDisposition } from './task-semantics.js';
|
|
21
|
+
import { providerErrorRecord, redactProviderMessage } from './provider-contract.js';
|
|
22
|
+
import { providerPollTaskView } from './provider-adapter.js';
|
|
18
23
|
|
|
19
24
|
const FILE = () => path.join(irisHome(), 'tasks.json');
|
|
20
25
|
/** 产物目录:$DSH_HOME/iris/v1/outputs */
|
|
@@ -24,7 +29,7 @@ export function outputsDir() {
|
|
|
24
29
|
const MAX_TASKS = 500;
|
|
25
30
|
/** 单个异步任务最长盯守时间 */
|
|
26
31
|
export const MAX_WATCH_MS = 20 * 60 * 1000;
|
|
27
|
-
/**
|
|
32
|
+
/** 连续轮询失败多少次后停止本轮盯守(旧任务仍按兼容状态落盘) */
|
|
28
33
|
const POLL_ERROR_TOLERANCE = 5;
|
|
29
34
|
|
|
30
35
|
let cache = null;
|
|
@@ -112,24 +117,337 @@ export function resetCache() {
|
|
|
112
117
|
export function update(id, patch) {
|
|
113
118
|
const t = get(id);
|
|
114
119
|
if (!t) return undefined;
|
|
120
|
+
if (isV2Task(t)) return mutateV2(id, (record) => Object.assign(record, patch));
|
|
115
121
|
Object.assign(t, patch, { updatedAt: new Date().toISOString() });
|
|
116
122
|
persist();
|
|
117
123
|
return t;
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
function newId(prefix) {
|
|
127
|
+
return prefix + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function appendTask(task) {
|
|
131
|
+
const data = load();
|
|
132
|
+
const previous = data.tasks;
|
|
133
|
+
data.tasks = [...previous, task];
|
|
134
|
+
// 只留最近 MAX_TASKS 条元数据
|
|
135
|
+
if (data.tasks.length > MAX_TASKS) data.tasks.splice(0, data.tasks.length - MAX_TASKS);
|
|
136
|
+
try {
|
|
137
|
+
persist();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
data.tasks = previous;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
return task;
|
|
143
|
+
}
|
|
144
|
+
|
|
120
145
|
export function create(fields) {
|
|
121
146
|
const t = {
|
|
122
|
-
id: 't_'
|
|
147
|
+
id: newId('t_'),
|
|
123
148
|
status: 'running', // running | succeeded | failed | canceled
|
|
124
149
|
files: [], // 相对 outputs/ 的文件名
|
|
125
150
|
...fields,
|
|
126
151
|
createdAt: new Date().toISOString()
|
|
127
152
|
};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
153
|
+
return appendTask(t);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Task/Attempt v2 记录;旧任务继续由 create/update 维护。 */
|
|
157
|
+
export function createV2(fields = {}) {
|
|
158
|
+
const now = new Date().toISOString();
|
|
159
|
+
const t = {
|
|
160
|
+
...fields,
|
|
161
|
+
id: newId('t_'),
|
|
162
|
+
schemaVersion: 2,
|
|
163
|
+
status: 'running',
|
|
164
|
+
phase: 'queued',
|
|
165
|
+
acceptance: 'none',
|
|
166
|
+
watchState: 'idle',
|
|
167
|
+
outcome: 'none',
|
|
168
|
+
deliveryState: 'none',
|
|
169
|
+
cancelState: 'none',
|
|
170
|
+
attempts: [],
|
|
171
|
+
files: [],
|
|
172
|
+
revision: 1,
|
|
173
|
+
createdAt: now,
|
|
174
|
+
updatedAt: now
|
|
175
|
+
};
|
|
176
|
+
const violations = semanticViolations(t);
|
|
177
|
+
if (violations.length) throw new Error('Task v2 初始语义无效:' + violations.join(', '));
|
|
178
|
+
return appendTask(t);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function isV2Task(task) {
|
|
182
|
+
return Boolean(task && task.schemaVersion === 2 && Array.isArray(task.attempts));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* v2 使用复制后落盘;写入失败时恢复内存旧值,避免“内存已受理、磁盘未受理”。
|
|
187
|
+
*/
|
|
188
|
+
function mutateV2(id, mutation) {
|
|
189
|
+
const data = load();
|
|
190
|
+
const index = data.tasks.findIndex((task) => task.id === id);
|
|
191
|
+
if (index < 0) return undefined;
|
|
192
|
+
const previous = data.tasks[index];
|
|
193
|
+
if (!isV2Task(previous)) throw new TypeError('任务不是 Task v2:' + id);
|
|
194
|
+
const next = structuredClone(previous);
|
|
195
|
+
mutation(next);
|
|
196
|
+
next.revision = Number(previous.revision || 0) + 1;
|
|
197
|
+
next.updatedAt = new Date().toISOString();
|
|
198
|
+
const violations = semanticViolations(next);
|
|
199
|
+
if (violations.length) throw new Error('Task v2 语义冲突:' + violations.join(', '));
|
|
200
|
+
next.status = deriveLegacyStatus(next);
|
|
201
|
+
data.tasks[index] = next;
|
|
202
|
+
try {
|
|
203
|
+
persist();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
data.tasks[index] = previous;
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
return next;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 在任何供应商请求发出前持久化稳定 Attempt ID。 */
|
|
212
|
+
export function beginAttempt(taskId, fields) {
|
|
213
|
+
const providerId = String(fields && fields.providerId || '').trim();
|
|
214
|
+
const model = String(fields && fields.model || '').trim();
|
|
215
|
+
const parsed = parseModelRef(model);
|
|
216
|
+
if (!providerId || !parsed || parsed.providerId !== providerId) {
|
|
217
|
+
throw new TypeError('Attempt 必须包含身份一致的 providerId 与复合 model');
|
|
218
|
+
}
|
|
219
|
+
let created;
|
|
220
|
+
mutateV2(taskId, (task) => {
|
|
221
|
+
if (task.acceptance === 'accepted' || task.acceptance === 'unknown') {
|
|
222
|
+
throw new Error('任务受理后禁止创建新的 Attempt');
|
|
223
|
+
}
|
|
224
|
+
const now = new Date().toISOString();
|
|
225
|
+
created = {
|
|
226
|
+
id: newId('a_'),
|
|
227
|
+
ordinal: task.attempts.length + 1,
|
|
228
|
+
providerId,
|
|
229
|
+
providerName: String(fields.providerName || providerId),
|
|
230
|
+
model,
|
|
231
|
+
modelId: parsed.modelId,
|
|
232
|
+
protocol: String(fields.protocol || ''),
|
|
233
|
+
stage: 'submitting',
|
|
234
|
+
acceptance: 'none',
|
|
235
|
+
startedAt: now
|
|
236
|
+
};
|
|
237
|
+
if (typeof fields.idempotencyKey === 'string' && fields.idempotencyKey.trim()) {
|
|
238
|
+
created.idempotencyKey = fields.idempotencyKey.trim();
|
|
239
|
+
}
|
|
240
|
+
task.attempts.push(created);
|
|
241
|
+
task.phase = 'submitting';
|
|
242
|
+
task.acceptance = 'none';
|
|
243
|
+
task.watchState = 'idle';
|
|
244
|
+
task.outcome = 'none';
|
|
245
|
+
task.deliveryState = 'none';
|
|
246
|
+
task.cancelState = 'none';
|
|
247
|
+
task.activeAttemptId = created.id;
|
|
248
|
+
task.providerId = providerId;
|
|
249
|
+
task.providerName = created.providerName;
|
|
250
|
+
task.modelRef = model;
|
|
251
|
+
task.model = parsed.modelId; // 兼容 v0.1.2 UI;modelRef 才是权威身份。
|
|
252
|
+
task.protocol = created.protocol;
|
|
253
|
+
delete task.remoteTaskId;
|
|
254
|
+
delete task.error;
|
|
255
|
+
delete task.lastError;
|
|
256
|
+
});
|
|
257
|
+
return structuredClone(created);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 在上传、提交等副作用前推进 Attempt stage;失败会阻止后续网络动作。 */
|
|
261
|
+
export function markAttemptStage(taskId, attemptId, stage) {
|
|
262
|
+
const allowed = ['validate', 'prepare', 'upload', 'submit', 'response'];
|
|
263
|
+
if (!allowed.includes(stage)) throw new TypeError('未知 Attempt stage:' + String(stage));
|
|
264
|
+
return mutateV2(taskId, (task) => {
|
|
265
|
+
const attempt = task.attempts.find((item) => item.id === attemptId);
|
|
266
|
+
if (!attempt) throw new Error('Attempt 不存在:' + String(attemptId));
|
|
267
|
+
if (attempt.acceptance !== 'none') throw new Error('终态 Attempt 不能改变 stage');
|
|
268
|
+
attempt.stage = stage;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 持久化一次 Provider submit 的事实结果。 */
|
|
273
|
+
export function recordAttemptResult(taskId, snapshot) {
|
|
274
|
+
return mutateV2(taskId, (task) => {
|
|
275
|
+
const attempt = task.attempts.find((item) => item.id === snapshot.id);
|
|
276
|
+
if (!attempt) throw new Error('Attempt 不存在:' + String(snapshot.id));
|
|
277
|
+
if (attempt.acceptance !== 'none') throw new Error('Attempt 结果不可重复写入:' + attempt.id);
|
|
278
|
+
if (snapshot.providerId !== attempt.providerId || snapshot.model !== attempt.model) {
|
|
279
|
+
throw new Error('Attempt 结果身份与预写记录不一致');
|
|
280
|
+
}
|
|
281
|
+
const acceptance = snapshot.acceptance;
|
|
282
|
+
if (!['accepted', 'not_accepted', 'unknown'].includes(acceptance)) {
|
|
283
|
+
throw new TypeError('未知受理结果:' + String(acceptance));
|
|
284
|
+
}
|
|
285
|
+
attempt.acceptance = acceptance;
|
|
286
|
+
attempt.resultKind = String(snapshot.resultKind || '');
|
|
287
|
+
attempt.stage = snapshot.resultKind === 'completed'
|
|
288
|
+
? 'terminal'
|
|
289
|
+
: (acceptance === 'accepted' ? 'accepted' : 'terminal');
|
|
290
|
+
attempt.finishedAt = new Date().toISOString();
|
|
291
|
+
if (snapshot.remoteTaskId) attempt.remoteTaskId = String(snapshot.remoteTaskId);
|
|
292
|
+
if (snapshot.error) attempt.error = providerErrorRecord(snapshot.error, { acceptance });
|
|
293
|
+
|
|
294
|
+
task.acceptance = acceptance;
|
|
295
|
+
task.phase = acceptance === 'accepted' ? 'accepted' : 'terminal';
|
|
296
|
+
task.watchState = 'idle';
|
|
297
|
+
if (acceptance === 'unknown') task.outcome = 'unknown';
|
|
298
|
+
if (acceptance === 'accepted' && snapshot.resultKind === 'completed') {
|
|
299
|
+
task.phase = 'running';
|
|
300
|
+
task.outcome = 'succeeded';
|
|
301
|
+
task.deliveryState = 'pending';
|
|
302
|
+
}
|
|
303
|
+
if (snapshot.remoteTaskId) task.remoteTaskId = String(snapshot.remoteTaskId);
|
|
304
|
+
if (attempt.error) {
|
|
305
|
+
task.lastError = attempt.error;
|
|
306
|
+
task.error = attempt.error.safeMessage;
|
|
307
|
+
} else {
|
|
308
|
+
delete task.lastError;
|
|
309
|
+
delete task.error;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 所有候选都明确未受理时,才可以把 Task 收口为生成失败。 */
|
|
315
|
+
export function finalizeNoAcceptance(taskId, error) {
|
|
316
|
+
return mutateV2(taskId, (task) => {
|
|
317
|
+
if (task.acceptance !== 'not_accepted') throw new Error('只有明确未受理才能收口为提交失败');
|
|
318
|
+
const safe = providerErrorRecord(error, { stage: 'submit', acceptance: 'not_accepted' });
|
|
319
|
+
task.phase = 'terminal';
|
|
320
|
+
task.watchState = 'idle';
|
|
321
|
+
task.outcome = 'failed';
|
|
322
|
+
task.deliveryState = 'none';
|
|
323
|
+
task.lastError = safe;
|
|
324
|
+
task.error = safe.safeMessage;
|
|
325
|
+
task.finishedAt = new Date().toISOString();
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 远端受理事实已落盘后,才进入轮询。 */
|
|
330
|
+
export function activateWatch(taskId) {
|
|
331
|
+
return mutateV2(taskId, (task) => {
|
|
332
|
+
if (task.acceptance !== 'accepted' || !task.remoteTaskId) {
|
|
333
|
+
throw new Error('缺少已受理事实或远程任务 id,不能开始盯守');
|
|
334
|
+
}
|
|
335
|
+
task.phase = 'running';
|
|
336
|
+
task.watchState = 'active';
|
|
337
|
+
task.outcome = 'none';
|
|
338
|
+
task.deliveryState = 'none';
|
|
339
|
+
delete task.error;
|
|
340
|
+
delete task.lastError;
|
|
341
|
+
delete task.finishedAt;
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 同步生成已有成功证据后,单独推进本地产物交付。 */
|
|
346
|
+
export function beginDelivery(taskId) {
|
|
347
|
+
return mutateV2(taskId, (task) => {
|
|
348
|
+
if (task.acceptance !== 'accepted' || task.outcome !== 'succeeded') {
|
|
349
|
+
throw new Error('只有已成功的受理结果才能开始交付');
|
|
350
|
+
}
|
|
351
|
+
if (task.deliveryState !== 'pending') {
|
|
352
|
+
throw new Error('产物不处于待交付状态:' + task.deliveryState);
|
|
353
|
+
}
|
|
354
|
+
task.phase = 'running';
|
|
355
|
+
task.watchState = 'idle';
|
|
356
|
+
task.deliveryState = 'downloading';
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 用户显式要求重新交付;只重开交付轴,不改变远端成功事实。 */
|
|
361
|
+
export function prepareRedelivery(taskId) {
|
|
362
|
+
return mutateV2(taskId, (task) => {
|
|
363
|
+
if (task.outcome !== 'succeeded' || task.deliveryState !== 'failed') {
|
|
364
|
+
throw new Error('任务不处于可重新交付状态');
|
|
365
|
+
}
|
|
366
|
+
task.phase = 'running';
|
|
367
|
+
task.watchState = 'idle';
|
|
368
|
+
task.deliveryState = 'pending';
|
|
369
|
+
delete task.error;
|
|
370
|
+
delete task.lastError;
|
|
371
|
+
delete task.finishedAt;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const ATTENTION_USER_STATES = new Set(['watching_paused', 'needs_attention', 'artifact_unavailable']);
|
|
376
|
+
|
|
377
|
+
function appendAttentionEvent(task, event) {
|
|
378
|
+
const events = Array.isArray(task.attentionEvents) ? task.attentionEvents : [];
|
|
379
|
+
events.push(event);
|
|
380
|
+
task.attentionEvents = events.slice(-20);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 标为已读只关闭提醒,不把未知事实伪造成成功、失败或未受理。 */
|
|
384
|
+
export function acknowledgeAttention(taskId, reason = 'read', relatedTaskId) {
|
|
385
|
+
return mutateV2(taskId, (task) => {
|
|
386
|
+
if (!ATTENTION_USER_STATES.has(deriveUserState(task))) throw new Error('任务当前没有需要处置的提醒');
|
|
387
|
+
if (attentionDisposition(task).status === 'acknowledged') throw new Error('任务提醒已经标为已读');
|
|
388
|
+
const safeReason = reason === 'retried' ? 'retried' : 'read';
|
|
389
|
+
appendAttentionEvent(task, {
|
|
390
|
+
type: 'acknowledged', reason: safeReason, at: new Date().toISOString(),
|
|
391
|
+
...(relatedTaskId ? { relatedTaskId: String(relatedTaskId) } : {})
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 恢复提醒同样只改变用户工作流元数据。 */
|
|
397
|
+
export function restoreAttention(taskId) {
|
|
398
|
+
return mutateV2(taskId, (task) => {
|
|
399
|
+
if (!ATTENTION_USER_STATES.has(deriveUserState(task))) throw new Error('任务事实已经不再需要提醒');
|
|
400
|
+
if (attentionDisposition(task).status !== 'acknowledged') throw new Error('任务提醒尚未标为已读');
|
|
401
|
+
appendAttentionEvent(task, { type: 'restored', reason: 'restored', at: new Date().toISOString() });
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** 在原任务和用户明确授权的新任务之间记录关系,并原子归档原提醒。 */
|
|
406
|
+
export function linkManualRetry(taskId, retryTaskId) {
|
|
407
|
+
return mutateV2(taskId, (task) => {
|
|
408
|
+
const before = attentionDisposition(task);
|
|
409
|
+
const links = Array.isArray(task.manualRetries) ? task.manualRetries : [];
|
|
410
|
+
if (!links.some((item) => item && item.taskId === retryTaskId)) {
|
|
411
|
+
links.push({ taskId: retryTaskId, createdAt: new Date().toISOString() });
|
|
412
|
+
}
|
|
413
|
+
task.manualRetries = links;
|
|
414
|
+
if (ATTENTION_USER_STATES.has(deriveUserState(task))
|
|
415
|
+
&& !(before.status === 'acknowledged' && before.reason === 'retried' && before.relatedTaskId === retryTaskId)) {
|
|
416
|
+
appendAttentionEvent(task, {
|
|
417
|
+
type: 'acknowledged', reason: 'retried', relatedTaskId: String(retryTaskId), at: new Date().toISOString()
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function completeDelivery(taskId, files) {
|
|
424
|
+
return mutateV2(taskId, (task) => {
|
|
425
|
+
if (task.outcome !== 'succeeded' || !['pending', 'downloading'].includes(task.deliveryState)) {
|
|
426
|
+
throw new Error('产物不处于可完成的交付状态');
|
|
427
|
+
}
|
|
428
|
+
task.phase = 'terminal';
|
|
429
|
+
task.watchState = 'idle';
|
|
430
|
+
task.deliveryState = 'ready';
|
|
431
|
+
task.files = Array.isArray(files) ? [...files] : [];
|
|
432
|
+
task.progress = '100%';
|
|
433
|
+
task.finishedAt = new Date().toISOString();
|
|
434
|
+
delete task.error;
|
|
435
|
+
delete task.lastError;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function failDelivery(taskId, error, files = []) {
|
|
440
|
+
return mutateV2(taskId, (task) => {
|
|
441
|
+
if (task.outcome !== 'succeeded') throw new Error('只有已成功生成的任务才能记录交付失败');
|
|
442
|
+
const safe = providerErrorRecord(error, { stage: 'download', category: 'local_io', acceptance: 'accepted' });
|
|
443
|
+
task.phase = 'terminal';
|
|
444
|
+
task.watchState = 'idle';
|
|
445
|
+
task.deliveryState = 'failed';
|
|
446
|
+
task.files = Array.isArray(files) ? [...files] : [];
|
|
447
|
+
task.lastError = safe;
|
|
448
|
+
task.error = '结果转存失败:' + safe.safeMessage;
|
|
449
|
+
task.finishedAt = new Date().toISOString();
|
|
450
|
+
});
|
|
133
451
|
}
|
|
134
452
|
|
|
135
453
|
/** 全部任务记录(新→旧,不截断)——清理/孤儿扫描用 */
|
|
@@ -191,80 +509,179 @@ export function stopWatchAll() {
|
|
|
191
509
|
* 开始盯守一个异步任务(提交成功后调用)。
|
|
192
510
|
* @param {object} task 已 create 的任务记录(须含 remoteTaskId)
|
|
193
511
|
* @param {object} deps
|
|
194
|
-
* key(): string
|
|
195
|
-
* poll({key
|
|
512
|
+
* key?(): string —— 仅供旧 poll 兼容;v0 Adapter 自持凭据闭包
|
|
513
|
+
* poll({key?, remoteTaskId}): Provider poll v0(兼容旧 done/ok 形状)—— 协议适配器查询
|
|
196
514
|
* onSuccess(task, {urls}): string[] —— 结果转存,返回相对 outputs/ 的文件名列表
|
|
197
515
|
* intervalMs?: number —— 轮询间隔(默认 2500)
|
|
198
516
|
* maxWatchMs?: number —— 盯守上限(默认 20 分钟)
|
|
517
|
+
* allowEmptyResult?: boolean —— 文本任务允许成功但结果为空
|
|
199
518
|
*/
|
|
200
519
|
export function watch(task, deps) {
|
|
201
520
|
stopWatch(task.id);
|
|
521
|
+
let current = get(task.id);
|
|
522
|
+
const v2 = isV2Task(current);
|
|
523
|
+
if (v2 && current.watchState !== 'active') current = activateWatch(task.id);
|
|
202
524
|
const intervalMs = deps.intervalMs || 2500;
|
|
203
525
|
const startedAt = Date.now();
|
|
204
526
|
errStreak.set(task.id, 0);
|
|
205
527
|
let emptyOk = 0; // 状态已 SUCCEEDED 但 results 尚未填充的次数(服务端竞态)
|
|
206
528
|
|
|
207
|
-
const
|
|
529
|
+
const finishLegacy = (patch) => {
|
|
208
530
|
update(task.id, patch);
|
|
209
531
|
stopWatch(task.id);
|
|
210
532
|
};
|
|
533
|
+
const finishV2 = (patch) => {
|
|
534
|
+
try {
|
|
535
|
+
return mutateV2(task.id, (record) => Object.assign(record, patch));
|
|
536
|
+
} finally {
|
|
537
|
+
stopWatch(task.id);
|
|
538
|
+
}
|
|
539
|
+
};
|
|
211
540
|
|
|
212
541
|
const tick = async () => {
|
|
213
542
|
const t = get(task.id);
|
|
214
543
|
if (!t || t.status !== 'running') return stopWatch(task.id);
|
|
215
544
|
if (Date.now() - startedAt > (deps.maxWatchMs || MAX_WATCH_MS)) {
|
|
216
|
-
|
|
545
|
+
const message = '盯守超时:服务端可能仍在继续,可稍后用任务查询确认';
|
|
546
|
+
if (!v2) return finishLegacy({ status: 'failed', error: message });
|
|
547
|
+
return finishV2({
|
|
548
|
+
phase: 'terminal', watchState: 'exhausted', outcome: 'unknown',
|
|
549
|
+
lastError: providerErrorRecord(message, { stage: 'poll', category: 'timeout', acceptance: 'accepted', retryable: true }),
|
|
550
|
+
error: message
|
|
551
|
+
});
|
|
217
552
|
}
|
|
218
553
|
let r;
|
|
219
554
|
try {
|
|
220
|
-
|
|
555
|
+
const raw = await deps.poll({
|
|
556
|
+
key: typeof deps.key === 'function' ? deps.key() : undefined,
|
|
557
|
+
remoteTaskId: t.remoteTaskId
|
|
558
|
+
});
|
|
559
|
+
r = providerPollTaskView(raw);
|
|
221
560
|
} catch (err) {
|
|
222
561
|
const n = (errStreak.get(task.id) || 0) + 1;
|
|
223
562
|
errStreak.set(task.id, n);
|
|
224
563
|
if (n >= POLL_ERROR_TOLERANCE) {
|
|
225
|
-
|
|
564
|
+
const safe = providerErrorRecord(err, { stage: 'poll', acceptance: 'accepted', retryable: true });
|
|
565
|
+
const message = '轮询连续失败 ' + n + ' 次:' + safe.safeMessage;
|
|
566
|
+
if (!v2) return finishLegacy({ status: 'failed', error: message });
|
|
567
|
+
return finishV2({ phase: 'terminal', watchState: 'exhausted', outcome: 'unknown', lastError: safe, error: message });
|
|
226
568
|
}
|
|
227
569
|
return schedule(); // 瞬时抖动,容忍
|
|
228
570
|
}
|
|
229
571
|
errStreak.set(task.id, 0);
|
|
230
572
|
if (!r.done) {
|
|
231
|
-
|
|
573
|
+
if (v2) mutateV2(task.id, (record) => { record.progress = r.status || 'running'; });
|
|
574
|
+
else update(task.id, { progress: r.status || 'running' });
|
|
232
575
|
return schedule();
|
|
233
576
|
}
|
|
234
|
-
if (!r.ok)
|
|
235
|
-
|
|
236
|
-
|
|
577
|
+
if (!r.ok) {
|
|
578
|
+
const message = redactProviderMessage(r.message || '任务失败');
|
|
579
|
+
if (!v2) {
|
|
580
|
+
return finishLegacy({ status: r.kind === 'canceled' ? 'canceled' : 'failed', error: message });
|
|
581
|
+
}
|
|
582
|
+
if (r.kind === 'unknown') {
|
|
583
|
+
return finishV2({
|
|
584
|
+
phase: 'terminal', watchState: 'exhausted', outcome: 'unknown',
|
|
585
|
+
lastError: r.error || providerErrorRecord(message, {
|
|
586
|
+
stage: 'poll', category: 'provider', acceptance: 'accepted'
|
|
587
|
+
}),
|
|
588
|
+
error: message, finishedAt: new Date().toISOString()
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
if (r.kind === 'canceled') {
|
|
592
|
+
return finishV2({
|
|
593
|
+
phase: 'terminal', watchState: 'idle', outcome: 'canceled', cancelState: 'remote_confirmed',
|
|
594
|
+
lastError: r.error, error: message, finishedAt: new Date().toISOString()
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
return finishV2({
|
|
598
|
+
phase: 'terminal', watchState: 'idle', outcome: 'failed',
|
|
599
|
+
lastError: r.error || providerErrorRecord(message, {
|
|
600
|
+
stage: 'poll', category: 'provider', acceptance: 'accepted'
|
|
601
|
+
}),
|
|
602
|
+
error: message, finishedAt: new Date().toISOString()
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
// 媒体结果 URL 未就绪时短等;文本转写可显式允许空结果。
|
|
606
|
+
if (!deps.allowEmptyResult && !(r.urls || []).length && emptyOk < 10) {
|
|
237
607
|
emptyOk++;
|
|
238
608
|
return schedule();
|
|
239
609
|
}
|
|
610
|
+
if (v2 && !deps.allowEmptyResult && !(r.urls || []).length) {
|
|
611
|
+
const message = '生成已成功,但服务端未提供可交付的结果 URL';
|
|
612
|
+
return finishV2({
|
|
613
|
+
phase: 'terminal', watchState: 'idle', outcome: 'succeeded', deliveryState: 'failed',
|
|
614
|
+
lastError: providerErrorRecord(message, { stage: 'download', category: 'protocol', acceptance: 'accepted' }),
|
|
615
|
+
error: message, finishedAt: new Date().toISOString()
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
if (v2) {
|
|
619
|
+
// 生成成功与产物交付是两个事实;先持久化成功,再执行下载。
|
|
620
|
+
mutateV2(task.id, (record) => {
|
|
621
|
+
record.phase = 'running';
|
|
622
|
+
record.watchState = 'active';
|
|
623
|
+
record.outcome = 'succeeded';
|
|
624
|
+
record.deliveryState = 'downloading';
|
|
625
|
+
});
|
|
626
|
+
}
|
|
240
627
|
try {
|
|
241
|
-
const files = (await deps.onSuccess(
|
|
628
|
+
const files = (await deps.onSuccess(get(task.id), r)) || [];
|
|
242
629
|
// progress 一并收尾为数值:轮询期写入的 "RUNNING" 等文本不能残留到成功后
|
|
243
|
-
|
|
630
|
+
if (!v2) return finishLegacy({ status: 'succeeded', files, progress: '100%', finishedAt: new Date().toISOString() });
|
|
631
|
+
return finishV2({
|
|
632
|
+
phase: 'terminal', watchState: 'idle', outcome: 'succeeded', deliveryState: 'ready',
|
|
633
|
+
files, progress: '100%', finishedAt: new Date().toISOString()
|
|
634
|
+
});
|
|
244
635
|
} catch (err) {
|
|
245
|
-
|
|
636
|
+
const safe = providerErrorRecord(err, { stage: 'download', category: 'local_io', acceptance: 'accepted' });
|
|
637
|
+
const message = '结果转存失败:' + safe.safeMessage;
|
|
638
|
+
if (!v2) return finishLegacy({ status: 'failed', error: message });
|
|
639
|
+
return finishV2({
|
|
640
|
+
phase: 'terminal', watchState: 'idle', outcome: 'succeeded', deliveryState: 'failed',
|
|
641
|
+
lastError: safe, error: message, finishedAt: new Date().toISOString()
|
|
642
|
+
});
|
|
246
643
|
}
|
|
247
644
|
};
|
|
248
645
|
|
|
249
646
|
const schedule = () => {
|
|
250
647
|
const h = setTimeout(() => {
|
|
251
|
-
tick().catch(() =>
|
|
648
|
+
tick().catch(() => stopWatch(task.id));
|
|
252
649
|
}, intervalMs);
|
|
253
650
|
if (h.unref) h.unref(); // 永不拖住 DSH 进程退出
|
|
254
651
|
watchers.set(task.id, h);
|
|
255
652
|
};
|
|
256
653
|
const h0 = setTimeout(() => {
|
|
257
|
-
tick().catch(() =>
|
|
654
|
+
tick().catch(() => stopWatch(task.id));
|
|
258
655
|
}, 500);
|
|
259
656
|
if (h0.unref) h0.unref();
|
|
260
657
|
watchers.set(task.id, h0);
|
|
261
658
|
}
|
|
262
659
|
|
|
263
|
-
/**
|
|
660
|
+
/** 取消:v2 未得到远端确认时绝不伪装成“已取消”。 */
|
|
264
661
|
export function cancel(taskId, reason) {
|
|
265
662
|
stopWatch(taskId);
|
|
266
663
|
const t = get(taskId);
|
|
267
|
-
if (t
|
|
664
|
+
if (!t || t.status !== 'running') return;
|
|
665
|
+
if (!isV2Task(t)) {
|
|
666
|
+
update(taskId, { status: 'canceled', error: reason || '已取消' });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (!['none', 'unknown'].includes(t.outcome)) return;
|
|
670
|
+
const message = redactProviderMessage(reason || '已停止本地盯守');
|
|
671
|
+
mutateV2(taskId, (record) => {
|
|
672
|
+
record.phase = 'terminal';
|
|
673
|
+
record.watchState = record.acceptance === 'accepted' ? 'suspended' : 'idle';
|
|
674
|
+
record.error = record.acceptance === 'accepted'
|
|
675
|
+
? message + ';远端取消状态未知'
|
|
676
|
+
: message;
|
|
677
|
+
if (record.acceptance === 'none' || record.acceptance === 'not_accepted') {
|
|
678
|
+
record.outcome = 'canceled';
|
|
679
|
+
record.cancelState = 'local_confirmed';
|
|
680
|
+
} else {
|
|
681
|
+
record.outcome = 'unknown';
|
|
682
|
+
record.cancelState = 'unknown';
|
|
683
|
+
}
|
|
684
|
+
});
|
|
268
685
|
}
|
|
269
686
|
|
|
270
687
|
/**
|
|
@@ -297,26 +714,77 @@ export function resumePending(depsFor) {
|
|
|
297
714
|
for (const t of [...load().tasks]) {
|
|
298
715
|
try {
|
|
299
716
|
if (t.status !== 'running') continue;
|
|
717
|
+
if (!isV2Task(t)) {
|
|
718
|
+
if (!t.remoteTaskId) {
|
|
719
|
+
update(t.id, { status: 'failed', error: '提交未完成(无远程任务 id),启动时标失败清理' });
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (Date.now() - new Date(t.createdAt).getTime() > MAX_WATCH_MS) {
|
|
723
|
+
update(t.id, { status: 'failed', error: '重启前任务已超时,请重新发起' });
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
const deps = depsFor(t);
|
|
727
|
+
if (!deps) {
|
|
728
|
+
update(t.id, { status: 'failed', error: '无法恢复:对应供应商不可用' });
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
watch(t, deps);
|
|
732
|
+
resumed.push(t.id);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (t.outcome === 'succeeded') {
|
|
737
|
+
if (t.deliveryState !== 'ready' && t.deliveryState !== 'failed') {
|
|
738
|
+
mutateV2(t.id, (record) => {
|
|
739
|
+
record.phase = 'terminal';
|
|
740
|
+
record.watchState = 'idle';
|
|
741
|
+
record.deliveryState = 'failed';
|
|
742
|
+
record.error = '产物交付在进程退出前未完成;生成成功事实保持不变';
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (t.outcome !== 'none') continue;
|
|
300
748
|
if (!t.remoteTaskId) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
749
|
+
if (t.acceptance === 'not_accepted') {
|
|
750
|
+
finalizeNoAcceptance(t.id, t.lastError || '供应商明确未受理');
|
|
751
|
+
} else {
|
|
752
|
+
mutateV2(t.id, (record) => {
|
|
753
|
+
record.phase = 'terminal';
|
|
754
|
+
record.acceptance = 'unknown';
|
|
755
|
+
record.watchState = 'idle';
|
|
756
|
+
record.outcome = 'unknown';
|
|
757
|
+
record.error = '提交在受理结果落盘前中断,远端受理状态未知;禁止自动重提';
|
|
758
|
+
});
|
|
759
|
+
}
|
|
304
760
|
continue;
|
|
305
761
|
}
|
|
306
762
|
if (Date.now() - new Date(t.createdAt).getTime() > MAX_WATCH_MS) {
|
|
307
|
-
|
|
763
|
+
mutateV2(t.id, (record) => {
|
|
764
|
+
record.phase = 'terminal';
|
|
765
|
+
record.watchState = 'exhausted';
|
|
766
|
+
record.outcome = 'unknown';
|
|
767
|
+
record.error = '重启前任务已超过自动盯守窗口;远端结果未知';
|
|
768
|
+
});
|
|
308
769
|
continue;
|
|
309
770
|
}
|
|
310
771
|
const deps = depsFor(t);
|
|
311
772
|
if (!deps) {
|
|
312
|
-
|
|
773
|
+
mutateV2(t.id, (record) => {
|
|
774
|
+
record.phase = 'terminal';
|
|
775
|
+
record.watchState = 'suspended';
|
|
776
|
+
record.error = '无法恢复盯守:对应供应商不可用;远端任务状态未改变';
|
|
777
|
+
});
|
|
313
778
|
continue;
|
|
314
779
|
}
|
|
315
780
|
watch(t, deps);
|
|
316
781
|
resumed.push(t.id);
|
|
317
782
|
} catch (err) {
|
|
318
|
-
|
|
319
|
-
|
|
783
|
+
if (!isV2Task(get(t.id))) {
|
|
784
|
+
// 单条恢复失败只标死这一条,绝不让恢复循环(乃至宿主启动)陪葬
|
|
785
|
+
update(t.id, { status: 'failed', error: '恢复异常:' + String((err && err.message) || err) });
|
|
786
|
+
}
|
|
787
|
+
// v2 持久化失败时保留原事实,不再进行可能导致重复计费的自动动作。
|
|
320
788
|
}
|
|
321
789
|
}
|
|
322
790
|
return resumed;
|