vg-coder-cli 2.0.67 → 2.0.68
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/package.json +1 -1
- package/src/server/api-server.js +31 -1
- package/src/server/task-queue.js +87 -0
package/package.json
CHANGED
package/src/server/api-server.js
CHANGED
|
@@ -99,7 +99,12 @@ class ApiServer {
|
|
|
99
99
|
|
|
100
100
|
// Task worker registration & lifecycle
|
|
101
101
|
socket.on('worker:register', (meta) => { taskQueue.setWorker(socket, meta || {}); });
|
|
102
|
-
socket.on('worker:heartbeat', () => {
|
|
102
|
+
socket.on('worker:heartbeat', () => {
|
|
103
|
+
// Bump lastSeen so the stale-worker reaper doesn't drop a live
|
|
104
|
+
// socket that's just been quiet (no task in flight).
|
|
105
|
+
const w = taskQueue.workers.get(socket.id);
|
|
106
|
+
if (w) w.lastSeen = Date.now();
|
|
107
|
+
});
|
|
103
108
|
socket.on('task:complete', (payload) => {
|
|
104
109
|
if (payload?.taskId) taskQueue.onWorkerComplete(payload.taskId, payload, socket.id);
|
|
105
110
|
});
|
|
@@ -799,6 +804,31 @@ class ApiServer {
|
|
|
799
804
|
} catch (e) { res.status(launcherErr(e)).json({ error: e.message }); }
|
|
800
805
|
});
|
|
801
806
|
|
|
807
|
+
// Block until the pinned launcher is ready for tasks. Replaces the common
|
|
808
|
+
// open-tab → poll-launchers → poll-workers pattern with a single sync
|
|
809
|
+
// call. Body: { chromeId, timeoutMs?: 15000, requireEmail?: true,
|
|
810
|
+
// requireWorker?: true }
|
|
811
|
+
// - 200 { ok: true, launcher, worker, elapsedMs } when ready
|
|
812
|
+
// - 404 launcher_not_found if chromeId never matched a launcher
|
|
813
|
+
// - 504 wait_ready_timeout if the readiness condition didn't hold in time
|
|
814
|
+
this.app.post('/api/launcher/wait-ready', async (req, res) => {
|
|
815
|
+
try {
|
|
816
|
+
const body = req.body || {};
|
|
817
|
+
const chromeId = (body.chromeId || '').toString().trim();
|
|
818
|
+
if (!chromeId) return res.status(400).json({ error: 'chromeId required' });
|
|
819
|
+
const result = await taskQueue.waitReady(chromeId, {
|
|
820
|
+
timeoutMs: body.timeoutMs,
|
|
821
|
+
requireEmail: body.requireEmail,
|
|
822
|
+
requireWorker: body.requireWorker,
|
|
823
|
+
});
|
|
824
|
+
res.json(result);
|
|
825
|
+
} catch (e) {
|
|
826
|
+
if (e.code === 'launcher_not_found') return res.status(404).json({ error: 'launcher_not_found' });
|
|
827
|
+
if (e.code === 'wait_ready_timeout') return res.status(504).json({ error: 'wait_ready_timeout', details: e.details });
|
|
828
|
+
res.status(500).json({ error: e.message });
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
802
832
|
// Run a predefined chrome.* command inside the launcher SW. Manifest V3
|
|
803
833
|
// CSP forbids new Function() in service workers, so eval was replaced by
|
|
804
834
|
// a fixed command map. Body:
|
package/src/server/task-queue.js
CHANGED
|
@@ -9,6 +9,13 @@ const AUTO_LAUNCH_DEBOUNCE_MS = 30_000;
|
|
|
9
9
|
const INTER_TASK_GAP_MS = 2_500;
|
|
10
10
|
const RATE_LIMIT_COOLDOWN_MS = 30 * 60_000;
|
|
11
11
|
const COOLDOWN_TICK_MS = 60_000;
|
|
12
|
+
// Reap workers whose socket went silent: no heartbeat AND no task activity
|
|
13
|
+
// past this window. Heartbeat fires every 5s from task-worker.js, so 120s
|
|
14
|
+
// is ~24 missed beats — comfortably past any legitimate network hiccup but
|
|
15
|
+
// short enough that zombie sockets (Chrome tab closed without disconnect
|
|
16
|
+
// event reaching server, content-script paused by power-saver, etc.) don't
|
|
17
|
+
// linger forever and bloat _drain's idleWorkers snapshot.
|
|
18
|
+
const STALE_WORKER_TTL_MS = 120_000;
|
|
12
19
|
const MAX_TASK_ATTEMPTS = 3;
|
|
13
20
|
const RL_CODES = new Set(['rate_limit_exceeded', 'quota_exceeded', 'generation_failed']);
|
|
14
21
|
|
|
@@ -269,6 +276,62 @@ class TaskQueue {
|
|
|
269
276
|
}
|
|
270
277
|
}
|
|
271
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Poll until a launcher's profile is "ready" for tasks: launcher connected,
|
|
281
|
+
* email scraped (if requireEmail), and ≥1 idle worker registered for the
|
|
282
|
+
* chromeId (if requireWorker). Saves callers from running their own
|
|
283
|
+
* GET /api/launchers + /api/workers poll loop after open-tab.
|
|
284
|
+
*
|
|
285
|
+
* Returns a snapshot on success; throws on timeout or immediate-miss.
|
|
286
|
+
*/
|
|
287
|
+
async waitReady(chromeId, opts = {}) {
|
|
288
|
+
const timeoutMs = Math.min(Math.max(opts.timeoutMs | 0 || 15_000, 1_000), 60_000);
|
|
289
|
+
const requireEmail = opts.requireEmail !== false;
|
|
290
|
+
const requireWorker = opts.requireWorker !== false;
|
|
291
|
+
const pollMs = 250;
|
|
292
|
+
const deadline = Date.now() + timeoutMs;
|
|
293
|
+
|
|
294
|
+
// Immediate-miss: chromeId never matched a launcher → 404 contract
|
|
295
|
+
// (vs. timeout). Caller can map differently.
|
|
296
|
+
const hasLauncher = () => [...this.launchers.values()].some(l => l.chromeId === chromeId);
|
|
297
|
+
if (!hasLauncher()) {
|
|
298
|
+
const err = new Error('launcher_not_found');
|
|
299
|
+
err.code = 'launcher_not_found';
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
while (Date.now() < deadline) {
|
|
304
|
+
const launcher = [...this.launchers.values()].find(l => l.chromeId === chromeId);
|
|
305
|
+
if (launcher) {
|
|
306
|
+
const emailOk = !requireEmail || (launcher.email && !launcher.email.startsWith('unknown:'));
|
|
307
|
+
const worker = !requireWorker ? null : [...this.workers.values()].find(w =>
|
|
308
|
+
w.chromeId === chromeId
|
|
309
|
+
&& w.status === 'idle'
|
|
310
|
+
&& w.email && !w.email.startsWith('unknown:')
|
|
311
|
+
);
|
|
312
|
+
const workerOk = !requireWorker || !!worker;
|
|
313
|
+
if (emailOk && workerOk) {
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
launcher: { id: launcher.id, chromeId: launcher.chromeId, email: launcher.email },
|
|
317
|
+
worker: worker ? { id: worker.id, email: worker.email, status: worker.status } : null,
|
|
318
|
+
elapsedMs: timeoutMs - (deadline - Date.now()),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
// Launcher disconnected during the wait — surface as not_found, not timeout.
|
|
323
|
+
const err = new Error('launcher_not_found');
|
|
324
|
+
err.code = 'launcher_not_found';
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
328
|
+
}
|
|
329
|
+
const err = new Error('wait_ready_timeout');
|
|
330
|
+
err.code = 'wait_ready_timeout';
|
|
331
|
+
err.details = { chromeId, requireEmail, requireWorker, timeoutMs };
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
|
|
272
335
|
// Reverse lookup: find chromeId bound to email. Returns the first match —
|
|
273
336
|
// if two launchers login the same email (test vs prod), this is ambiguous
|
|
274
337
|
// and the caller should prefer explicit chromeId routing instead.
|
|
@@ -873,9 +936,33 @@ class TaskQueue {
|
|
|
873
936
|
console.log(chalk.green(`[TaskQueue] Worker ${w.email} cooldown ended`));
|
|
874
937
|
}
|
|
875
938
|
}
|
|
939
|
+
this._reapStaleWorkers(now);
|
|
876
940
|
if (revived) setImmediate(() => this._drain());
|
|
877
941
|
}
|
|
878
942
|
|
|
943
|
+
// Drop workers whose socket has been silent past STALE_WORKER_TTL_MS. Skips
|
|
944
|
+
// busy/recycling workers — those are mid-task or mid-recycle and may
|
|
945
|
+
// legitimately not heartbeat until they emit task:complete. Reaping a
|
|
946
|
+
// zombie worker also triggers _drain because the removal frees a slot
|
|
947
|
+
// that the legitimate launcher can refill when it reconnects.
|
|
948
|
+
_reapStaleWorkers(now = Date.now()) {
|
|
949
|
+
let reaped = 0;
|
|
950
|
+
for (const w of [...this.workers.values()]) {
|
|
951
|
+
if (w.status === 'busy' || w.status === 'recycling') continue;
|
|
952
|
+
const idleFor = now - (w.lastSeen || 0);
|
|
953
|
+
if (idleFor <= STALE_WORKER_TTL_MS) continue;
|
|
954
|
+
console.log(chalk.yellow(
|
|
955
|
+
`[TaskQueue] Reaping stale worker ${w.id} (${w.email || 'unknown'}, idle ${Math.round(idleFor / 1000)}s)`
|
|
956
|
+
));
|
|
957
|
+
// Drop from registry. Don't try to disconnect the socket — if it's
|
|
958
|
+
// genuinely alive, _drain will skip dispatching to it, and the next
|
|
959
|
+
// worker:heartbeat or worker:register from that socket re-adds it.
|
|
960
|
+
this.workers.delete(w.id);
|
|
961
|
+
reaped++;
|
|
962
|
+
}
|
|
963
|
+
if (reaped) setImmediate(() => this._drain());
|
|
964
|
+
}
|
|
965
|
+
|
|
879
966
|
_autoLaunchBrowser(task = null) {
|
|
880
967
|
const now = Date.now();
|
|
881
968
|
if (now - this.launchedAt < AUTO_LAUNCH_DEBOUNCE_MS) return;
|