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