dsh-multimodal-node 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/pool.js ADDED
@@ -0,0 +1,591 @@
1
+ /**
2
+ * The task pool: everything a client POSTs to the gateway lands here, and the
3
+ * scheduler decides which bound instance runs it.
4
+ *
5
+ * The contract with callers is ComfyUI's own: POST /prompt returns a prompt id
6
+ * immediately, and the caller polls /history/<id> until it appears. The pool
7
+ * therefore owns identifiers, not the backends — one gateway prompt id maps to
8
+ * one upstream prompt id on whichever instance got the work.
9
+ *
10
+ * Two orderings, switched at runtime:
11
+ *
12
+ * fifo — strictly by arrival (and explicit priority), what a plain queue
13
+ * does. Predictable, and what a user expects from "按先后顺序来".
14
+ * dynamic — "least total time": every task gets a cost estimate from its own
15
+ * graph, every instance keeps an EWMA of seconds-per-cost-unit, and
16
+ * the dispatcher repeatedly assigns the most expensive ready task
17
+ * to the instance that would finish it soonest (LPT over a
18
+ * machine-aware time model). Explicit priority still jumps the
19
+ * line, so a user can force a task in front.
20
+ */
21
+ import { randomUUID } from 'node:crypto';
22
+ import { Backend } from './comfy.js';
23
+
24
+ const MAX_ATTEMPTS = 2;
25
+ const TICK_MS = 400;
26
+
27
+ /** Rough "cost units" for one prompt graph: sampler work dominates, the rest is a floor. */
28
+ export function estimateCost(prompt) {
29
+ const nodes = prompt !== null && typeof prompt === 'object' ? prompt : {};
30
+ const byId = new Map(Object.entries(nodes));
31
+ const resolved = new Map();
32
+
33
+ /** Follow one input link back to the node that produces it (bounded depth). */
34
+ const follow = (value, depth = 0) => {
35
+ if (depth > 6 || !Array.isArray(value) || value.length < 1) return undefined;
36
+ const node = byId.get(String(value[0]));
37
+ if (node === undefined) return undefined;
38
+ return node;
39
+ };
40
+
41
+ const latentSize = (node) => {
42
+ if (node === undefined || typeof node !== 'object') return { mp: 1, batch: 1 };
43
+ const inputs = node.inputs ?? {};
44
+ if (inputs.width !== undefined && inputs.height !== undefined) {
45
+ const batch = Number(inputs.batch_size ?? 1) || 1;
46
+ return { mp: (Number(inputs.width) * Number(inputs.height)) / 1e6, batch };
47
+ }
48
+ if (Array.isArray(inputs.latent_image) || Array.isArray(inputs.samples)) {
49
+ return latentSize(follow(inputs.latent_image ?? inputs.samples, 1));
50
+ }
51
+ if (Array.isArray(inputs.pixels)) {
52
+ const source = follow(inputs.pixels);
53
+ return source === undefined ? { mp: 1, batch: 1 } : latentSize(source);
54
+ }
55
+ return { mp: 1, batch: 1 };
56
+ };
57
+
58
+ let cost = 0;
59
+ let samplers = 0;
60
+ for (const [id, node] of byId) {
61
+ if (node === null || typeof node !== 'object') continue;
62
+ const type = String(node.class_type ?? '');
63
+ const inputs = node.inputs ?? {};
64
+ if (/sampler/i.test(type) && inputs.steps !== undefined) {
65
+ samplers += 1;
66
+ const steps = Number(inputs.steps) || 20;
67
+ const { mp, batch } = latentSize({ inputs, class_type: type });
68
+ const cfg = Number(inputs.cfg ?? 7) || 7;
69
+ // CFG batches the UNet twice above 1.0 — the single biggest term.
70
+ const cfgFactor = cfg > 1.01 ? 2 : 1;
71
+ cost += steps * mp * batch * cfgFactor;
72
+ resolved.set(id, cost);
73
+ }
74
+ }
75
+ // VAE decode/encode and upscales cost real time but not sampler steps.
76
+ for (const [id, node] of byId) {
77
+ if (node === null || typeof node !== 'object') continue;
78
+ const type = String(node.class_type ?? '');
79
+ if (/VAEDecode|VAEEncode|Upscale|ImageScale/i.test(type)) {
80
+ const { mp, batch } = latentSize({ inputs: node.inputs ?? {}, class_type: type });
81
+ cost += 0.5 * mp * batch;
82
+ resolved.set(id, cost);
83
+ }
84
+ }
85
+ const nodeCount = byId.size;
86
+ // Model load: the first run after an unload pays a VRAM fill; approximate.
87
+ cost += nodeCount * 0.05;
88
+ if (samplers === 0) cost += 1;
89
+ return Math.max(0.1, cost);
90
+ }
91
+
92
+ export class TaskPool {
93
+ /**
94
+ * @param {{instances: import('./instances.js').InstanceManager, settings: () => any,
95
+ * onChange: () => void, log?: (level: string, message: string) => void}} options
96
+ */
97
+ constructor(options) {
98
+ this.options = options;
99
+ this.tasks = new Map(); // gateway id -> task
100
+ this.order = []; // ids of live tasks, kept sorted on demand
101
+ this.timer = null;
102
+ this.ticking = false;
103
+ this.listeners = new Set();
104
+ this.counters = { submitted: 0, completed: 0, failed: 0, retried: 0 };
105
+ this.lastTickAt = 0;
106
+ /** instanceId -> { by, token } while a person holds that canvas. */
107
+ this.bridgeLocks = new Map();
108
+ }
109
+
110
+ /**
111
+ * An instance being driven by a person (the remote console's canvas) leaves
112
+ * the rotation: two independent producers queueing on one GPU is how VRAM
113
+ * runs out mid-sampling. Passing `null` hands it back.
114
+ */
115
+ setBridgeLock(instanceId, lock) {
116
+ if (lock === null || lock === undefined) this.bridgeLocks.delete(instanceId);
117
+ else this.bridgeLocks.set(instanceId, lock);
118
+ this.notify();
119
+ void this.pump();
120
+ }
121
+
122
+ bridgeLockOf(instanceId) {
123
+ return this.bridgeLocks.get(instanceId) ?? null;
124
+ }
125
+
126
+ /**
127
+ * Move one queued task in the visible order. The pool is order-driven rather
128
+ * than slot-driven, so "up" means "priority above the task before me" — the
129
+ * only thing a person can actually mean by dragging a row.
130
+ */
131
+ reorder(id, delta) {
132
+ const task = this.tasks.get(id);
133
+ if (task === undefined) return { ok: false, error: `unknown prompt ${id}` };
134
+ if (task.state !== 'queued') return { ok: false, error: `只有排队中的任务能调整顺序(当前 ${task.state})` };
135
+ if (!Number.isFinite(delta) || delta === 0) return { ok: false, error: 'delta 不能为 0' };
136
+ return this.prioritize(id, delta > 0 ? -1 : 1);
137
+ }
138
+
139
+ get instances() {
140
+ return this.options.instances;
141
+ }
142
+
143
+ toggleDynamic(enabled) {
144
+ this.options.settings().dynamic = enabled === true;
145
+ this.options.onChange();
146
+ this.notify();
147
+ void this.pump();
148
+ }
149
+
150
+ notify() {
151
+ for (const fn of this.listeners) {
152
+ try {
153
+ fn();
154
+ } catch {
155
+ // a broken listener must not stop scheduling
156
+ }
157
+ }
158
+ }
159
+
160
+ subscribe(fn) {
161
+ this.listeners.add(fn);
162
+ return () => this.listeners.delete(fn);
163
+ }
164
+
165
+ backend(runtime) {
166
+ return new Backend({
167
+ id: runtime.id,
168
+ baseUrl: runtime.instance.baseUrl,
169
+ apiKey: runtime.instance.apiKey,
170
+ });
171
+ }
172
+
173
+ /** Queue one graph. Returns the gateway prompt id at once. */
174
+ enqueue(body) {
175
+ const prompt = body?.prompt;
176
+ if (prompt === null || typeof prompt !== 'object' || Array.isArray(prompt) || Object.keys(prompt).length === 0) {
177
+ throw new Error('prompt 必须是节点对象(ComfyUI /prompt 的 body.prompt)');
178
+ }
179
+ const requestedPriority = Number(
180
+ body.priority ?? body.extra_data?.gateway_priority ?? body.extra_data?.priority ?? 0,
181
+ );
182
+ const id = typeof body.prompt_id === 'string' && body.prompt_id !== '' ? body.prompt_id : randomUUID();
183
+ const task = {
184
+ id,
185
+ seq: this.counters.submitted + 1,
186
+ prompt,
187
+ clientId: typeof body.client_id === 'string' ? body.client_id : '',
188
+ extraData: body.extra_data ?? {},
189
+ priority: Number.isFinite(requestedPriority) ? Math.max(-100, Math.min(100, Math.floor(requestedPriority))) : 0,
190
+ cost: estimateCost(prompt),
191
+ enqueuedAt: Date.now(),
192
+ startedAt: null,
193
+ finishedAt: null,
194
+ state: 'queued',
195
+ backendId: null,
196
+ upstreamId: null,
197
+ attempts: 0,
198
+ error: null,
199
+ outputs: null,
200
+ historyEntry: null,
201
+ progress: null,
202
+ pinned: typeof body.endpoint === 'string' && body.endpoint !== '' && body.endpoint !== 'auto' ? body.endpoint : null,
203
+ };
204
+ this.tasks.set(id, task);
205
+ this.order.push(id);
206
+ this.counters.submitted += 1;
207
+ this.options.log?.('info', `enqueue ${id.slice(0, 8)} cost=${task.cost.toFixed(1)}`);
208
+ this.notify();
209
+ void this.pump();
210
+ return { prompt_id: id, number: task.seq, node_errors: {} };
211
+ }
212
+
213
+ /** Ready tasks in dispatch order for the current policy. */
214
+ ordered() {
215
+ const live = this.order
216
+ .map((id) => this.tasks.get(id))
217
+ .filter((task) => task !== undefined && task.state === 'queued');
218
+ const dynamic = this.options.settings().dynamic === true;
219
+ live.sort((left, right) => {
220
+ if (left.priority !== right.priority) return right.priority - left.priority;
221
+ if (dynamic) {
222
+ // LPT: the most expensive ready task goes first, so a fast GPU is
223
+ // never left holding a cheap job while a big one waits.
224
+ if (Math.abs(left.cost - right.cost) > 1e-6) return right.cost - left.cost;
225
+ }
226
+ return left.enqueuedAt - right.enqueuedAt;
227
+ });
228
+ return live;
229
+ }
230
+
231
+ /** Free capacity for one instance: maxConcurrent minus what it is running. */
232
+ freeSlots(runtime) {
233
+ const running = [...this.tasks.values()].filter(
234
+ (task) => task.state === 'running' && task.backendId === runtime.id,
235
+ ).length;
236
+ return runtime.instance.maxConcurrent - running;
237
+ }
238
+
239
+ pickBackend(task) {
240
+ const candidates = this.instances
241
+ .enabled()
242
+ .filter((runtime) => runtime.health.ok !== false && !runtime.starting && !runtime.stopping)
243
+ .filter((runtime) => !this.bridgeLocks.has(runtime.id))
244
+ .filter((runtime) => this.freeSlots(runtime) > 0);
245
+ if (candidates.length === 0) return undefined;
246
+ if (task.pinned !== null) {
247
+ const pinned = candidates.find((runtime) => runtime.id === task.pinned);
248
+ if (pinned !== undefined) return pinned;
249
+ }
250
+ // Time model: seconds = speed × cost. Prefer the instance that finishes
251
+ // this task soonest, weighted by the operator's dispatch weight.
252
+ let best;
253
+ let bestScore = Infinity;
254
+ for (const runtime of candidates) {
255
+ const speed = runtime.speed > 0 ? runtime.speed : 1;
256
+ const queueAhead = [...this.tasks.values()].filter(
257
+ (other) => other.state === 'running' && other.backendId === runtime.id,
258
+ ).reduce((sum, other) => sum + other.cost * speed, 0);
259
+ const score = (queueAhead + task.cost * speed) / Math.max(1, runtime.instance.weight);
260
+ if (score < bestScore) {
261
+ bestScore = score;
262
+ best = runtime;
263
+ }
264
+ }
265
+ return best;
266
+ }
267
+
268
+ /** One scheduling pass: fill every free slot it can, then arm the timer. */
269
+ async pump() {
270
+ if (this.ticking) return;
271
+ this.ticking = true;
272
+ this.lastTickAt = Date.now();
273
+ try {
274
+ for (;;) {
275
+ const queue = this.ordered();
276
+ if (queue.length === 0) break;
277
+ const task = queue[0];
278
+ const runtime = this.pickBackend(task);
279
+ if (runtime === undefined) break;
280
+ queue.shift();
281
+ await this.dispatch(task, runtime);
282
+ }
283
+ } finally {
284
+ this.ticking = false;
285
+ this.notify();
286
+ }
287
+ }
288
+
289
+ async dispatch(task, runtime) {
290
+ task.state = 'running';
291
+ task.startedAt = Date.now();
292
+ task.backendId = runtime.id;
293
+ task.attempts += 1;
294
+ task.error = null;
295
+ const body = {
296
+ prompt: task.prompt,
297
+ client_id: task.clientId !== '' ? task.clientId : `gateway-${runtime.id}`,
298
+ extra_data: task.extraData ?? {},
299
+ };
300
+ try {
301
+ const reply = await this.backend(runtime).prompt(body);
302
+ task.upstreamId = typeof reply?.prompt_id === 'string' ? reply.prompt_id : null;
303
+ this.options.log?.('info', `${task.id.slice(0, 8)} -> ${runtime.id} (${task.upstreamId?.slice(0, 8) ?? '?'})`);
304
+ } catch (error) {
305
+ const message = error instanceof Error ? error.message : String(error);
306
+ task.error = message;
307
+ runtime.lastError = message;
308
+ runtime.health = { ...runtime.health, ok: false, checkedAt: Date.now(), error: message };
309
+ this.counters.retried += 1;
310
+ if (task.attempts < MAX_ATTEMPTS) {
311
+ task.state = 'queued';
312
+ task.backendId = null;
313
+ task.upstreamId = null;
314
+ task.startedAt = null;
315
+ this.options.log?.('warn', `${task.id.slice(0, 8)} retry after: ${message}`);
316
+ } else {
317
+ task.state = 'failed';
318
+ task.finishedAt = Date.now();
319
+ this.counters.failed += 1;
320
+ }
321
+ }
322
+ this.notify();
323
+ }
324
+
325
+ /** Poll the backends: finished runs, dropped runs, progress. */
326
+ async collect() {
327
+ const running = [...this.tasks.values()].filter((task) => task.state === 'running' && task.upstreamId !== null);
328
+ const byBackend = new Map();
329
+ for (const task of running) {
330
+ const list = byBackend.get(task.backendId) ?? [];
331
+ list.push(task);
332
+ byBackend.set(task.backendId, list);
333
+ }
334
+ await Promise.all(
335
+ [...byBackend].map(async ([backendId, tasks]) => {
336
+ const runtime = this.instances.get(backendId);
337
+ if (runtime === undefined) return;
338
+ const client = this.backend(runtime);
339
+ let queue;
340
+ try {
341
+ queue = await client.queue();
342
+ } catch {
343
+ return; // a dead instance is handled by its own health probe
344
+ }
345
+ const runningIds = new Set((queue.queue_running ?? []).map((entry) => entry[1]));
346
+ const pendingIds = new Set((queue.queue_pending ?? []).map((entry) => entry[1]));
347
+ for (const task of tasks) {
348
+ const position = runningIds.has(task.upstreamId) ? 'running' : pendingIds.has(task.upstreamId) ? 'pending' : 'gone';
349
+ task.progress = { position };
350
+ if (position !== 'gone') continue;
351
+ let entry;
352
+ try {
353
+ const history = await client.historyOf(task.upstreamId);
354
+ entry = history?.[task.upstreamId];
355
+ } catch {
356
+ entry = undefined;
357
+ }
358
+ if (entry === undefined) {
359
+ // Not queued and not in history: the instance lost it (restart,
360
+ // cleartext cancel, crash). Retry elsewhere rather than hang.
361
+ if (task.attempts < MAX_ATTEMPTS) {
362
+ task.state = 'queued';
363
+ task.backendId = null;
364
+ task.upstreamId = null;
365
+ task.startedAt = null;
366
+ this.counters.retried += 1;
367
+ this.options.log?.('warn', `${task.id.slice(0, 8)} lost on ${backendId}, requeued`);
368
+ } else {
369
+ task.state = 'failed';
370
+ task.finishedAt = Date.now();
371
+ task.error = '任务在执行端消失(实例重启或被清队)';
372
+ this.counters.failed += 1;
373
+ }
374
+ continue;
375
+ }
376
+ this.finish(task, runtime, entry);
377
+ }
378
+ }),
379
+ );
380
+ }
381
+
382
+ finish(task, runtime, entry) {
383
+ const status = entry?.status ?? {};
384
+ const completed = status.completed === true || status.status_str === 'success';
385
+ task.historyEntry = entry;
386
+ task.outputs = entry?.outputs ?? {};
387
+ task.finishedAt = Date.now();
388
+ task.state = completed ? 'done' : 'failed';
389
+ task.error = completed ? null : (status.status_str === 'error' ? '执行失败' : status.status_str ?? '未知状态');
390
+ if (!completed) this.counters.failed += 1;
391
+ else this.counters.completed += 1;
392
+ // Learn this instance's speed: seconds per cost unit, EWMA over the last
393
+ // runs. One slow run (a cold model load) must not skew the scheduler.
394
+ const seconds = task.startedAt === null ? null : (task.finishedAt - task.startedAt) / 1000;
395
+ if (seconds !== null && seconds > 0.2 && task.cost > 0) {
396
+ const sample = seconds / task.cost;
397
+ runtime.speed = runtime.speedSamples === 0 ? sample : runtime.speed * 0.7 + sample * 0.3;
398
+ runtime.speedSamples += 1;
399
+ }
400
+ this.options.log?.(
401
+ completed ? 'info' : 'error',
402
+ `${task.id.slice(0, 8)} ${completed ? 'done' : 'failed'} on ${runtime.id} in ${seconds === null ? '?' : seconds.toFixed(1)}s`,
403
+ );
404
+ this.trim();
405
+ this.notify();
406
+ }
407
+
408
+ /** Keep finished tasks for the history view, but bounded. */
409
+ trim() {
410
+ const keepFinished = 200;
411
+ const finished = this.order
412
+ .map((id) => this.tasks.get(id))
413
+ .filter((task) => task !== undefined && task.state !== 'queued' && task.state !== 'running');
414
+ const excess = finished.length - keepFinished;
415
+ if (excess <= 0) return;
416
+ for (const task of finished.slice(0, excess)) {
417
+ this.tasks.delete(task.id);
418
+ }
419
+ this.order = this.order.filter((id) => this.tasks.has(id));
420
+ }
421
+
422
+ cancel(id) {
423
+ const task = this.tasks.get(id);
424
+ if (task === undefined) return { ok: false, error: `unknown prompt ${id}` };
425
+ if (task.state === 'queued') {
426
+ task.state = 'cancelled';
427
+ task.finishedAt = Date.now();
428
+ this.notify();
429
+ return { ok: true, cancelled: 'queued' };
430
+ }
431
+ if (task.state === 'running') {
432
+ const runtime = task.backendId === null ? undefined : this.instances.get(task.backendId);
433
+ task.state = 'cancelled';
434
+ task.finishedAt = Date.now();
435
+ if (runtime !== undefined && task.upstreamId !== null) {
436
+ // Pending: dequeue; running: interrupt only this prompt.
437
+ const client = this.backend(runtime);
438
+ void client
439
+ .deleteQueueItems([task.upstreamId])
440
+ .catch(() => {})
441
+ .then(() => client.interrupt(task.upstreamId).catch(() => {}));
442
+ }
443
+ this.notify();
444
+ return { ok: true, cancelled: 'running' };
445
+ }
446
+ return { ok: false, error: `prompt ${id} 已结束(${task.state})` };
447
+ }
448
+
449
+ /** Explicit priority bump: the "插队" button in the UI and the tool. */
450
+ prioritize(id, delta) {
451
+ const task = this.tasks.get(id);
452
+ if (task === undefined) return { ok: false, error: `unknown prompt ${id}` };
453
+ if (task.state !== 'queued') return { ok: false, error: `只有排队中的任务能调整优先级(当前 ${task.state})` };
454
+ task.priority = Math.max(-100, Math.min(100, task.priority + Math.floor(Number(delta) || 1)));
455
+ this.notify();
456
+ void this.pump();
457
+ return { ok: true, priority: task.priority };
458
+ }
459
+
460
+ /** Sum of `cost × speed` still ahead: a lower bound on remaining wall time. */
461
+ estimateRemaining() {
462
+ const now = Date.now();
463
+ const perInstance = new Map();
464
+ for (const runtime of this.instances.enabled()) perInstance.set(runtime.id, { busyUntil: 0, speed: runtime.speed || 1, id: runtime.id });
465
+ const work = [...this.tasks.values()].filter((task) => task.state === 'queued' || task.state === 'running');
466
+ // Running work already occupies its instance.
467
+ for (const task of work.filter((task) => task.state === 'running')) {
468
+ const slot = perInstance.get(task.backendId);
469
+ if (slot !== undefined) slot.busyUntil += task.cost * slot.speed * 1000;
470
+ }
471
+ for (const task of this.ordered()) {
472
+ let best;
473
+ for (const slot of perInstance.values()) {
474
+ if (best === undefined || slot.busyUntil < best.busyUntil) best = slot;
475
+ }
476
+ if (best === undefined) break;
477
+ best.busyUntil += task.cost * best.speed * 1000;
478
+ }
479
+ const finishes = [...perInstance.values()].map((slot) => slot.busyUntil);
480
+ const maxFinish = finishes.length === 0 ? 0 : Math.max(...finishes);
481
+ return { makespanMs: Math.round(maxFinish), at: now };
482
+ }
483
+
484
+ /** Merge our view with what the instances report about foreign work. */
485
+ async queueView() {
486
+ const running = [];
487
+ const pending = this.ordered().map((task, index) => [
488
+ task.seq,
489
+ task.id,
490
+ task.prompt,
491
+ task.extraData,
492
+ Object.keys(task.prompt),
493
+ index,
494
+ ]);
495
+ for (const task of this.tasks.values()) {
496
+ if (task.state !== 'running') continue;
497
+ running.push([task.seq, task.upstreamId ?? task.id, task.prompt, task.extraData, Object.keys(task.prompt), task.progress?.position === 'running' ? 0 : 0]);
498
+ }
499
+ // Foreign work already sitting in an instance's own queue is real and must
500
+ // stay visible, otherwise the frontend believes the GPU is free.
501
+ await Promise.all(
502
+ this.instances.enabled().map(async (runtime) => {
503
+ try {
504
+ const queue = await this.backend(runtime).queue();
505
+ for (const entry of queue.queue_running ?? []) {
506
+ if (entry[1] === undefined || [...this.tasks.values()].some((task) => task.upstreamId === entry[1])) continue;
507
+ running.push(entry);
508
+ }
509
+ for (const entry of queue.queue_pending ?? []) {
510
+ if (entry[1] === undefined || [...this.tasks.values()].some((task) => task.upstreamId === entry[1])) continue;
511
+ pending.push(entry);
512
+ }
513
+ } catch {
514
+ // an unreachable instance simply contributes nothing
515
+ }
516
+ }),
517
+ );
518
+ return { queue_running: running, queue_pending: pending };
519
+ }
520
+
521
+ snapshot() {
522
+ const tasks = this.order
523
+ .map((id) => this.tasks.get(id))
524
+ .filter((task) => task !== undefined)
525
+ .slice()
526
+ .sort((left, right) => {
527
+ const rank = (task) => (task.state === 'running' ? 0 : task.state === 'queued' ? 1 : 2);
528
+ if (rank(left) !== rank(right)) return rank(left) - rank(right);
529
+ if (left.state === 'queued' && right.state === 'queued') return (right.priority - left.priority) || (left.enqueuedAt - right.enqueuedAt);
530
+ return (right.finishedAt ?? right.enqueuedAt) - (left.finishedAt ?? left.enqueuedAt);
531
+ });
532
+ return {
533
+ dynamic: this.options.settings().dynamic === true,
534
+ counters: this.counters,
535
+ estimate: this.estimateRemaining(),
536
+ tasks: tasks.map((task) => ({
537
+ id: task.id,
538
+ seq: task.seq,
539
+ state: task.state,
540
+ backendId: task.backendId,
541
+ priority: task.priority,
542
+ cost: Number(task.cost.toFixed(2)),
543
+ attempts: task.attempts,
544
+ enqueuedAt: task.enqueuedAt,
545
+ startedAt: task.startedAt,
546
+ finishedAt: task.finishedAt,
547
+ error: task.error,
548
+ progress: task.progress,
549
+ pinned: task.pinned,
550
+ workflow: task.extraData?.workflowName ?? task.extraData?.workflow ?? null,
551
+ outputs: countOutputs(task.outputs),
552
+ })),
553
+ };
554
+ }
555
+
556
+ start() {
557
+ if (this.timer !== null) return;
558
+ this.timer = setInterval(() => {
559
+ void (async () => {
560
+ try {
561
+ await this.instances.probeAll(false);
562
+ await this.collect();
563
+ await this.pump();
564
+ } catch {
565
+ // a scheduling pass never throws into the timer
566
+ }
567
+ })();
568
+ }, TICK_MS);
569
+ if (typeof this.timer.unref === 'function') this.timer.unref();
570
+ }
571
+
572
+ dispose() {
573
+ if (this.timer !== null) {
574
+ clearInterval(this.timer);
575
+ this.timer = null;
576
+ }
577
+ this.listeners.clear();
578
+ }
579
+ }
580
+
581
+ function countOutputs(outputs) {
582
+ if (outputs === null || typeof outputs !== 'object') return 0;
583
+ let total = 0;
584
+ for (const value of Object.values(outputs)) {
585
+ if (value === null || typeof value !== 'object') continue;
586
+ for (const list of Object.values(value)) {
587
+ if (Array.isArray(list)) total += list.length;
588
+ }
589
+ }
590
+ return total;
591
+ }