vg-coder-cli 2.0.63 → 2.0.65
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 +91 -17
- package/src/server/task-queue.js +135 -34
- package/src/server/views/vg-coder/background.js +74 -1
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,
|
|
@@ -691,43 +712,71 @@ class ApiServer {
|
|
|
691
712
|
res.json({ launchers: taskQueue.listLaunchers() });
|
|
692
713
|
});
|
|
693
714
|
|
|
694
|
-
//
|
|
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?
|
|
695
737
|
this.app.get('/api/launcher/tabs', async (req, res) => {
|
|
696
738
|
try {
|
|
697
|
-
const
|
|
698
|
-
const opts = label ? { workerLabel: label } : { all: true };
|
|
739
|
+
const opts = launcherOpts({}, req.query) || { all: true };
|
|
699
740
|
const result = await taskQueue.requestLauncher('launcher:list_tabs', {}, opts, 5_000);
|
|
700
741
|
res.json(result);
|
|
701
|
-
} catch (e) { res.status(
|
|
742
|
+
} catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
|
|
702
743
|
});
|
|
703
744
|
|
|
704
|
-
// 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.
|
|
705
747
|
this.app.post('/api/launcher/close-tab', async (req, res) => {
|
|
706
748
|
try {
|
|
707
749
|
const body = req.body || {};
|
|
708
|
-
const opts = body
|
|
750
|
+
const opts = launcherOpts(body) || (body.all ? { all: true } : {});
|
|
709
751
|
const payload = body.tabId != null ? { tabId: body.tabId } : {};
|
|
710
752
|
const result = await taskQueue.requestLauncher('launcher:close_tab', payload, opts, 5_000);
|
|
711
753
|
res.json(result);
|
|
712
|
-
} catch (e) { res.status(
|
|
754
|
+
} catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
|
|
713
755
|
});
|
|
714
756
|
|
|
715
|
-
// 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.
|
|
716
762
|
this.app.post('/api/launcher/open-tab', async (req, res) => {
|
|
717
763
|
try {
|
|
718
764
|
const body = req.body || {};
|
|
719
|
-
const opts = body
|
|
765
|
+
const opts = launcherOpts(body) || {};
|
|
720
766
|
const payload = { url: body.url, model: body.model, active: body.active };
|
|
721
|
-
// Pin model
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
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).
|
|
725
771
|
if (body.model) {
|
|
726
|
-
if (
|
|
727
|
-
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);
|
|
728
776
|
} else {
|
|
729
777
|
for (const l of taskQueue.launchers.values()) {
|
|
730
|
-
if (l.
|
|
778
|
+
if (l.chromeId) taskQueue._pinnedModelByLauncher.set(l.chromeId, body.model);
|
|
779
|
+
if (l.email) taskQueue._pinnedModelByLauncher.set(l.email, body.model);
|
|
731
780
|
}
|
|
732
781
|
}
|
|
733
782
|
}
|
|
@@ -736,7 +785,32 @@ class ApiServer {
|
|
|
736
785
|
// phải lớn hơn để có response thay vì error.
|
|
737
786
|
const result = await taskQueue.requestLauncher('launcher:open_tab', payload, opts, 15_000);
|
|
738
787
|
res.json(result);
|
|
739
|
-
} catch (e) { res.status(
|
|
788
|
+
} catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// Dump launcher SW state (chromeId source, tabs, storage, runtime info).
|
|
792
|
+
// Query: chromeId? | workerLabel? | (none → broadcast all launchers).
|
|
793
|
+
// Useful for diagnosing chromeId/profile mismatches.
|
|
794
|
+
this.app.get('/api/launcher/debug', async (req, res) => {
|
|
795
|
+
try {
|
|
796
|
+
const opts = launcherOpts({}, req.query) || { all: true };
|
|
797
|
+
const result = await taskQueue.requestLauncher('launcher:debug', {}, opts, 5_000);
|
|
798
|
+
res.json(result);
|
|
799
|
+
} catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// Run JS inside the launcher SW. Body: { chromeId? | workerLabel?, code, timeoutMs? }
|
|
803
|
+
// Code is wrapped in `async () => { ${code} }` — use `return` to surface
|
|
804
|
+
// a value. Result must JSON-serialize. Debug-only.
|
|
805
|
+
this.app.post('/api/launcher/eval', async (req, res) => {
|
|
806
|
+
try {
|
|
807
|
+
const body = req.body || {};
|
|
808
|
+
if (!body.code) return res.status(400).json({ error: 'code required' });
|
|
809
|
+
const opts = launcherOpts(body) || (body.all ? { all: true } : {});
|
|
810
|
+
const timeoutMs = Math.min(Math.max(parseInt(body.timeoutMs, 10) || 10_000, 1_000), 30_000);
|
|
811
|
+
const result = await taskQueue.requestLauncher('launcher:eval', { code: body.code }, opts, timeoutMs);
|
|
812
|
+
res.json(result);
|
|
813
|
+
} catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
|
|
740
814
|
});
|
|
741
815
|
|
|
742
816
|
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) {
|
|
@@ -116,6 +117,7 @@ class TaskQueue {
|
|
|
116
117
|
out.push({
|
|
117
118
|
id: w.id,
|
|
118
119
|
email: w.email,
|
|
120
|
+
chromeId: w.chromeId || null,
|
|
119
121
|
status: w.status,
|
|
120
122
|
currentTaskId: w.currentTaskId,
|
|
121
123
|
cooldownUntil: cd,
|
|
@@ -168,14 +170,17 @@ class TaskQueue {
|
|
|
168
170
|
if (meta?.chromeId && email && !email.startsWith('unknown:')) {
|
|
169
171
|
this._bindChromeIdToEmail(meta.chromeId, email);
|
|
170
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;
|
|
171
176
|
if (wasUnknown && email !== existing.email) {
|
|
172
177
|
// shouldn't happen — kept for clarity (email already assigned above)
|
|
173
178
|
}
|
|
174
179
|
// Re-establish pin từ URL hiện tại của tab worker. Khắc phục race khi
|
|
175
|
-
// open-tab handler set
|
|
180
|
+
// open-tab handler set _pinnedModelByLauncher nhưng launcher email lúc đó
|
|
176
181
|
// null (chưa scrape) → pin bị mất → recycle reopen với default.
|
|
177
182
|
if (meta?.pinnedModel && !email.startsWith('unknown:')) {
|
|
178
|
-
this.
|
|
183
|
+
this._pinnedModelByLauncher.set(email, meta.pinnedModel);
|
|
179
184
|
}
|
|
180
185
|
console.log(chalk.green(`[TaskQueue] Worker re-register: ${socket.id} (${email})${meta?.pinnedModel ? ` pinned=${meta.pinnedModel}` : ''}`));
|
|
181
186
|
setImmediate(() => this._drain());
|
|
@@ -186,6 +191,10 @@ class TaskQueue {
|
|
|
186
191
|
socket,
|
|
187
192
|
id: socket.id,
|
|
188
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,
|
|
189
198
|
meta: { ...(meta || {}), registeredAt: Date.now() },
|
|
190
199
|
status: 'idle',
|
|
191
200
|
currentTaskId: null,
|
|
@@ -200,7 +209,7 @@ class TaskQueue {
|
|
|
200
209
|
}
|
|
201
210
|
// Re-establish pin từ URL hiện tại (xem comment ở re-register branch).
|
|
202
211
|
if (meta?.pinnedModel && !email.startsWith('unknown:')) {
|
|
203
|
-
this.
|
|
212
|
+
this._pinnedModelByLauncher.set(email, meta.pinnedModel);
|
|
204
213
|
}
|
|
205
214
|
console.log(chalk.green(`[TaskQueue] Worker registered: ${socket.id} (${email})${meta?.pinnedModel ? ` pinned=${meta.pinnedModel}` : ''}`));
|
|
206
215
|
setImmediate(() => this._drain());
|
|
@@ -251,14 +260,44 @@ class TaskQueue {
|
|
|
251
260
|
for (const l of this.launchers.values()) {
|
|
252
261
|
if (l.chromeId === chromeId) l.email = email;
|
|
253
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;
|
|
254
282
|
}
|
|
255
283
|
|
|
256
284
|
/**
|
|
257
|
-
* Pick a connected launcher
|
|
258
|
-
*
|
|
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.
|
|
259
291
|
*/
|
|
260
292
|
_pickLauncher(task) {
|
|
261
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
|
+
}
|
|
262
301
|
const pin = task?.workerLabel ? task.workerLabel.toLowerCase() : null;
|
|
263
302
|
if (pin) {
|
|
264
303
|
for (const l of this.launchers.values()) {
|
|
@@ -281,8 +320,13 @@ class TaskQueue {
|
|
|
281
320
|
|
|
282
321
|
/**
|
|
283
322
|
* Send a request to one or all connected launchers and await their ack.
|
|
284
|
-
* opts: { workerLabel?, all? } —
|
|
285
|
-
*
|
|
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.
|
|
286
330
|
*/
|
|
287
331
|
requestLauncher(event, payload = {}, opts = {}, timeoutMs = 5_000) {
|
|
288
332
|
if (opts.all) {
|
|
@@ -306,8 +350,15 @@ class TaskQueue {
|
|
|
306
350
|
})));
|
|
307
351
|
}
|
|
308
352
|
return new Promise((resolve, reject) => {
|
|
309
|
-
const
|
|
310
|
-
|
|
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
|
+
}
|
|
311
362
|
let done = false;
|
|
312
363
|
const timer = setTimeout(() => {
|
|
313
364
|
if (done) return;
|
|
@@ -359,6 +410,7 @@ class TaskQueue {
|
|
|
359
410
|
task.timing.queuedAt = Date.now();
|
|
360
411
|
task.attempts = task.attempts || [];
|
|
361
412
|
task.workerLabel = task.workerLabel || null;
|
|
413
|
+
task.chromeId = task.chromeId || null;
|
|
362
414
|
await store.saveTask(task);
|
|
363
415
|
this.cache.set(task.id, task);
|
|
364
416
|
this.pending.push({ id: task.id, workingDir: task.workingDir });
|
|
@@ -426,13 +478,14 @@ class TaskQueue {
|
|
|
426
478
|
return;
|
|
427
479
|
}
|
|
428
480
|
const workerEmail = worker.email;
|
|
481
|
+
const workerChromeId = worker.chromeId || null;
|
|
429
482
|
worker.currentTaskId = null;
|
|
430
483
|
worker.status = 'idle';
|
|
431
484
|
worker.lastSeen = Date.now();
|
|
432
485
|
|
|
433
486
|
if (task.cancelRequestedAt) {
|
|
434
487
|
// discard result; still recycle the tab so next task gets a clean slate
|
|
435
|
-
this._recycleWorkerTab(workerEmail);
|
|
488
|
+
this._recycleWorkerTab(workerEmail, workerChromeId);
|
|
436
489
|
setImmediate(() => this._drain());
|
|
437
490
|
return;
|
|
438
491
|
}
|
|
@@ -453,7 +506,7 @@ class TaskQueue {
|
|
|
453
506
|
// model URL pinned. AI Studio's "+ New chat" link drops the ?model=
|
|
454
507
|
// query and Angular routerLink ignores href hijack — close+reopen via
|
|
455
508
|
// launcher is the only deterministic way to lock the model per task.
|
|
456
|
-
this._recycleWorkerTab(workerEmail);
|
|
509
|
+
this._recycleWorkerTab(workerEmail, workerChromeId);
|
|
457
510
|
// Drain right away so other idle workers pick up queued tasks while
|
|
458
511
|
// this worker's tab is being recycled (~5s).
|
|
459
512
|
setImmediate(() => this._drain());
|
|
@@ -467,13 +520,14 @@ class TaskQueue {
|
|
|
467
520
|
return;
|
|
468
521
|
}
|
|
469
522
|
const workerEmail = worker.email;
|
|
523
|
+
const workerChromeId = worker.chromeId || null;
|
|
470
524
|
await this._failOrRequeue(task, worker, error);
|
|
471
525
|
// Only recycle if the worker isn't in cooldown (rate-limited workers
|
|
472
526
|
// have their tab still open intentionally — recycling won't unfreeze
|
|
473
527
|
// the quota and just wastes a tab cycle).
|
|
474
528
|
const w = this.workers.get(socketId);
|
|
475
529
|
if (!w || w.status !== 'rate_limited') {
|
|
476
|
-
this._recycleWorkerTab(workerEmail);
|
|
530
|
+
this._recycleWorkerTab(workerEmail, workerChromeId);
|
|
477
531
|
} else {
|
|
478
532
|
setTimeout(() => this._drain(), INTER_TASK_GAP_MS);
|
|
479
533
|
}
|
|
@@ -485,34 +539,44 @@ class TaskQueue {
|
|
|
485
539
|
* clearWorker; the open creates a new tab whose worker registers ~3-5s
|
|
486
540
|
* later. _drain runs naturally on register so any queued task picks up
|
|
487
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).
|
|
488
546
|
*/
|
|
489
|
-
_recycleWorkerTab(workerEmail) {
|
|
547
|
+
_recycleWorkerTab(workerEmail, chromeId = null) {
|
|
490
548
|
if (!workerEmail || workerEmail.startsWith('unknown:')) return;
|
|
491
549
|
// Cancel any pending idle-close — we're about to recycle, no need to close
|
|
492
550
|
// separately. (This handles the race where a task lands during the idle
|
|
493
551
|
// window: drain triggers recycle, idle timer becomes redundant.)
|
|
494
552
|
this._cancelIdleClose(workerEmail);
|
|
495
|
-
// Mark
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
// 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).
|
|
499
556
|
for (const w of this.workers.values()) {
|
|
500
|
-
|
|
501
|
-
|
|
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;
|
|
502
565
|
// If WORKER_IDLE_TTL_MS === 1, treat post-task recycle as close-only:
|
|
503
566
|
// skip reopen; let _autoLaunchBrowser open a fresh tab when next task lands.
|
|
504
567
|
const closeOnly = WORKER_IDLE_TTL_MS === 1;
|
|
505
568
|
setImmediate(async () => {
|
|
506
569
|
try {
|
|
507
|
-
await this.requestLauncher('launcher:close_tab', {},
|
|
570
|
+
await this.requestLauncher('launcher:close_tab', {}, routeOpts, 5_000);
|
|
508
571
|
if (closeOnly) {
|
|
509
572
|
console.log(chalk.cyan(`[TaskQueue] Closed tab for ${workerEmail} (VG_WORKER_IDLE_TTL_MS=1)`));
|
|
510
573
|
return;
|
|
511
574
|
}
|
|
512
575
|
await new Promise(r => setTimeout(r, 400));
|
|
513
|
-
const pinned = this.
|
|
576
|
+
const pinned = this._pinnedModelByLauncher.get(pinKey)
|
|
577
|
+
|| this._pinnedModelByLauncher.get(workerEmail);
|
|
514
578
|
const openPayload = pinned ? { model: pinned } : {};
|
|
515
|
-
await this.requestLauncher('launcher:open_tab', openPayload,
|
|
579
|
+
await this.requestLauncher('launcher:open_tab', openPayload, routeOpts, 8_000);
|
|
516
580
|
console.log(chalk.cyan(`[TaskQueue] Recycled tab for ${workerEmail}${pinned ? ` (model=${pinned})` : ''}`));
|
|
517
581
|
// Schedule idle close. If new task arrives before TTL, enqueue() will
|
|
518
582
|
// cancel this timer.
|
|
@@ -521,7 +585,8 @@ class TaskQueue {
|
|
|
521
585
|
console.log(chalk.yellow(`[TaskQueue] Recycle ${workerEmail} failed: ${err.message}`));
|
|
522
586
|
// Restore status if recycle failed and worker still in registry.
|
|
523
587
|
for (const w of this.workers.values()) {
|
|
524
|
-
|
|
588
|
+
const matches = chromeId ? w.chromeId === chromeId : w.email === workerEmail;
|
|
589
|
+
if (matches && w.status === 'recycling') w.status = 'idle';
|
|
525
590
|
}
|
|
526
591
|
setImmediate(() => this._drain());
|
|
527
592
|
}
|
|
@@ -543,12 +608,23 @@ class TaskQueue {
|
|
|
543
608
|
this._cancelIdleClose(workerEmail);
|
|
544
609
|
const t = setTimeout(() => {
|
|
545
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
|
+
}
|
|
546
620
|
// Skip if a task is already queued for this worker (or workerless).
|
|
547
621
|
const hasPendingForEmail = this.pending.some(p => {
|
|
548
622
|
const task = this.cache.get(p.id);
|
|
549
623
|
if (!task) return false;
|
|
550
|
-
if (
|
|
551
|
-
|
|
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;
|
|
552
628
|
});
|
|
553
629
|
if (hasPendingForEmail) return;
|
|
554
630
|
// Skip if any worker for this email is busy/recycling (in-flight work).
|
|
@@ -575,6 +651,22 @@ class TaskQueue {
|
|
|
575
651
|
// When a task is enqueued, cancel any idle-close timers that could
|
|
576
652
|
// otherwise close a tab we're about to dispatch to.
|
|
577
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
|
+
}
|
|
578
670
|
if (task.workerLabel) {
|
|
579
671
|
this._cancelIdleClose(task.workerLabel.toLowerCase());
|
|
580
672
|
} else {
|
|
@@ -595,6 +687,7 @@ class TaskQueue {
|
|
|
595
687
|
for (const w of this.workers.values()) {
|
|
596
688
|
if (w.id === excludeSocketId) continue;
|
|
597
689
|
if (w.status === 'rate_limited') continue;
|
|
690
|
+
if (task.chromeId && w.chromeId !== task.chromeId) continue;
|
|
598
691
|
if (task.workerLabel && w.email !== task.workerLabel.toLowerCase()) continue;
|
|
599
692
|
return true;
|
|
600
693
|
}
|
|
@@ -603,6 +696,10 @@ class TaskQueue {
|
|
|
603
696
|
|
|
604
697
|
_anyWorkerCouldMatch(task) {
|
|
605
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
|
+
}
|
|
606
703
|
if (!task.workerLabel) return true;
|
|
607
704
|
const lc = task.workerLabel.toLowerCase();
|
|
608
705
|
for (const w of this.workers.values()) if (w.email === lc) return true;
|
|
@@ -700,10 +797,13 @@ class TaskQueue {
|
|
|
700
797
|
this.pending.splice(i, 1);
|
|
701
798
|
continue;
|
|
702
799
|
}
|
|
703
|
-
// Pick first idle worker that matches the pin
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
+
});
|
|
707
807
|
if (wIdx === -1) {
|
|
708
808
|
if (!this._anyWorkerCouldMatch(task)) this._autoLaunchBrowser(task);
|
|
709
809
|
i++;
|
|
@@ -781,7 +881,8 @@ class TaskQueue {
|
|
|
781
881
|
try {
|
|
782
882
|
launcher.socket.emit('launcher:open_aistudio', {
|
|
783
883
|
taskId: task?.id || null,
|
|
784
|
-
workerLabel: task?.workerLabel || null
|
|
884
|
+
workerLabel: task?.workerLabel || null,
|
|
885
|
+
chromeId: task?.chromeId || null
|
|
785
886
|
}, (ack) => {
|
|
786
887
|
if (ack?.ok) {
|
|
787
888
|
console.log(chalk.green(`[TaskQueue] Launcher ${tag} ${ack.action} tab ${ack.tabId}`));
|