vg-coder-cli 2.0.45 → 2.0.47

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.
@@ -0,0 +1,705 @@
1
+ const { exec } = require('child_process');
2
+ const chalk = require('chalk');
3
+ const store = require('./task-store');
4
+ const webhook = require('./task-webhook');
5
+
6
+ const ALLOWED_DOMAIN = 'aistudio.google.com';
7
+ const AUTO_LAUNCH_URL = 'https://aistudio.google.com';
8
+ const AUTO_LAUNCH_DEBOUNCE_MS = 30_000;
9
+ const INTER_TASK_GAP_MS = 2_500;
10
+ const RATE_LIMIT_COOLDOWN_MS = 30 * 60_000;
11
+ const COOLDOWN_TICK_MS = 60_000;
12
+ const MAX_TASK_ATTEMPTS = 3;
13
+ const RL_CODES = new Set(['rate_limit_exceeded', 'quota_exceeded', 'generation_failed']);
14
+
15
+ class TaskQueue {
16
+ constructor() {
17
+ this.io = null;
18
+ this.workers = new Map(); // socketId → WorkerReg
19
+ this.launchers = new Map(); // socketId → LauncherReg { socket, id, chromeId, email, lastSeen }
20
+ this.chromeIdToEmail = new Map(); // chromeId → email (learned from worker:register)
21
+ this.pending = []; // [{ id, workingDir }]
22
+ this.cache = new Map(); // taskId → task
23
+ this.launchedAt = 0;
24
+ this._cooldownTimer = null;
25
+ }
26
+
27
+ attachIO(io) {
28
+ this.io = io;
29
+ if (!this._cooldownTimer) {
30
+ this._cooldownTimer = setInterval(() => this._tickCooldowns(), COOLDOWN_TICK_MS);
31
+ if (this._cooldownTimer.unref) this._cooldownTimer.unref();
32
+ }
33
+ }
34
+
35
+ // ---------- Worker registry helpers ----------
36
+
37
+ /**
38
+ * Resolve a worker by socketId or workerLabel (email). If neither given,
39
+ * pick the first idle worker. Returns null if nothing matches.
40
+ */
41
+ _resolveWorker({ socketId, workerLabel } = {}) {
42
+ if (socketId) return this.workers.get(socketId) || null;
43
+ if (workerLabel) {
44
+ const lc = workerLabel.toLowerCase();
45
+ for (const w of this.workers.values()) {
46
+ if ((w.email || '').toLowerCase() === lc) return w;
47
+ }
48
+ return null;
49
+ }
50
+ for (const w of this.workers.values()) {
51
+ if (w.status === 'idle') return w;
52
+ }
53
+ // Fallback to any worker (e.g. for status checks)
54
+ return this.workers.values().next().value || null;
55
+ }
56
+
57
+ /**
58
+ * Send a request to a specific worker (or first idle if not specified) and
59
+ * await its ack reply.
60
+ * opts: { socketId?, workerLabel? }
61
+ */
62
+ requestWorker(event, payload = {}, opts = {}, timeoutMs = 10_000) {
63
+ // Allow legacy form: requestWorker(event, payload, timeoutMs)
64
+ if (typeof opts === 'number') { timeoutMs = opts; opts = {}; }
65
+ return new Promise((resolve, reject) => {
66
+ const worker = this._resolveWorker(opts);
67
+ if (!worker) return reject(new Error('no_worker_connected'));
68
+ const sock = worker.socket;
69
+ let done = false;
70
+ const timer = setTimeout(() => {
71
+ if (done) return;
72
+ done = true;
73
+ reject(new Error('worker_timeout'));
74
+ }, timeoutMs);
75
+ try {
76
+ sock.emit(event, payload, (response) => {
77
+ if (done) return;
78
+ done = true;
79
+ clearTimeout(timer);
80
+ resolve(response);
81
+ });
82
+ } catch (err) {
83
+ clearTimeout(timer);
84
+ done = true;
85
+ reject(err);
86
+ }
87
+ });
88
+ }
89
+
90
+ listWorkers() {
91
+ const out = [];
92
+ for (const w of this.workers.values()) {
93
+ out.push({
94
+ id: w.id,
95
+ email: w.email,
96
+ status: w.status,
97
+ currentTaskId: w.currentTaskId,
98
+ cooldownUntil: w.cooldownUntil || 0,
99
+ lastSeen: w.lastSeen,
100
+ meta: { domain: w.meta?.domain, chatId: w.meta?.chatId, registeredAt: w.meta?.registeredAt }
101
+ });
102
+ }
103
+ out.sort((a, b) => (a.email || '').localeCompare(b.email || ''));
104
+ return out;
105
+ }
106
+
107
+ workerStatus(label) {
108
+ if (!this.workers.size) return { connected: false };
109
+ if (!label) {
110
+ // Back-compat: pick first idle, else first any
111
+ const w = this._resolveWorker();
112
+ return w ? { connected: true, socketId: w.id, email: w.email, meta: w.meta || {}, status: w.status, currentTaskId: w.currentTaskId } : { connected: false };
113
+ }
114
+ const w = this._resolveWorker({ workerLabel: label });
115
+ if (!w) return { connected: false, label };
116
+ return { connected: true, socketId: w.id, email: w.email, meta: w.meta || {}, status: w.status, currentTaskId: w.currentTaskId };
117
+ }
118
+
119
+ // ---------- Worker lifecycle ----------
120
+
121
+ setWorker(socket, meta) {
122
+ if (meta?.domain && meta.domain !== ALLOWED_DOMAIN) {
123
+ socket.emit('worker:rejected', { reason: 'unsupported_domain', expected: ALLOWED_DOMAIN });
124
+ return false;
125
+ }
126
+ const email = (meta?.email && /[\w.+-]+@[\w-]+\.[\w-]+/.test(meta.email))
127
+ ? String(meta.email).toLowerCase()
128
+ : `unknown:${socket.id}`;
129
+
130
+ const reg = {
131
+ socket,
132
+ id: socket.id,
133
+ email,
134
+ meta: { ...(meta || {}), registeredAt: Date.now() },
135
+ status: 'idle',
136
+ currentTaskId: null,
137
+ cooldownUntil: 0,
138
+ lastSeen: Date.now()
139
+ };
140
+ this.workers.set(socket.id, reg);
141
+ // Link launcher in same Chrome profile to this email so future pin-targeted
142
+ // auto-launches go to the right profile.
143
+ if (meta?.chromeId && email && !email.startsWith('unknown:')) {
144
+ this._bindChromeIdToEmail(meta.chromeId, email);
145
+ }
146
+ console.log(chalk.green(`[TaskQueue] Worker registered: ${socket.id} (${email})`));
147
+ setImmediate(() => this._drain());
148
+ return true;
149
+ }
150
+
151
+ // ---------- Launcher (per-profile background SW) ----------
152
+
153
+ setLauncher(socket, meta) {
154
+ const chromeId = String(meta?.chromeId || '').trim();
155
+ if (!chromeId) {
156
+ console.log(chalk.yellow('[TaskQueue] Launcher rejected: missing chromeId'));
157
+ return false;
158
+ }
159
+ // Drop any existing launcher with same chromeId (reconnect case).
160
+ for (const [sid, l] of this.launchers) {
161
+ if (l.chromeId === chromeId && sid !== socket.id) {
162
+ try { l.socket.disconnect(true); } catch (_) {}
163
+ this.launchers.delete(sid);
164
+ }
165
+ }
166
+ const reg = {
167
+ socket,
168
+ id: socket.id,
169
+ chromeId,
170
+ email: this.chromeIdToEmail.get(chromeId) || null,
171
+ meta: { ...(meta || {}), registeredAt: Date.now() },
172
+ lastSeen: Date.now()
173
+ };
174
+ this.launchers.set(socket.id, reg);
175
+ console.log(chalk.green(`[TaskQueue] Launcher registered: ${socket.id} (chromeId=${chromeId.slice(0, 8)}…, email=${reg.email || 'unknown'})`));
176
+ return true;
177
+ }
178
+
179
+ clearLauncher(socket) {
180
+ const l = this.launchers.get(socket.id);
181
+ if (!l) return;
182
+ this.launchers.delete(socket.id);
183
+ console.log(chalk.yellow(`[TaskQueue] Launcher disconnected: ${socket.id} (${l.email || 'unknown'})`));
184
+ }
185
+
186
+ _bindChromeIdToEmail(chromeId, email) {
187
+ if (!chromeId || !email) return;
188
+ const prev = this.chromeIdToEmail.get(chromeId);
189
+ if (prev === email) return;
190
+ this.chromeIdToEmail.set(chromeId, email);
191
+ // Update any connected launcher with this chromeId.
192
+ for (const l of this.launchers.values()) {
193
+ if (l.chromeId === chromeId) l.email = email;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Pick a connected launcher matching the task's pin (or any launcher if no
199
+ * pin). Returns null if none.
200
+ */
201
+ _pickLauncher(task) {
202
+ if (!this.launchers.size) return null;
203
+ const pin = task?.workerLabel ? task.workerLabel.toLowerCase() : null;
204
+ if (pin) {
205
+ for (const l of this.launchers.values()) {
206
+ if ((l.email || '').toLowerCase() === pin) return l;
207
+ }
208
+ return null; // pin set but no launcher knows that email yet
209
+ }
210
+ // No pin → first launcher (insertion order; round-robin not worth it for rare auto-launch)
211
+ return this.launchers.values().next().value || null;
212
+ }
213
+
214
+ listLaunchers() {
215
+ return [...this.launchers.values()].map(l => ({
216
+ id: l.id,
217
+ chromeId: l.chromeId,
218
+ email: l.email,
219
+ lastSeen: l.lastSeen
220
+ }));
221
+ }
222
+
223
+ /**
224
+ * Send a request to one or all connected launchers and await their ack.
225
+ * opts: { workerLabel?, all? } — pin to a specific profile by email,
226
+ * or `all: true` to broadcast and aggregate replies from every launcher.
227
+ */
228
+ requestLauncher(event, payload = {}, opts = {}, timeoutMs = 5_000) {
229
+ if (opts.all) {
230
+ const launchers = [...this.launchers.values()];
231
+ if (!launchers.length) return Promise.reject(new Error('no_launcher_connected'));
232
+ return Promise.all(launchers.map(l => new Promise((resolve) => {
233
+ let done = false;
234
+ const timer = setTimeout(() => { if (!done) { done = true; resolve({ launcher: { email: l.email, chromeId: l.chromeId }, ok: false, error: 'launcher_timeout' }); } }, timeoutMs);
235
+ try {
236
+ l.socket.emit(event, payload, (response) => {
237
+ if (done) return;
238
+ done = true;
239
+ clearTimeout(timer);
240
+ resolve({ launcher: { email: l.email, chromeId: l.chromeId }, ...(response || { ok: false, error: 'no_response' }) });
241
+ });
242
+ } catch (err) {
243
+ clearTimeout(timer);
244
+ done = true;
245
+ resolve({ launcher: { email: l.email, chromeId: l.chromeId }, ok: false, error: err.message });
246
+ }
247
+ })));
248
+ }
249
+ return new Promise((resolve, reject) => {
250
+ const launcher = this._pickLauncher({ workerLabel: opts.workerLabel || null });
251
+ if (!launcher) return reject(new Error('no_launcher_connected'));
252
+ let done = false;
253
+ const timer = setTimeout(() => {
254
+ if (done) return;
255
+ done = true;
256
+ reject(new Error('launcher_timeout'));
257
+ }, timeoutMs);
258
+ try {
259
+ launcher.socket.emit(event, payload, (response) => {
260
+ if (done) return;
261
+ done = true;
262
+ clearTimeout(timer);
263
+ resolve(response);
264
+ });
265
+ } catch (err) {
266
+ clearTimeout(timer);
267
+ done = true;
268
+ reject(err);
269
+ }
270
+ });
271
+ }
272
+
273
+ clearWorker(socket) {
274
+ const worker = this.workers.get(socket.id);
275
+ if (!worker) return;
276
+ this.workers.delete(socket.id);
277
+ console.log(chalk.yellow(`[TaskQueue] Worker disconnected: ${socket.id} (${worker.email})`));
278
+
279
+ // If a task was running on this worker, fail it (with possible failover).
280
+ if (worker.currentTaskId) {
281
+ const task = this.cache.get(worker.currentTaskId);
282
+ if (task && task.status === 'running') {
283
+ this._failOrRequeue(task, worker, { code: 'worker_disconnected', message: 'Worker disconnected during execution' });
284
+ }
285
+ }
286
+ }
287
+
288
+ // ---------- Public queue API ----------
289
+
290
+ async enqueue(task) {
291
+ task.status = 'queued';
292
+ task.timing = task.timing || {};
293
+ task.timing.createdAt = task.timing.createdAt || Date.now();
294
+ task.timing.queuedAt = Date.now();
295
+ task.attempts = task.attempts || [];
296
+ task.workerLabel = task.workerLabel || null;
297
+ await store.saveTask(task);
298
+ this.cache.set(task.id, task);
299
+ this.pending.push({ id: task.id, workingDir: task.workingDir });
300
+
301
+ // Auto-launch if no worker matches the task's pin (or no workers at all).
302
+ if (!this._anyWorkerCouldMatch(task)) this._autoLaunchBrowser(task);
303
+
304
+ setImmediate(() => this._drain());
305
+ return { taskId: task.id, status: task.status, position: this.pending.length };
306
+ }
307
+
308
+ async cancel(taskId, workingDir) {
309
+ const task = this.cache.get(taskId) || await store.loadTask(workingDir, taskId);
310
+ if (!task) return { ok: false, code: 404 };
311
+ if (task.status === 'done' || task.status === 'failed' || task.status === 'canceled') {
312
+ return { ok: false, code: 409, status: task.status };
313
+ }
314
+ task.cancelRequestedAt = Date.now();
315
+
316
+ if (task.status === 'queued') {
317
+ this.pending = this.pending.filter(p => p.id !== taskId);
318
+ task.status = 'canceled';
319
+ task.timing.finishedAt = Date.now();
320
+ await store.saveTask(task);
321
+ this.cache.set(taskId, task);
322
+ return { ok: true, status: 'canceled' };
323
+ }
324
+
325
+ // running → best-effort: ask the worker to stop, mark canceled
326
+ const worker = this._workerByCurrentTask(taskId);
327
+ if (worker) {
328
+ try { worker.socket.emit('task:cancel', { taskId }); } catch (_) {}
329
+ worker.currentTaskId = null;
330
+ worker.status = 'idle';
331
+ }
332
+ task.status = 'canceled';
333
+ task.timing.finishedAt = Date.now();
334
+ task.timing.durationMs = task.timing.finishedAt - (task.timing.startedAt || task.timing.createdAt);
335
+ await store.saveTask(task);
336
+ this.cache.set(taskId, task);
337
+ setImmediate(() => this._drain());
338
+ webhook.deliver(task).catch(() => {});
339
+ return { ok: true, status: 'canceled' };
340
+ }
341
+
342
+ async get(taskId, workingDir) {
343
+ if (this.cache.has(taskId)) return this.cache.get(taskId);
344
+ return store.loadTask(workingDir, taskId);
345
+ }
346
+
347
+ async list(workingDir, filter) {
348
+ return store.listTasks(workingDir, filter);
349
+ }
350
+
351
+ // ---------- Worker callbacks ----------
352
+
353
+ async onWorkerComplete(taskId, payload, socketId) {
354
+ const worker = socketId ? this.workers.get(socketId) : this._workerByCurrentTask(taskId);
355
+ const task = this.cache.get(taskId);
356
+ if (!task || !worker || worker.currentTaskId !== taskId) {
357
+ console.log(chalk.yellow(`[TaskQueue] Stale complete for ${taskId} from ${socketId || '?'}`));
358
+ return;
359
+ }
360
+ const workerEmail = worker.email;
361
+ worker.currentTaskId = null;
362
+ worker.status = 'idle';
363
+ worker.lastSeen = Date.now();
364
+
365
+ if (task.cancelRequestedAt) {
366
+ // discard result; still recycle the tab so next task gets a clean slate
367
+ this._recycleWorkerTab(workerEmail);
368
+ setImmediate(() => this._drain());
369
+ return;
370
+ }
371
+ task.status = 'done';
372
+ task.result = { chatId: payload?.chatId || null };
373
+ task.timing.finishedAt = Date.now();
374
+ task.timing.durationMs = task.timing.finishedAt - (task.timing.startedAt || task.timing.createdAt);
375
+ await store.writeResult(task.workingDir, task.id, payload?.markdown || '');
376
+ await store.saveTask(task);
377
+ this.cache.set(task.id, task);
378
+ console.log(chalk.green(`[TaskQueue] Completed ${task.id} on ${workerEmail} in ${task.timing.durationMs}ms`));
379
+ webhook.deliver(task).catch(() => {});
380
+
381
+ // Recycle the worker's tab so the next task gets a fresh chat with the
382
+ // model URL pinned. AI Studio's "+ New chat" link drops the ?model=
383
+ // query and Angular routerLink ignores href hijack — close+reopen via
384
+ // launcher is the only deterministic way to lock the model per task.
385
+ this._recycleWorkerTab(workerEmail);
386
+ // Drain right away so other idle workers pick up queued tasks while
387
+ // this worker's tab is being recycled (~5s).
388
+ setImmediate(() => this._drain());
389
+ }
390
+
391
+ async onWorkerFailed(taskId, error, socketId) {
392
+ const worker = socketId ? this.workers.get(socketId) : this._workerByCurrentTask(taskId);
393
+ const task = this.cache.get(taskId);
394
+ if (!task || !worker || worker.currentTaskId !== taskId) {
395
+ console.log(chalk.yellow(`[TaskQueue] Stale failure for ${taskId} from ${socketId || '?'}`));
396
+ return;
397
+ }
398
+ const workerEmail = worker.email;
399
+ await this._failOrRequeue(task, worker, error);
400
+ // Only recycle if the worker isn't in cooldown (rate-limited workers
401
+ // have their tab still open intentionally — recycling won't unfreeze
402
+ // the quota and just wastes a tab cycle).
403
+ const w = this.workers.get(socketId);
404
+ if (!w || w.status !== 'rate_limited') {
405
+ this._recycleWorkerTab(workerEmail);
406
+ } else {
407
+ setTimeout(() => this._drain(), INTER_TASK_GAP_MS);
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Ask the launcher in the given profile to close all AI Studio tabs and
413
+ * open a fresh one. The browser-side close fires worker:disconnect →
414
+ * clearWorker; the open creates a new tab whose worker registers ~3-5s
415
+ * later. _drain runs naturally on register so any queued task picks up
416
+ * the fresh worker. Fire-and-forget — errors are logged but don't bubble.
417
+ */
418
+ _recycleWorkerTab(workerEmail) {
419
+ if (!workerEmail || workerEmail.startsWith('unknown:')) return;
420
+ // Mark every worker matching this email as 'recycling' so _drain skips
421
+ // them while the close+open is in flight. Without this, a queued task
422
+ // can race in and get dispatched to a worker whose tab is about to
423
+ // close, leading to a stuck "running" task that never completes.
424
+ for (const w of this.workers.values()) {
425
+ if (w.email === workerEmail && w.status === 'idle') w.status = 'recycling';
426
+ }
427
+ setImmediate(async () => {
428
+ try {
429
+ await this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000);
430
+ await new Promise(r => setTimeout(r, 400));
431
+ await this.requestLauncher('launcher:open_tab', {}, { workerLabel: workerEmail }, 8_000);
432
+ console.log(chalk.cyan(`[TaskQueue] Recycled tab for ${workerEmail}`));
433
+ } catch (err) {
434
+ console.log(chalk.yellow(`[TaskQueue] Recycle ${workerEmail} failed: ${err.message}`));
435
+ // Restore status if recycle failed and worker still in registry.
436
+ for (const w of this.workers.values()) {
437
+ if (w.email === workerEmail && w.status === 'recycling') w.status = 'idle';
438
+ }
439
+ setImmediate(() => this._drain());
440
+ }
441
+ });
442
+ }
443
+
444
+ // ---------- Internal ----------
445
+
446
+ _workerByCurrentTask(taskId) {
447
+ for (const w of this.workers.values()) if (w.currentTaskId === taskId) return w;
448
+ return null;
449
+ }
450
+
451
+ _hasOtherUsableWorker(task, excludeSocketId) {
452
+ for (const w of this.workers.values()) {
453
+ if (w.id === excludeSocketId) continue;
454
+ if (w.status === 'rate_limited') continue;
455
+ if (task.workerLabel && w.email !== task.workerLabel.toLowerCase()) continue;
456
+ return true;
457
+ }
458
+ return false;
459
+ }
460
+
461
+ _anyWorkerCouldMatch(task) {
462
+ if (!this.workers.size) return false;
463
+ if (!task.workerLabel) return true;
464
+ const lc = task.workerLabel.toLowerCase();
465
+ for (const w of this.workers.values()) if (w.email === lc) return true;
466
+ return false;
467
+ }
468
+
469
+ /**
470
+ * Decide whether a failed task should be requeued onto another worker
471
+ * (rate-limit failover) or finalized as failed.
472
+ */
473
+ async _failOrRequeue(task, worker, error) {
474
+ const code = error?.code || 'unknown';
475
+ const message = error?.message || String(error);
476
+
477
+ task.attempts = task.attempts || [];
478
+ task.attempts.push({ workerEmail: worker.email, code, message: message.slice(0, 300), at: Date.now() });
479
+
480
+ // Mark worker as rate-limited if applicable
481
+ if (RL_CODES.has(code)) {
482
+ worker.status = 'rate_limited';
483
+ worker.cooldownUntil = Date.now() + RATE_LIMIT_COOLDOWN_MS;
484
+ worker.currentTaskId = null;
485
+ console.log(chalk.yellow(`[TaskQueue] Worker ${worker.email} rate-limited until ${new Date(worker.cooldownUntil).toLocaleTimeString()}`));
486
+ } else {
487
+ // Plain failure — worker becomes idle again
488
+ worker.currentTaskId = null;
489
+ worker.status = 'idle';
490
+ }
491
+
492
+ const canFailover = RL_CODES.has(code)
493
+ && task.attempts.length < MAX_TASK_ATTEMPTS
494
+ && this._hasOtherUsableWorker(task, worker.id);
495
+
496
+ if (canFailover) {
497
+ // Re-queue at head; don't fire webhook
498
+ task.status = 'queued';
499
+ task.timing.queuedAt = Date.now();
500
+ delete task.timing.startedAt;
501
+ delete task.timing.finishedAt;
502
+ delete task.timing.durationMs;
503
+ task.error = null;
504
+ this.pending.unshift({ id: task.id, workingDir: task.workingDir });
505
+ await store.saveTask(task);
506
+ this.cache.set(task.id, task);
507
+ console.log(chalk.cyan(`[TaskQueue] Failover ${task.id}: requeued (attempt ${task.attempts.length}/${MAX_TASK_ATTEMPTS})`));
508
+ return;
509
+ }
510
+
511
+ // Finalize as failed
512
+ task.status = 'failed';
513
+ task.error = { code, message };
514
+ task.timing.finishedAt = Date.now();
515
+ task.timing.durationMs = task.timing.finishedAt - (task.timing.startedAt || task.timing.createdAt);
516
+ await store.saveTask(task);
517
+ this.cache.set(task.id, task);
518
+ console.log(chalk.red(`[TaskQueue] Failed ${task.id} on ${worker.email}: ${code}`));
519
+ webhook.deliver(task).catch(() => {});
520
+ }
521
+
522
+ /**
523
+ * Dispatch as many queued tasks as there are matching idle workers.
524
+ */
525
+ async _drain() {
526
+ if (!this.workers.size) {
527
+ if (this.pending.length) {
528
+ const head = this.cache.get(this.pending[0].id);
529
+ this._autoLaunchBrowser(head);
530
+ }
531
+ return;
532
+ }
533
+
534
+ // Snapshot workers with status idle
535
+ let idleWorkers = [...this.workers.values()].filter(w => w.status === 'idle');
536
+ if (!idleWorkers.length && this.pending.length) {
537
+ // No idle workers — nothing to dispatch right now.
538
+ return;
539
+ }
540
+
541
+ // Walk pending; for each task, find a matching idle worker
542
+ const dispatched = new Set();
543
+ let i = 0;
544
+ while (i < this.pending.length && idleWorkers.length) {
545
+ const entry = this.pending[i];
546
+ const task = this.cache.get(entry.id) || await store.loadTask(entry.workingDir, entry.id);
547
+ if (!task || task.status !== 'queued') {
548
+ this.pending.splice(i, 1);
549
+ continue;
550
+ }
551
+ // Pick first idle worker that matches the pin
552
+ const wIdx = idleWorkers.findIndex(w =>
553
+ !task.workerLabel || w.email === task.workerLabel.toLowerCase()
554
+ );
555
+ if (wIdx === -1) {
556
+ if (!this._anyWorkerCouldMatch(task)) this._autoLaunchBrowser(task);
557
+ i++;
558
+ continue;
559
+ }
560
+ const worker = idleWorkers[wIdx];
561
+ idleWorkers.splice(wIdx, 1);
562
+ this.pending.splice(i, 1);
563
+ dispatched.add(task.id);
564
+ await this._dispatch(task, worker);
565
+ }
566
+ }
567
+
568
+ async _dispatch(task, worker) {
569
+ task.status = 'running';
570
+ task.timing.startedAt = Date.now();
571
+ task.worker = { socketId: worker.id, email: worker.email, ...(worker.meta || {}) };
572
+ await store.saveTask(task);
573
+ this.cache.set(task.id, task);
574
+ worker.currentTaskId = task.id;
575
+ worker.status = 'busy';
576
+
577
+ const port = this.io?.httpServer?.address?.()?.port || 6868;
578
+ const baseUrl = `http://127.0.0.1:${port}`;
579
+ const filesPayload = (task.files || []).map(f => ({
580
+ idx: f.idx,
581
+ name: f.name,
582
+ mime: f.mime,
583
+ size: f.size,
584
+ url: `${baseUrl}/api/tasks/${task.id}/files/${f.idx}`
585
+ }));
586
+
587
+ try {
588
+ worker.socket.emit('task:execute', {
589
+ taskId: task.id,
590
+ prompt: task.prompt,
591
+ files: filesPayload,
592
+ meta: task.meta || null,
593
+ timeoutMs: task.timeoutMs || 5 * 60_000
594
+ });
595
+ console.log(chalk.cyan(`[TaskQueue] Dispatched ${task.id} → ${worker.email} (${worker.id})`));
596
+ } catch (err) {
597
+ worker.currentTaskId = null;
598
+ worker.status = 'idle';
599
+ await this._failOrRequeue(task, worker, { code: 'dispatch_failed', message: err.message });
600
+ }
601
+ }
602
+
603
+ _tickCooldowns() {
604
+ const now = Date.now();
605
+ let revived = 0;
606
+ for (const w of this.workers.values()) {
607
+ if (w.status === 'rate_limited' && w.cooldownUntil && now >= w.cooldownUntil) {
608
+ w.status = 'idle';
609
+ w.cooldownUntil = 0;
610
+ revived++;
611
+ console.log(chalk.green(`[TaskQueue] Worker ${w.email} cooldown ended`));
612
+ }
613
+ }
614
+ if (revived) setImmediate(() => this._drain());
615
+ }
616
+
617
+ _autoLaunchBrowser(task = null) {
618
+ const now = Date.now();
619
+ if (now - this.launchedAt < AUTO_LAUNCH_DEBOUNCE_MS) return;
620
+ this.launchedAt = now;
621
+
622
+ // Prefer asking a connected launcher (extension background SW in the right
623
+ // Chrome profile) to open / focus the AI Studio tab. Falls back to OS
624
+ // `open` if no launcher matches the pin or none are connected.
625
+ const launcher = this._pickLauncher(task);
626
+ if (launcher) {
627
+ const tag = launcher.email || `chromeId=${launcher.chromeId.slice(0, 8)}…`;
628
+ console.log(chalk.cyan(`[TaskQueue] No matching worker — asking launcher ${tag} to open AI Studio`));
629
+ try {
630
+ launcher.socket.emit('launcher:open_aistudio', {
631
+ taskId: task?.id || null,
632
+ workerLabel: task?.workerLabel || null
633
+ }, (ack) => {
634
+ if (ack?.ok) {
635
+ console.log(chalk.green(`[TaskQueue] Launcher ${tag} ${ack.action} tab ${ack.tabId}`));
636
+ } else {
637
+ console.log(chalk.yellow(`[TaskQueue] Launcher ${tag} failed: ${ack?.error || 'no ack'}`));
638
+ }
639
+ });
640
+ return;
641
+ } catch (err) {
642
+ console.log(chalk.yellow(`[TaskQueue] Launcher emit failed: ${err.message}`));
643
+ // fall through to OS open
644
+ }
645
+ }
646
+
647
+ let cmd;
648
+ switch (process.platform) {
649
+ case 'darwin': cmd = `open "${AUTO_LAUNCH_URL}"`; break;
650
+ case 'win32': cmd = `start "" "${AUTO_LAUNCH_URL}"`; break;
651
+ default: cmd = `xdg-open "${AUTO_LAUNCH_URL}"`; break;
652
+ }
653
+ console.log(chalk.cyan(`[TaskQueue] No matching worker / launcher — falling back to OS open ${AUTO_LAUNCH_URL}`));
654
+ exec(cmd, (err) => {
655
+ if (err) console.log(chalk.yellow(`[TaskQueue] OS auto-launch failed: ${err.message}`));
656
+ });
657
+ }
658
+
659
+ // ---------- Boot rehydration ----------
660
+
661
+ async rehydrateFromProjects(projectManager) {
662
+ const projects = projectManager?.getAllProjects?.() || [];
663
+ const dirs = new Set();
664
+ if (projectManager?.getActiveProject?.()) dirs.add(projectManager.getActiveProject().workingDir);
665
+ for (const p of projects) if (p.workingDir) dirs.add(p.workingDir);
666
+ if (!dirs.size) dirs.add(process.cwd());
667
+
668
+ let queued = 0, restarted = 0;
669
+ for (const dir of dirs) {
670
+ const tasks = await store.rehydrate(dir);
671
+ for (const task of tasks) {
672
+ task.workingDir = task.workingDir || dir;
673
+ task.attempts = task.attempts || [];
674
+ task.workerLabel = task.workerLabel || null;
675
+ if (task.status === 'running') {
676
+ task.status = 'failed';
677
+ task.error = { code: 'server_restart', message: 'Server restarted while task was running' };
678
+ task.timing = task.timing || {};
679
+ task.timing.finishedAt = Date.now();
680
+ task.timing.durationMs = task.timing.finishedAt - (task.timing.startedAt || task.timing.createdAt);
681
+ await store.saveTask(task);
682
+ this.cache.set(task.id, task);
683
+ webhook.deliver(task).catch(() => {});
684
+ restarted++;
685
+ } else if (task.status === 'queued') {
686
+ this.cache.set(task.id, task);
687
+ this.pending.push({ id: task.id, workingDir: task.workingDir });
688
+ queued++;
689
+ } else {
690
+ this.cache.set(task.id, task);
691
+ }
692
+ }
693
+ }
694
+ this.pending.sort((a, b) => {
695
+ const ta = (this.cache.get(a.id)?.timing?.createdAt) || 0;
696
+ const tb = (this.cache.get(b.id)?.timing?.createdAt) || 0;
697
+ return ta - tb;
698
+ });
699
+ if (queued || restarted) {
700
+ console.log(chalk.cyan(`[TaskQueue] Rehydrated: ${queued} queued, ${restarted} marked failed (server_restart)`));
701
+ }
702
+ }
703
+ }
704
+
705
+ module.exports = new TaskQueue();