vg-coder-cli 2.0.49 → 2.0.50
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/INTEGRATION.md +1 -0
- package/package.json +1 -1
- package/src/server/task-queue.js +124 -3
package/INTEGRATION.md
CHANGED
|
@@ -29,6 +29,7 @@ External Service ◀── POST <webhookUrl> (push, retry 3×)
|
|
|
29
29
|
- **Workers**: mỗi tab Chrome trên `aistudio.google.com` với extension VG Coder = 1 worker. Server tự load-balance song song giữa các worker idle.
|
|
30
30
|
- **Launchers**: mỗi Chrome profile = 1 launcher (background service worker của extension). Dùng để mở/đóng tab AI Studio và lock model cho từng task.
|
|
31
31
|
- **Auto-recycle**: sau mỗi task done/failed, server gọi launcher đóng tab worker đó + mở lại tab mới với `?model=<target>` — guarantee model lock per task. Mark worker `recycling` để tránh race.
|
|
32
|
+
- **Idle tab TTL** (v2.0.50+): sau khi recycle xong, nếu không có task mới trong `VG_WORKER_IDLE_TTL_MS` (default `120000` = 2 phút), launcher đóng tab → CPU container về ~0%. Task tiếp theo sẽ trigger `launcher:open_aistudio` để mở lại tab. Set `VG_WORKER_IDLE_TTL_MS=0` để giữ tab always-on (behavior cũ); set `=1` để đóng ngay sau mỗi task.
|
|
32
33
|
- **Failover**: rate-limit / quota error → mark worker `rate_limited` 30 phút, requeue task sang worker khác (max 3 attempts). Tab giữ nguyên qua cooldown (không recycle khi rate-limited).
|
|
33
34
|
- **Default model**: `gemini-3-flash-preview` (free + multimodal — verified working với image + PDF). Avoid `-lite` (text-only) hoặc `-pro-preview` (paid alias to Deep Research).
|
|
34
35
|
- **Persistence**: tasks lưu vào `.vg/tasks/<id>/{task.json,result.md,files/}` của active project.
|
package/package.json
CHANGED
package/src/server/task-queue.js
CHANGED
|
@@ -12,6 +12,18 @@ const COOLDOWN_TICK_MS = 60_000;
|
|
|
12
12
|
const MAX_TASK_ATTEMPTS = 3;
|
|
13
13
|
const RL_CODES = new Set(['rate_limit_exceeded', 'quota_exceeded', 'generation_failed']);
|
|
14
14
|
|
|
15
|
+
// Idle worker tab TTL: close the AI Studio tab if no new task arrives within
|
|
16
|
+
// this window after a task completes (and the post-task recycle finishes).
|
|
17
|
+
// Set 0 to disable (worker tab always-on, behavior pre-2.0.50).
|
|
18
|
+
// Set 1 to close immediately after every task. Default 120000 (2 minutes) keeps
|
|
19
|
+
// burst tasks fast while idling background CPU to ~0% on long quiet stretches.
|
|
20
|
+
const WORKER_IDLE_TTL_MS = (() => {
|
|
21
|
+
const raw = process.env.VG_WORKER_IDLE_TTL_MS;
|
|
22
|
+
if (raw === undefined || raw === '') return 120_000;
|
|
23
|
+
const n = parseInt(raw, 10);
|
|
24
|
+
return Number.isFinite(n) && n >= 0 ? n : 120_000;
|
|
25
|
+
})();
|
|
26
|
+
|
|
15
27
|
class TaskQueue {
|
|
16
28
|
constructor() {
|
|
17
29
|
this.io = null;
|
|
@@ -22,6 +34,10 @@ class TaskQueue {
|
|
|
22
34
|
this.cache = new Map(); // taskId → task
|
|
23
35
|
this.launchedAt = 0;
|
|
24
36
|
this._cooldownTimer = null;
|
|
37
|
+
// workerEmail → setTimeout handle. When timer fires, close that email's
|
|
38
|
+
// AI Studio tab (free CPU). Cancelled when a new task arrives or a worker
|
|
39
|
+
// recycles for that email.
|
|
40
|
+
this._idleCloseTimers = new Map();
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
attachIO(io) {
|
|
@@ -127,6 +143,27 @@ class TaskQueue {
|
|
|
127
143
|
? String(meta.email).toLowerCase()
|
|
128
144
|
: `unknown:${socket.id}`;
|
|
129
145
|
|
|
146
|
+
// If the same socket re-registers (e.g. extension scraped the email after
|
|
147
|
+
// initial register completed), MERGE into the existing entry instead of
|
|
148
|
+
// replacing it. Replacing would clobber currentTaskId / status while a
|
|
149
|
+
// task was mid-flight and lose the only path back to onWorkerCompleted.
|
|
150
|
+
const existing = this.workers.get(socket.id);
|
|
151
|
+
if (existing) {
|
|
152
|
+
const wasUnknown = (existing.email || '').startsWith('unknown:');
|
|
153
|
+
existing.email = email;
|
|
154
|
+
existing.meta = { ...existing.meta, ...(meta || {}) };
|
|
155
|
+
existing.lastSeen = Date.now();
|
|
156
|
+
if (meta?.chromeId && email && !email.startsWith('unknown:')) {
|
|
157
|
+
this._bindChromeIdToEmail(meta.chromeId, email);
|
|
158
|
+
}
|
|
159
|
+
if (wasUnknown && email !== existing.email) {
|
|
160
|
+
// shouldn't happen — kept for clarity (email already assigned above)
|
|
161
|
+
}
|
|
162
|
+
console.log(chalk.green(`[TaskQueue] Worker re-register: ${socket.id} (${email})`));
|
|
163
|
+
setImmediate(() => this._drain());
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
130
167
|
const reg = {
|
|
131
168
|
socket,
|
|
132
169
|
id: socket.id,
|
|
@@ -276,6 +313,12 @@ class TaskQueue {
|
|
|
276
313
|
this.workers.delete(socket.id);
|
|
277
314
|
console.log(chalk.yellow(`[TaskQueue] Worker disconnected: ${socket.id} (${worker.email})`));
|
|
278
315
|
|
|
316
|
+
// Drop pending idle-close timer if no other worker for this email remains.
|
|
317
|
+
if (worker.email && !worker.email.startsWith('unknown:')) {
|
|
318
|
+
const stillPresent = [...this.workers.values()].some(w => w.email === worker.email);
|
|
319
|
+
if (!stillPresent) this._cancelIdleClose(worker.email);
|
|
320
|
+
}
|
|
321
|
+
|
|
279
322
|
// If a task was running on this worker, fail it (with possible failover).
|
|
280
323
|
if (worker.currentTaskId) {
|
|
281
324
|
const task = this.cache.get(worker.currentTaskId);
|
|
@@ -298,6 +341,9 @@ class TaskQueue {
|
|
|
298
341
|
this.cache.set(task.id, task);
|
|
299
342
|
this.pending.push({ id: task.id, workingDir: task.workingDir });
|
|
300
343
|
|
|
344
|
+
// Cancel any pending idle-close so the about-to-dispatch tab stays open.
|
|
345
|
+
this._cancelAllIdleCloseForLaunch(task);
|
|
346
|
+
|
|
301
347
|
// Auto-launch if no worker matches the task's pin (or no workers at all).
|
|
302
348
|
if (!this._anyWorkerCouldMatch(task)) this._autoLaunchBrowser(task);
|
|
303
349
|
|
|
@@ -417,6 +463,10 @@ class TaskQueue {
|
|
|
417
463
|
*/
|
|
418
464
|
_recycleWorkerTab(workerEmail) {
|
|
419
465
|
if (!workerEmail || workerEmail.startsWith('unknown:')) return;
|
|
466
|
+
// Cancel any pending idle-close — we're about to recycle, no need to close
|
|
467
|
+
// separately. (This handles the race where a task lands during the idle
|
|
468
|
+
// window: drain triggers recycle, idle timer becomes redundant.)
|
|
469
|
+
this._cancelIdleClose(workerEmail);
|
|
420
470
|
// Mark every worker matching this email as 'recycling' so _drain skips
|
|
421
471
|
// them while the close+open is in flight. Without this, a queued task
|
|
422
472
|
// can race in and get dispatched to a worker whose tab is about to
|
|
@@ -424,12 +474,22 @@ class TaskQueue {
|
|
|
424
474
|
for (const w of this.workers.values()) {
|
|
425
475
|
if (w.email === workerEmail && w.status === 'idle') w.status = 'recycling';
|
|
426
476
|
}
|
|
477
|
+
// If WORKER_IDLE_TTL_MS === 1, treat post-task recycle as close-only:
|
|
478
|
+
// skip reopen; let _autoLaunchBrowser open a fresh tab when next task lands.
|
|
479
|
+
const closeOnly = WORKER_IDLE_TTL_MS === 1;
|
|
427
480
|
setImmediate(async () => {
|
|
428
481
|
try {
|
|
429
482
|
await this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000);
|
|
483
|
+
if (closeOnly) {
|
|
484
|
+
console.log(chalk.cyan(`[TaskQueue] Closed tab for ${workerEmail} (VG_WORKER_IDLE_TTL_MS=1)`));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
430
487
|
await new Promise(r => setTimeout(r, 400));
|
|
431
488
|
await this.requestLauncher('launcher:open_tab', {}, { workerLabel: workerEmail }, 8_000);
|
|
432
489
|
console.log(chalk.cyan(`[TaskQueue] Recycled tab for ${workerEmail}`));
|
|
490
|
+
// Schedule idle close. If new task arrives before TTL, enqueue() will
|
|
491
|
+
// cancel this timer.
|
|
492
|
+
this._scheduleIdleClose(workerEmail);
|
|
433
493
|
} catch (err) {
|
|
434
494
|
console.log(chalk.yellow(`[TaskQueue] Recycle ${workerEmail} failed: ${err.message}`));
|
|
435
495
|
// Restore status if recycle failed and worker still in registry.
|
|
@@ -441,6 +501,62 @@ class TaskQueue {
|
|
|
441
501
|
});
|
|
442
502
|
}
|
|
443
503
|
|
|
504
|
+
// ---------- Idle worker tab TTL ----------
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Schedule an automatic tab-close for a worker email after WORKER_IDLE_TTL_MS
|
|
508
|
+
* of idleness. Only kicks in when there's no pending task that can land on
|
|
509
|
+
* this worker. Re-arming (e.g. after another recycle) cancels the previous
|
|
510
|
+
* timer.
|
|
511
|
+
*/
|
|
512
|
+
_scheduleIdleClose(workerEmail) {
|
|
513
|
+
if (!workerEmail || workerEmail.startsWith('unknown:')) return;
|
|
514
|
+
if (!WORKER_IDLE_TTL_MS) return; // 0 → feature disabled
|
|
515
|
+
if (WORKER_IDLE_TTL_MS === 1) return; // 1 → handled inline by _recycleWorkerTab
|
|
516
|
+
this._cancelIdleClose(workerEmail);
|
|
517
|
+
const t = setTimeout(() => {
|
|
518
|
+
this._idleCloseTimers.delete(workerEmail);
|
|
519
|
+
// Skip if a task is already queued for this worker (or workerless).
|
|
520
|
+
const hasPendingForEmail = this.pending.some(p => {
|
|
521
|
+
const task = this.cache.get(p.id);
|
|
522
|
+
if (!task) return false;
|
|
523
|
+
if (!task.workerLabel) return true;
|
|
524
|
+
return task.workerLabel.toLowerCase() === workerEmail;
|
|
525
|
+
});
|
|
526
|
+
if (hasPendingForEmail) return;
|
|
527
|
+
// Skip if any worker for this email is busy/recycling (in-flight work).
|
|
528
|
+
for (const w of this.workers.values()) {
|
|
529
|
+
if (w.email === workerEmail && w.status !== 'idle') return;
|
|
530
|
+
}
|
|
531
|
+
console.log(chalk.gray(`[TaskQueue] Idle TTL expired for ${workerEmail} — closing tab`));
|
|
532
|
+
this.requestLauncher('launcher:close_tab', {}, { workerLabel: workerEmail }, 5_000)
|
|
533
|
+
.catch(err => console.log(chalk.yellow(`[TaskQueue] Idle close ${workerEmail} failed: ${err.message}`)));
|
|
534
|
+
}, WORKER_IDLE_TTL_MS);
|
|
535
|
+
if (t.unref) t.unref();
|
|
536
|
+
this._idleCloseTimers.set(workerEmail, t);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
_cancelIdleClose(workerEmail) {
|
|
540
|
+
const t = this._idleCloseTimers.get(workerEmail);
|
|
541
|
+
if (t) {
|
|
542
|
+
clearTimeout(t);
|
|
543
|
+
this._idleCloseTimers.delete(workerEmail);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
_cancelAllIdleCloseForLaunch(task) {
|
|
548
|
+
// When a task is enqueued, cancel any idle-close timers that could
|
|
549
|
+
// otherwise close a tab we're about to dispatch to.
|
|
550
|
+
if (!task) return;
|
|
551
|
+
if (task.workerLabel) {
|
|
552
|
+
this._cancelIdleClose(task.workerLabel.toLowerCase());
|
|
553
|
+
} else {
|
|
554
|
+
for (const email of Array.from(this._idleCloseTimers.keys())) {
|
|
555
|
+
this._cancelIdleClose(email);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
444
560
|
// ---------- Internal ----------
|
|
445
561
|
|
|
446
562
|
_workerByCurrentTask(taskId) {
|
|
@@ -531,10 +647,15 @@ class TaskQueue {
|
|
|
531
647
|
return;
|
|
532
648
|
}
|
|
533
649
|
|
|
534
|
-
// Snapshot workers with status idle
|
|
535
|
-
|
|
650
|
+
// Snapshot workers with status idle. Skip workers whose email scrape
|
|
651
|
+
// is still pending (`unknown:<sid>`) — extension typically re-registers
|
|
652
|
+
// ~3-5s later with the real email, and dispatching during that window
|
|
653
|
+
// races with the re-register and clobbers task lifecycle state.
|
|
654
|
+
let idleWorkers = [...this.workers.values()].filter(w =>
|
|
655
|
+
w.status === 'idle' && !(w.email || '').startsWith('unknown:')
|
|
656
|
+
);
|
|
536
657
|
if (!idleWorkers.length && this.pending.length) {
|
|
537
|
-
// No idle workers — nothing to dispatch right now.
|
|
658
|
+
// No usable idle workers — nothing to dispatch right now.
|
|
538
659
|
return;
|
|
539
660
|
}
|
|
540
661
|
|