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.
- package/INTEGRATION.md +418 -0
- package/debug.log +42 -0
- package/dist/vg-coder-bundle.js +50 -44
- package/package.json +2 -1
- package/src/server/api-server.js +355 -2
- package/src/server/task-queue.js +705 -0
- package/src/server/task-store.js +112 -0
- package/src/server/task-webhook.js +48 -0
- package/src/server/views/css/agent-panel.css +259 -3
- package/src/server/views/css/code-viewer.css +158 -3
- package/src/server/views/js/features/agent-panel.js +248 -12
- package/src/server/views/js/features/code-viewer.js +18 -3
- package/src/server/views/js/features/git-view.js +1 -1
- package/src/server/views/js/features/mermaid-viewer.js +494 -0
- package/src/server/views/js/features/resize.js +1 -1
- package/src/server/views/js/features/task-worker.js +448 -0
- package/src/server/views/js/main.js +4 -0
- package/src/server/views/vg-coder/background.js +17860 -11946
- package/src/server/views/vg-coder/controller.js +42 -10
- package/src/server/views/vg-coder/manifest.json +2 -1
- package/src/server/views/vg-coder/sidepanel.js +13 -7
- package/test-large/package.json +1 -0
- package/test-large/src/components/Button.tsx +1 -0
- package/test-large/src/index.ts +1 -0
- package/test-small/package.json +1 -0
- package/test-small/src/index.js +1 -0
|
@@ -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();
|