vg-coder-cli 2.0.62 → 2.0.64
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 +21 -4
- package/package.json +1 -1
- package/src/server/api-server.js +85 -17
- package/src/server/task-queue.js +148 -35
package/INTEGRATION.md
CHANGED
|
@@ -105,6 +105,7 @@ ssh ... "docker exec chrome-mcp-vgcoder supervisorctl restart chromium"
|
|
|
105
105
|
| `webhookUrl` | no | URL nhận callback khi task xong |
|
|
106
106
|
| `meta` | no | JSON string, đính kèm metadata tùy ý (forward nguyên về webhook) |
|
|
107
107
|
| `workerLabel` | no | Email worker để pin task vào tài khoản cụ thể (vd `alice@gmail.com`) |
|
|
108
|
+
| `chromeId` | no | UUID Chrome profile để pin task vào **exact profile** (precedence > `workerLabel`). Dùng khi cần Best-of-N parallel runs hoặc khi 2 profile share cùng email. Nếu `chromeId` không match launcher/worker đang kết nối → **404 `launcher_not_found`** (không silent fallback). |
|
|
108
109
|
|
|
109
110
|
**Response 202**
|
|
110
111
|
|
|
@@ -133,6 +134,7 @@ ssh ... "docker exec chrome-mcp-vgcoder supervisorctl restart chromium"
|
|
|
133
134
|
"webhook": { "attempts": [{ "at": 0, "status": 200 }], "deliveredAt": 0 },
|
|
134
135
|
"worker": { "socketId": "...", "email": "alice@gmail.com", "chatId": "..." },
|
|
135
136
|
"workerLabel": null,
|
|
137
|
+
"chromeId": null,
|
|
136
138
|
"attempts": [
|
|
137
139
|
{ "workerEmail": "alice@gmail.com", "code": "rate_limit_exceeded", "at": 0 }
|
|
138
140
|
],
|
|
@@ -170,6 +172,7 @@ Worker dùng để fetch file đã upload. Caller thường không gọi.
|
|
|
170
172
|
{
|
|
171
173
|
"id": "abc...",
|
|
172
174
|
"email": "alice@gmail.com",
|
|
175
|
+
"chromeId": "75cd593d-a861-4a6f-89a0-386e1e9e61c5",
|
|
173
176
|
"status": "idle|busy|rate_limited",
|
|
174
177
|
"currentTaskId": null,
|
|
175
178
|
"cooldownUntil": 1778252081156,
|
|
@@ -211,15 +214,29 @@ Server có thể chủ động list / đóng / mở tab AI Studio trong từng p
|
|
|
211
214
|
|
|
212
215
|
| Method | Path | Body / Query | Mô tả |
|
|
213
216
|
|---|---|---|---|
|
|
214
|
-
| `GET` | `/api/launcher/tabs` | `?label=<email>` (optional) | List tabs trong profile (hoặc all profiles nếu bỏ
|
|
215
|
-
| `POST` | `/api/launcher/close-tab` | `{ workerLabel?, tabId? }` | Đóng tab cụ thể, hoặc tất cả tab AI Studio nếu bỏ `tabId` |
|
|
216
|
-
| `POST` | `/api/launcher/open-tab` | `{ workerLabel?, model?, url?, active? }` | Mở tab mới. `model` mặc định `gemini-3-flash-preview`. Response v2.0.52+ kèm `requested_model` / `actual_model` / `fallback_occurred` (URL-based, **không reliable** với AI Studio versions mới — xem note) |
|
|
217
|
+
| `GET` | `/api/launcher/tabs` | `?chromeId=<uuid>` \| `?label=<email>` (optional) | List tabs trong profile (hoặc all profiles nếu bỏ pin) |
|
|
218
|
+
| `POST` | `/api/launcher/close-tab` | `{ chromeId? \| workerLabel?, tabId?, all? }` | Đóng tab cụ thể, hoặc tất cả tab AI Studio nếu bỏ `tabId` |
|
|
219
|
+
| `POST` | `/api/launcher/open-tab` | `{ chromeId? \| workerLabel?, model?, url?, active? }` | Mở tab mới. `model` mặc định `gemini-3-flash-preview`. Response v2.0.52+ kèm `requested_model` / `actual_model` / `fallback_occurred` (URL-based, **không reliable** với AI Studio versions mới — xem note) |
|
|
220
|
+
|
|
221
|
+
**Pin precedence**: `chromeId` > `workerLabel` > default. `chromeId` chỉ exact-match một
|
|
222
|
+
launcher cụ thể — dùng để address **profile chưa bind email** (mới cài extension,
|
|
223
|
+
chưa mở AI Studio lần nào → `email: null` trong `/api/launchers`). Sau khi `open-tab`
|
|
224
|
+
load AI Studio, DOM scraper bind email tự động và lần sau có thể dùng `workerLabel`.
|
|
225
|
+
|
|
226
|
+
**Error contract** (v2.0.64+): explicit pin không match → HTTP **404
|
|
227
|
+
`launcher_not_found`**. Không có launcher nào kết nối → HTTP **503
|
|
228
|
+
`no_launcher_connected`**. Trước v2.0.64, mọi miss đều silent fallback sang
|
|
229
|
+
launcher khác — phá invariant pin của orchestrator.
|
|
217
230
|
|
|
218
231
|
```bash
|
|
219
232
|
# List tab tất cả profile
|
|
220
233
|
curl -s http://127.0.0.1:6868/api/launcher/tabs | jq
|
|
221
234
|
|
|
222
|
-
#
|
|
235
|
+
# Bootstrap profile mới (chưa có email) — open AI Studio bằng chromeId
|
|
236
|
+
curl -X POST -d '{"chromeId":"81b9b30c-f9de-4920-899c-9ca3782fa3d7","model":"gemini-3.1-pro-preview"}' \
|
|
237
|
+
-H 'Content-Type: application/json' http://127.0.0.1:6868/api/launcher/open-tab
|
|
238
|
+
|
|
239
|
+
# Reset tab profile alice về model multimodal (vẫn route by email — backward compat)
|
|
223
240
|
curl -X POST -d '{"workerLabel":"alice@gmail.com"}' \
|
|
224
241
|
-H 'Content-Type: application/json' http://127.0.0.1:6868/api/launcher/close-tab
|
|
225
242
|
curl -X POST -d '{"workerLabel":"alice@gmail.com","model":"gemini-3-flash-preview"}' \
|
package/package.json
CHANGED
package/src/server/api-server.js
CHANGED
|
@@ -555,6 +555,26 @@ class ApiServer {
|
|
|
555
555
|
const workerLabel = req.body?.workerLabel
|
|
556
556
|
? String(req.body.workerLabel).toLowerCase().trim()
|
|
557
557
|
: null;
|
|
558
|
+
// chromeId pin — addresses an exact Chrome profile, independent
|
|
559
|
+
// of email binding. Precedence over workerLabel during dispatch.
|
|
560
|
+
// If the chromeId isn't connected (launcher or worker), reject
|
|
561
|
+
// with 404 — no silent fallback to a different profile.
|
|
562
|
+
const chromeId = req.body?.chromeId
|
|
563
|
+
? String(req.body.chromeId).trim()
|
|
564
|
+
: null;
|
|
565
|
+
if (chromeId) {
|
|
566
|
+
const knownLauncher = [...taskQueue.launchers.values()]
|
|
567
|
+
.some(l => l.chromeId === chromeId);
|
|
568
|
+
const knownWorker = [...taskQueue.workers.values()]
|
|
569
|
+
.some(w => w.chromeId === chromeId);
|
|
570
|
+
if (!knownLauncher && !knownWorker) {
|
|
571
|
+
return res.status(404).json({
|
|
572
|
+
error: 'launcher_not_found',
|
|
573
|
+
chromeId,
|
|
574
|
+
message: 'No connected launcher or worker matches chromeId'
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
558
578
|
|
|
559
579
|
const id = taskStore.newTaskId();
|
|
560
580
|
const finalDir = taskStore.taskDir(req.workingDir, id);
|
|
@@ -584,6 +604,7 @@ class ApiServer {
|
|
|
584
604
|
webhook: { attempts: [], deliveredAt: null },
|
|
585
605
|
worker: null,
|
|
586
606
|
workerLabel,
|
|
607
|
+
chromeId,
|
|
587
608
|
attempts: [],
|
|
588
609
|
result: null,
|
|
589
610
|
error: null,
|
|
@@ -668,47 +689,94 @@ class ApiServer {
|
|
|
668
689
|
res.json({ workers: taskQueue.listWorkers() });
|
|
669
690
|
});
|
|
670
691
|
|
|
692
|
+
// Reset cooldown manually. Useful khi operator chắc chắn AI Studio đã hết
|
|
693
|
+
// rate-limit (vd quan sát qua noVNC submit prompt được) nhưng server timer
|
|
694
|
+
// 30 phút chưa expire. Body: { email: "alice@gmail.com" } hoặc {} cho tất cả.
|
|
695
|
+
this.app.post('/api/workers/reset-cooldown', (req, res) => {
|
|
696
|
+
const targetEmail = (req.body?.email || '').toString().toLowerCase().trim();
|
|
697
|
+
const reset = [];
|
|
698
|
+
for (const w of taskQueue.workers.values()) {
|
|
699
|
+
if (w.status !== 'rate_limited') continue;
|
|
700
|
+
if (targetEmail && w.email !== targetEmail) continue;
|
|
701
|
+
w.status = 'idle';
|
|
702
|
+
w.cooldownUntil = 0;
|
|
703
|
+
w.lastFailureCode = null;
|
|
704
|
+
w.lastFailureMessage = null;
|
|
705
|
+
reset.push({ id: w.id, email: w.email });
|
|
706
|
+
}
|
|
707
|
+
if (reset.length) setImmediate(() => taskQueue._drain());
|
|
708
|
+
res.json({ ok: true, reset, count: reset.length });
|
|
709
|
+
});
|
|
710
|
+
|
|
671
711
|
this.app.get('/api/launchers', (req, res) => {
|
|
672
712
|
res.json({ launchers: taskQueue.listLaunchers() });
|
|
673
713
|
});
|
|
674
714
|
|
|
675
|
-
//
|
|
715
|
+
// Translate task-queue errors to HTTP status. `launcher_not_found` is the
|
|
716
|
+
// contract for an explicit chromeId/workerLabel pin that doesn't match any
|
|
717
|
+
// connected launcher — distinct from `no_launcher_connected` (no launchers
|
|
718
|
+
// at all). Callers rely on 404 to decide retry-vs-give-up.
|
|
719
|
+
const launcherErr = (e) => {
|
|
720
|
+
if (e.message === 'launcher_not_found') return 404;
|
|
721
|
+
if (e.message === 'no_launcher_connected') return 503;
|
|
722
|
+
return 503;
|
|
723
|
+
};
|
|
724
|
+
// Build opts from body/query. chromeId takes precedence over workerLabel
|
|
725
|
+
// (mirrors task-queue _pickLauncher precedence). `all: true` only honored
|
|
726
|
+
// when neither pin is provided.
|
|
727
|
+
const launcherOpts = (body = {}, query = {}) => {
|
|
728
|
+
const chromeId = (body.chromeId || query.chromeId || '').toString().trim();
|
|
729
|
+
if (chromeId) return { chromeId };
|
|
730
|
+
const label = (body.workerLabel || query.workerLabel || query.label || '').toString().trim();
|
|
731
|
+
if (label) return { workerLabel: label };
|
|
732
|
+
return null; // caller decides between {all:true} and {} default
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
// List AI Studio tabs across all profiles (or 1 if pinned).
|
|
736
|
+
// Query/body: chromeId? | workerLabel?
|
|
676
737
|
this.app.get('/api/launcher/tabs', async (req, res) => {
|
|
677
738
|
try {
|
|
678
|
-
const
|
|
679
|
-
const opts = label ? { workerLabel: label } : { all: true };
|
|
739
|
+
const opts = launcherOpts({}, req.query) || { all: true };
|
|
680
740
|
const result = await taskQueue.requestLauncher('launcher:list_tabs', {}, opts, 5_000);
|
|
681
741
|
res.json(result);
|
|
682
|
-
} catch (e) { res.status(
|
|
742
|
+
} catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
|
|
683
743
|
});
|
|
684
744
|
|
|
685
|
-
// Close tab(s). Body: { workerLabel?, tabId? }
|
|
745
|
+
// Close tab(s). Body: { chromeId? | workerLabel?, tabId?, all? }
|
|
746
|
+
// Omit tabId to close all tabs in the matched profile.
|
|
686
747
|
this.app.post('/api/launcher/close-tab', async (req, res) => {
|
|
687
748
|
try {
|
|
688
749
|
const body = req.body || {};
|
|
689
|
-
const opts = body
|
|
750
|
+
const opts = launcherOpts(body) || (body.all ? { all: true } : {});
|
|
690
751
|
const payload = body.tabId != null ? { tabId: body.tabId } : {};
|
|
691
752
|
const result = await taskQueue.requestLauncher('launcher:close_tab', payload, opts, 5_000);
|
|
692
753
|
res.json(result);
|
|
693
|
-
} catch (e) { res.status(
|
|
754
|
+
} catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
|
|
694
755
|
});
|
|
695
756
|
|
|
696
|
-
// Open new AI Studio tab.
|
|
757
|
+
// Open new AI Studio tab.
|
|
758
|
+
// Body: { chromeId? | workerLabel?, model?, url?, active? }
|
|
759
|
+
// chromeId is the only way to address an unbound launcher (email still
|
|
760
|
+
// null because the extension hasn't scraped AI Studio yet) — opening a
|
|
761
|
+
// tab via chromeId triggers the DOM scrape which binds the email.
|
|
697
762
|
this.app.post('/api/launcher/open-tab', async (req, res) => {
|
|
698
763
|
try {
|
|
699
764
|
const body = req.body || {};
|
|
700
|
-
const opts = body
|
|
765
|
+
const opts = launcherOpts(body) || {};
|
|
701
766
|
const payload = { url: body.url, model: body.model, active: body.active };
|
|
702
|
-
// Pin model
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
767
|
+
// Pin model so _recycleWorkerTab reopens with the same model.
|
|
768
|
+
// Key precedence: chromeId (exact profile) > workerLabel (email).
|
|
769
|
+
// No pin field → broadcast: pin every connected launcher under
|
|
770
|
+
// both its chromeId and email (single-tenant ergonomics).
|
|
706
771
|
if (body.model) {
|
|
707
|
-
if (
|
|
708
|
-
taskQueue.
|
|
772
|
+
if (opts.chromeId) {
|
|
773
|
+
taskQueue._pinnedModelByLauncher.set(opts.chromeId, body.model);
|
|
774
|
+
} else if (opts.workerLabel) {
|
|
775
|
+
taskQueue._pinnedModelByLauncher.set(opts.workerLabel, body.model);
|
|
709
776
|
} else {
|
|
710
777
|
for (const l of taskQueue.launchers.values()) {
|
|
711
|
-
if (l.
|
|
778
|
+
if (l.chromeId) taskQueue._pinnedModelByLauncher.set(l.chromeId, body.model);
|
|
779
|
+
if (l.email) taskQueue._pinnedModelByLauncher.set(l.email, body.model);
|
|
712
780
|
}
|
|
713
781
|
}
|
|
714
782
|
}
|
|
@@ -717,7 +785,7 @@ class ApiServer {
|
|
|
717
785
|
// phải lớn hơn để có response thay vì error.
|
|
718
786
|
const result = await taskQueue.requestLauncher('launcher:open_tab', payload, opts, 15_000);
|
|
719
787
|
res.json(result);
|
|
720
|
-
} catch (e) { res.status(
|
|
788
|
+
} catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
|
|
721
789
|
});
|
|
722
790
|
|
|
723
791
|
this.app.get('/api/worker/status', (req, res) => {
|
package/src/server/task-queue.js
CHANGED
|
@@ -38,10 +38,11 @@ class TaskQueue {
|
|
|
38
38
|
// AI Studio tab (free CPU). Cancelled when a new task arrives or a worker
|
|
39
39
|
// recycles for that email.
|
|
40
40
|
this._idleCloseTimers = new Map();
|
|
41
|
-
//
|
|
42
|
-
// recycle dùng để reopen tab với cùng
|
|
43
|
-
// Default null → recycle dùng
|
|
44
|
-
|
|
41
|
+
// launcher key (chromeId hoặc email fallback) → model string. Set qua
|
|
42
|
+
// /api/launcher/open-tab body.model; recycle dùng để reopen tab với cùng
|
|
43
|
+
// model (giữ pin qua nhiều task). Default null → recycle dùng
|
|
44
|
+
// AISTUDIO_DEFAULT_MODEL của extension.
|
|
45
|
+
this._pinnedModelByLauncher = new Map();
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
attachIO(io) {
|
|
@@ -109,13 +110,22 @@ class TaskQueue {
|
|
|
109
110
|
|
|
110
111
|
listWorkers() {
|
|
111
112
|
const out = [];
|
|
113
|
+
const now = Date.now();
|
|
112
114
|
for (const w of this.workers.values()) {
|
|
115
|
+
const cd = w.cooldownUntil || 0;
|
|
116
|
+
const remainingMs = cd > now ? cd - now : 0;
|
|
113
117
|
out.push({
|
|
114
118
|
id: w.id,
|
|
115
119
|
email: w.email,
|
|
120
|
+
chromeId: w.chromeId || null,
|
|
116
121
|
status: w.status,
|
|
117
122
|
currentTaskId: w.currentTaskId,
|
|
118
|
-
cooldownUntil:
|
|
123
|
+
cooldownUntil: cd,
|
|
124
|
+
cooldownUntilISO: cd ? new Date(cd).toISOString() : null,
|
|
125
|
+
cooldownRemainingMs: remainingMs,
|
|
126
|
+
cooldownRemainingMinutes: Math.ceil(remainingMs / 60_000),
|
|
127
|
+
lastFailureCode: w.lastFailureCode || null,
|
|
128
|
+
lastFailureMessage: w.lastFailureMessage || null,
|
|
119
129
|
lastSeen: w.lastSeen,
|
|
120
130
|
meta: { domain: w.meta?.domain, chatId: w.meta?.chatId, registeredAt: w.meta?.registeredAt }
|
|
121
131
|
});
|
|
@@ -160,14 +170,17 @@ class TaskQueue {
|
|
|
160
170
|
if (meta?.chromeId && email && !email.startsWith('unknown:')) {
|
|
161
171
|
this._bindChromeIdToEmail(meta.chromeId, email);
|
|
162
172
|
}
|
|
173
|
+
// Phase 1: chromeId comes from worker meta if extension passes it,
|
|
174
|
+
// else reverse-lookup from chromeIdToEmail. Phase 2 will require it.
|
|
175
|
+
existing.chromeId = meta?.chromeId || this._emailToChromeId(email) || existing.chromeId || null;
|
|
163
176
|
if (wasUnknown && email !== existing.email) {
|
|
164
177
|
// shouldn't happen — kept for clarity (email already assigned above)
|
|
165
178
|
}
|
|
166
179
|
// Re-establish pin từ URL hiện tại của tab worker. Khắc phục race khi
|
|
167
|
-
// open-tab handler set
|
|
180
|
+
// open-tab handler set _pinnedModelByLauncher nhưng launcher email lúc đó
|
|
168
181
|
// null (chưa scrape) → pin bị mất → recycle reopen với default.
|
|
169
182
|
if (meta?.pinnedModel && !email.startsWith('unknown:')) {
|
|
170
|
-
this.
|
|
183
|
+
this._pinnedModelByLauncher.set(email, meta.pinnedModel);
|
|
171
184
|
}
|
|
172
185
|
console.log(chalk.green(`[TaskQueue] Worker re-register: ${socket.id} (${email})${meta?.pinnedModel ? ` pinned=${meta.pinnedModel}` : ''}`));
|
|
173
186
|
setImmediate(() => this._drain());
|
|
@@ -178,6 +191,10 @@ class TaskQueue {
|
|
|
178
191
|
socket,
|
|
179
192
|
id: socket.id,
|
|
180
193
|
email,
|
|
194
|
+
// Phase 1: chromeId may be absent until extension bundle propagates it.
|
|
195
|
+
// Reverse-lookup from chromeIdToEmail fills the gap for already-bound
|
|
196
|
+
// profiles. Recycle/dispatch paths prefer chromeId when present.
|
|
197
|
+
chromeId: meta?.chromeId || this._emailToChromeId(email) || null,
|
|
181
198
|
meta: { ...(meta || {}), registeredAt: Date.now() },
|
|
182
199
|
status: 'idle',
|
|
183
200
|
currentTaskId: null,
|
|
@@ -192,7 +209,7 @@ class TaskQueue {
|
|
|
192
209
|
}
|
|
193
210
|
// Re-establish pin từ URL hiện tại (xem comment ở re-register branch).
|
|
194
211
|
if (meta?.pinnedModel && !email.startsWith('unknown:')) {
|
|
195
|
-
this.
|
|
212
|
+
this._pinnedModelByLauncher.set(email, meta.pinnedModel);
|
|
196
213
|
}
|
|
197
214
|
console.log(chalk.green(`[TaskQueue] Worker registered: ${socket.id} (${email})${meta?.pinnedModel ? ` pinned=${meta.pinnedModel}` : ''}`));
|
|
198
215
|
setImmediate(() => this._drain());
|
|
@@ -243,14 +260,44 @@ class TaskQueue {
|
|
|
243
260
|
for (const l of this.launchers.values()) {
|
|
244
261
|
if (l.chromeId === chromeId) l.email = email;
|
|
245
262
|
}
|
|
263
|
+
// Backfill worker.chromeId for already-connected workers in this profile
|
|
264
|
+
// (Phase 1 reverse-lookup: workers register before launcher binds email).
|
|
265
|
+
for (const w of this.workers.values()) {
|
|
266
|
+
if (!w.chromeId && (w.email || '').toLowerCase() === email.toLowerCase()) {
|
|
267
|
+
w.chromeId = chromeId;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Reverse lookup: find chromeId bound to email. Returns the first match —
|
|
273
|
+
// if two launchers login the same email (test vs prod), this is ambiguous
|
|
274
|
+
// and the caller should prefer explicit chromeId routing instead.
|
|
275
|
+
_emailToChromeId(email) {
|
|
276
|
+
if (!email || email.startsWith('unknown:')) return null;
|
|
277
|
+
const lc = email.toLowerCase();
|
|
278
|
+
for (const [cid, em] of this.chromeIdToEmail) {
|
|
279
|
+
if (em && em.toLowerCase() === lc) return cid;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
246
282
|
}
|
|
247
283
|
|
|
248
284
|
/**
|
|
249
|
-
* Pick a connected launcher
|
|
250
|
-
*
|
|
285
|
+
* Pick a connected launcher. Precedence: explicit chromeId from opts/task,
|
|
286
|
+
* then workerLabel (email), then any launcher. Returns null if the explicit
|
|
287
|
+
* pin doesn't match a connected launcher — callers MUST treat this as a
|
|
288
|
+
* hard miss (404), not silently dispatch elsewhere.
|
|
289
|
+
*
|
|
290
|
+
* Accepts either a task ({ chromeId?, workerLabel? }) or raw opts object.
|
|
251
291
|
*/
|
|
252
292
|
_pickLauncher(task) {
|
|
253
293
|
if (!this.launchers.size) return null;
|
|
294
|
+
const chromeId = task?.chromeId ? String(task.chromeId).trim() : null;
|
|
295
|
+
if (chromeId) {
|
|
296
|
+
for (const l of this.launchers.values()) {
|
|
297
|
+
if (l.chromeId === chromeId) return l;
|
|
298
|
+
}
|
|
299
|
+
return null; // explicit chromeId pin — no silent fallback
|
|
300
|
+
}
|
|
254
301
|
const pin = task?.workerLabel ? task.workerLabel.toLowerCase() : null;
|
|
255
302
|
if (pin) {
|
|
256
303
|
for (const l of this.launchers.values()) {
|
|
@@ -273,8 +320,13 @@ class TaskQueue {
|
|
|
273
320
|
|
|
274
321
|
/**
|
|
275
322
|
* Send a request to one or all connected launchers and await their ack.
|
|
276
|
-
* opts: { workerLabel?, all? } —
|
|
277
|
-
*
|
|
323
|
+
* opts: { chromeId?, workerLabel?, all? } — chromeId pins to an exact
|
|
324
|
+
* Chrome profile (precedence over workerLabel), workerLabel pins by email,
|
|
325
|
+
* `all: true` broadcasts and aggregates replies from every launcher.
|
|
326
|
+
*
|
|
327
|
+
* Rejects with `launcher_not_found` (not `no_launcher_connected`) when an
|
|
328
|
+
* explicit pin fails to match — callers can map that to HTTP 404 instead of
|
|
329
|
+
* silently falling back to a different profile.
|
|
278
330
|
*/
|
|
279
331
|
requestLauncher(event, payload = {}, opts = {}, timeoutMs = 5_000) {
|
|
280
332
|
if (opts.all) {
|
|
@@ -298,8 +350,15 @@ class TaskQueue {
|
|
|
298
350
|
})));
|
|
299
351
|
}
|
|
300
352
|
return new Promise((resolve, reject) => {
|
|
301
|
-
const
|
|
302
|
-
|
|
353
|
+
const hasExplicitPin = !!(opts.chromeId || opts.workerLabel);
|
|
354
|
+
const launcher = this._pickLauncher({
|
|
355
|
+
chromeId: opts.chromeId || null,
|
|
356
|
+
workerLabel: opts.workerLabel || null,
|
|
357
|
+
});
|
|
358
|
+
if (!launcher) {
|
|
359
|
+
if (hasExplicitPin) return reject(new Error('launcher_not_found'));
|
|
360
|
+
return reject(new Error('no_launcher_connected'));
|
|
361
|
+
}
|
|
303
362
|
let done = false;
|
|
304
363
|
const timer = setTimeout(() => {
|
|
305
364
|
if (done) return;
|
|
@@ -351,6 +410,7 @@ class TaskQueue {
|
|
|
351
410
|
task.timing.queuedAt = Date.now();
|
|
352
411
|
task.attempts = task.attempts || [];
|
|
353
412
|
task.workerLabel = task.workerLabel || null;
|
|
413
|
+
task.chromeId = task.chromeId || null;
|
|
354
414
|
await store.saveTask(task);
|
|
355
415
|
this.cache.set(task.id, task);
|
|
356
416
|
this.pending.push({ id: task.id, workingDir: task.workingDir });
|
|
@@ -418,13 +478,14 @@ class TaskQueue {
|
|
|
418
478
|
return;
|
|
419
479
|
}
|
|
420
480
|
const workerEmail = worker.email;
|
|
481
|
+
const workerChromeId = worker.chromeId || null;
|
|
421
482
|
worker.currentTaskId = null;
|
|
422
483
|
worker.status = 'idle';
|
|
423
484
|
worker.lastSeen = Date.now();
|
|
424
485
|
|
|
425
486
|
if (task.cancelRequestedAt) {
|
|
426
487
|
// discard result; still recycle the tab so next task gets a clean slate
|
|
427
|
-
this._recycleWorkerTab(workerEmail);
|
|
488
|
+
this._recycleWorkerTab(workerEmail, workerChromeId);
|
|
428
489
|
setImmediate(() => this._drain());
|
|
429
490
|
return;
|
|
430
491
|
}
|
|
@@ -445,7 +506,7 @@ class TaskQueue {
|
|
|
445
506
|
// model URL pinned. AI Studio's "+ New chat" link drops the ?model=
|
|
446
507
|
// query and Angular routerLink ignores href hijack — close+reopen via
|
|
447
508
|
// launcher is the only deterministic way to lock the model per task.
|
|
448
|
-
this._recycleWorkerTab(workerEmail);
|
|
509
|
+
this._recycleWorkerTab(workerEmail, workerChromeId);
|
|
449
510
|
// Drain right away so other idle workers pick up queued tasks while
|
|
450
511
|
// this worker's tab is being recycled (~5s).
|
|
451
512
|
setImmediate(() => this._drain());
|
|
@@ -459,13 +520,14 @@ class TaskQueue {
|
|
|
459
520
|
return;
|
|
460
521
|
}
|
|
461
522
|
const workerEmail = worker.email;
|
|
523
|
+
const workerChromeId = worker.chromeId || null;
|
|
462
524
|
await this._failOrRequeue(task, worker, error);
|
|
463
525
|
// Only recycle if the worker isn't in cooldown (rate-limited workers
|
|
464
526
|
// have their tab still open intentionally — recycling won't unfreeze
|
|
465
527
|
// the quota and just wastes a tab cycle).
|
|
466
528
|
const w = this.workers.get(socketId);
|
|
467
529
|
if (!w || w.status !== 'rate_limited') {
|
|
468
|
-
this._recycleWorkerTab(workerEmail);
|
|
530
|
+
this._recycleWorkerTab(workerEmail, workerChromeId);
|
|
469
531
|
} else {
|
|
470
532
|
setTimeout(() => this._drain(), INTER_TASK_GAP_MS);
|
|
471
533
|
}
|
|
@@ -477,34 +539,44 @@ class TaskQueue {
|
|
|
477
539
|
* clearWorker; the open creates a new tab whose worker registers ~3-5s
|
|
478
540
|
* later. _drain runs naturally on register so any queued task picks up
|
|
479
541
|
* the fresh worker. Fire-and-forget — errors are logged but don't bubble.
|
|
542
|
+
*
|
|
543
|
+
* Prefers chromeId routing when provided (exact profile) — falls back to
|
|
544
|
+
* workerEmail when chromeId is unknown (e.g. extension hasn't propagated
|
|
545
|
+
* chromeId yet in Phase 1).
|
|
480
546
|
*/
|
|
481
|
-
_recycleWorkerTab(workerEmail) {
|
|
547
|
+
_recycleWorkerTab(workerEmail, chromeId = null) {
|
|
482
548
|
if (!workerEmail || workerEmail.startsWith('unknown:')) return;
|
|
483
549
|
// Cancel any pending idle-close — we're about to recycle, no need to close
|
|
484
550
|
// separately. (This handles the race where a task lands during the idle
|
|
485
551
|
// window: drain triggers recycle, idle timer becomes redundant.)
|
|
486
552
|
this._cancelIdleClose(workerEmail);
|
|
487
|
-
// Mark
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
// close, leading to a stuck "running" task that never completes.
|
|
553
|
+
// Mark workers in this profile as 'recycling' so _drain skips them. Match
|
|
554
|
+
// by chromeId when present (exact profile), else by email (covers the
|
|
555
|
+
// chromeId-unknown path).
|
|
491
556
|
for (const w of this.workers.values()) {
|
|
492
|
-
|
|
493
|
-
|
|
557
|
+
const matches = chromeId
|
|
558
|
+
? w.chromeId === chromeId
|
|
559
|
+
: w.email === workerEmail;
|
|
560
|
+
if (matches && w.status === 'idle') w.status = 'recycling';
|
|
561
|
+
}
|
|
562
|
+
// Route launcher calls by chromeId when known, email otherwise.
|
|
563
|
+
const routeOpts = chromeId ? { chromeId } : { workerLabel: workerEmail };
|
|
564
|
+
const pinKey = chromeId || workerEmail;
|
|
494
565
|
// If WORKER_IDLE_TTL_MS === 1, treat post-task recycle as close-only:
|
|
495
566
|
// skip reopen; let _autoLaunchBrowser open a fresh tab when next task lands.
|
|
496
567
|
const closeOnly = WORKER_IDLE_TTL_MS === 1;
|
|
497
568
|
setImmediate(async () => {
|
|
498
569
|
try {
|
|
499
|
-
await this.requestLauncher('launcher:close_tab', {},
|
|
570
|
+
await this.requestLauncher('launcher:close_tab', {}, routeOpts, 5_000);
|
|
500
571
|
if (closeOnly) {
|
|
501
572
|
console.log(chalk.cyan(`[TaskQueue] Closed tab for ${workerEmail} (VG_WORKER_IDLE_TTL_MS=1)`));
|
|
502
573
|
return;
|
|
503
574
|
}
|
|
504
575
|
await new Promise(r => setTimeout(r, 400));
|
|
505
|
-
const pinned = this.
|
|
576
|
+
const pinned = this._pinnedModelByLauncher.get(pinKey)
|
|
577
|
+
|| this._pinnedModelByLauncher.get(workerEmail);
|
|
506
578
|
const openPayload = pinned ? { model: pinned } : {};
|
|
507
|
-
await this.requestLauncher('launcher:open_tab', openPayload,
|
|
579
|
+
await this.requestLauncher('launcher:open_tab', openPayload, routeOpts, 8_000);
|
|
508
580
|
console.log(chalk.cyan(`[TaskQueue] Recycled tab for ${workerEmail}${pinned ? ` (model=${pinned})` : ''}`));
|
|
509
581
|
// Schedule idle close. If new task arrives before TTL, enqueue() will
|
|
510
582
|
// cancel this timer.
|
|
@@ -513,7 +585,8 @@ class TaskQueue {
|
|
|
513
585
|
console.log(chalk.yellow(`[TaskQueue] Recycle ${workerEmail} failed: ${err.message}`));
|
|
514
586
|
// Restore status if recycle failed and worker still in registry.
|
|
515
587
|
for (const w of this.workers.values()) {
|
|
516
|
-
|
|
588
|
+
const matches = chromeId ? w.chromeId === chromeId : w.email === workerEmail;
|
|
589
|
+
if (matches && w.status === 'recycling') w.status = 'idle';
|
|
517
590
|
}
|
|
518
591
|
setImmediate(() => this._drain());
|
|
519
592
|
}
|
|
@@ -535,12 +608,23 @@ class TaskQueue {
|
|
|
535
608
|
this._cancelIdleClose(workerEmail);
|
|
536
609
|
const t = setTimeout(() => {
|
|
537
610
|
this._idleCloseTimers.delete(workerEmail);
|
|
611
|
+
// Collect chromeIds bound to this email so we can spot chromeId-pinned
|
|
612
|
+
// tasks targeting the same profile.
|
|
613
|
+
const myChromeIds = new Set();
|
|
614
|
+
for (const w of this.workers.values()) {
|
|
615
|
+
if (w.email === workerEmail && w.chromeId) myChromeIds.add(w.chromeId);
|
|
616
|
+
}
|
|
617
|
+
for (const [cid, em] of this.chromeIdToEmail) {
|
|
618
|
+
if (em === workerEmail) myChromeIds.add(cid);
|
|
619
|
+
}
|
|
538
620
|
// Skip if a task is already queued for this worker (or workerless).
|
|
539
621
|
const hasPendingForEmail = this.pending.some(p => {
|
|
540
622
|
const task = this.cache.get(p.id);
|
|
541
623
|
if (!task) return false;
|
|
542
|
-
if (
|
|
543
|
-
|
|
624
|
+
if (task.chromeId && myChromeIds.has(task.chromeId)) return true;
|
|
625
|
+
if (!task.workerLabel && !task.chromeId) return true;
|
|
626
|
+
if (task.workerLabel && task.workerLabel.toLowerCase() === workerEmail) return true;
|
|
627
|
+
return false;
|
|
544
628
|
});
|
|
545
629
|
if (hasPendingForEmail) return;
|
|
546
630
|
// Skip if any worker for this email is busy/recycling (in-flight work).
|
|
@@ -567,6 +651,22 @@ class TaskQueue {
|
|
|
567
651
|
// When a task is enqueued, cancel any idle-close timers that could
|
|
568
652
|
// otherwise close a tab we're about to dispatch to.
|
|
569
653
|
if (!task) return;
|
|
654
|
+
if (task.chromeId) {
|
|
655
|
+
// chromeId pin → resolve to email via connected worker (or chromeIdToEmail
|
|
656
|
+
// map) and cancel that email's timer. Idle timers are still keyed by
|
|
657
|
+
// email since the close-tab socket message routes per-launcher anyway.
|
|
658
|
+
let email = this.chromeIdToEmail.get(task.chromeId);
|
|
659
|
+
if (!email) {
|
|
660
|
+
for (const w of this.workers.values()) {
|
|
661
|
+
if (w.chromeId === task.chromeId && w.email && !w.email.startsWith('unknown:')) {
|
|
662
|
+
email = w.email;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (email) this._cancelIdleClose(email);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
570
670
|
if (task.workerLabel) {
|
|
571
671
|
this._cancelIdleClose(task.workerLabel.toLowerCase());
|
|
572
672
|
} else {
|
|
@@ -587,6 +687,7 @@ class TaskQueue {
|
|
|
587
687
|
for (const w of this.workers.values()) {
|
|
588
688
|
if (w.id === excludeSocketId) continue;
|
|
589
689
|
if (w.status === 'rate_limited') continue;
|
|
690
|
+
if (task.chromeId && w.chromeId !== task.chromeId) continue;
|
|
590
691
|
if (task.workerLabel && w.email !== task.workerLabel.toLowerCase()) continue;
|
|
591
692
|
return true;
|
|
592
693
|
}
|
|
@@ -595,6 +696,10 @@ class TaskQueue {
|
|
|
595
696
|
|
|
596
697
|
_anyWorkerCouldMatch(task) {
|
|
597
698
|
if (!this.workers.size) return false;
|
|
699
|
+
if (task.chromeId) {
|
|
700
|
+
for (const w of this.workers.values()) if (w.chromeId === task.chromeId) return true;
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
598
703
|
if (!task.workerLabel) return true;
|
|
599
704
|
const lc = task.workerLabel.toLowerCase();
|
|
600
705
|
for (const w of this.workers.values()) if (w.email === lc) return true;
|
|
@@ -616,10 +721,14 @@ class TaskQueue {
|
|
|
616
721
|
if (RL_CODES.has(code)) {
|
|
617
722
|
worker.status = 'rate_limited';
|
|
618
723
|
worker.cooldownUntil = Date.now() + RATE_LIMIT_COOLDOWN_MS;
|
|
724
|
+
worker.lastFailureCode = code;
|
|
725
|
+
worker.lastFailureMessage = message.slice(0, 300);
|
|
619
726
|
worker.currentTaskId = null;
|
|
620
727
|
console.log(chalk.yellow(`[TaskQueue] Worker ${worker.email} rate-limited until ${new Date(worker.cooldownUntil).toLocaleTimeString()}`));
|
|
621
728
|
} else {
|
|
622
729
|
// Plain failure — worker becomes idle again
|
|
730
|
+
worker.lastFailureCode = code;
|
|
731
|
+
worker.lastFailureMessage = message.slice(0, 300);
|
|
623
732
|
worker.currentTaskId = null;
|
|
624
733
|
worker.status = 'idle';
|
|
625
734
|
}
|
|
@@ -688,10 +797,13 @@ class TaskQueue {
|
|
|
688
797
|
this.pending.splice(i, 1);
|
|
689
798
|
continue;
|
|
690
799
|
}
|
|
691
|
-
// Pick first idle worker that matches the pin
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
800
|
+
// Pick first idle worker that matches the pin. chromeId takes
|
|
801
|
+
// precedence over workerLabel — explicit profile pin is authoritative.
|
|
802
|
+
const wIdx = idleWorkers.findIndex(w => {
|
|
803
|
+
if (task.chromeId) return w.chromeId === task.chromeId;
|
|
804
|
+
if (task.workerLabel) return w.email === task.workerLabel.toLowerCase();
|
|
805
|
+
return true;
|
|
806
|
+
});
|
|
695
807
|
if (wIdx === -1) {
|
|
696
808
|
if (!this._anyWorkerCouldMatch(task)) this._autoLaunchBrowser(task);
|
|
697
809
|
i++;
|
|
@@ -769,7 +881,8 @@ class TaskQueue {
|
|
|
769
881
|
try {
|
|
770
882
|
launcher.socket.emit('launcher:open_aistudio', {
|
|
771
883
|
taskId: task?.id || null,
|
|
772
|
-
workerLabel: task?.workerLabel || null
|
|
884
|
+
workerLabel: task?.workerLabel || null,
|
|
885
|
+
chromeId: task?.chromeId || null
|
|
773
886
|
}, (ack) => {
|
|
774
887
|
if (ack?.ok) {
|
|
775
888
|
console.log(chalk.green(`[TaskQueue] Launcher ${tag} ${ack.action} tab ${ack.tabId}`));
|