channel-worker 2.5.39 → 2.5.41

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.
@@ -1443,6 +1443,33 @@ class CommandPoller {
1443
1443
  const domQ = typeof queueInfo === 'number' ? queueCount : (queueInfo?.dom_count || 0);
1444
1444
  if (!queueCount) { this._dispatching = false; return; }
1445
1445
 
1446
+ // 4b. WHO can actually claim that queue. An idea pinned to one renderer
1447
+ // produces commands only that profile may claim — a raw total told us
1448
+ // "there's work, open another profile", the freshly-opened profile
1449
+ // claimed nothing, the round-robin below closed it as idle, and the
1450
+ // next cycle reopened it: a browser blinking open/closed every 5s
1451
+ // (observed on i5-pc: idea pinned to veo3-pro1, veo3-pro4 flapping).
1452
+ // unpinned_count === null means an older API that doesn't report the
1453
+ // breakdown → fall back to the previous "everything is claimable".
1454
+ const unpinnedCount = (queueInfo && typeof queueInfo.unpinned_count === 'number')
1455
+ ? queueInfo.unpinned_count
1456
+ : null;
1457
+ const pinnedMap = (queueInfo && queueInfo.pinned && typeof queueInfo.pinned === 'object')
1458
+ ? queueInfo.pinned
1459
+ : {};
1460
+ const pinnedFor = (r) => {
1461
+ const id = String(r?.nst_profile_id || '');
1462
+ if (!id) return 0;
1463
+ if (typeof pinnedMap[id] === 'number') return pinnedMap[id];
1464
+ const lower = id.toLowerCase();
1465
+ for (const k of Object.keys(pinnedMap)) {
1466
+ if (k.toLowerCase() === lower) return pinnedMap[k];
1467
+ }
1468
+ return 0;
1469
+ };
1470
+ // Can this renderer claim anything at all right now?
1471
+ const canClaim = (r) => (unpinnedCount === null) || unpinnedCount > 0 || pinnedFor(r) > 0;
1472
+
1446
1473
  // 5. Parallel limit — sourced from THIS daemon's Worker.parallel_limit
1447
1474
  // via /workers/me. Replaces the legacy global Settings
1448
1475
  // (flowkit_max_concurrent / veo3_parallel_limit) which throttled
@@ -1456,12 +1483,16 @@ class CommandPoller {
1456
1483
  let parallelLimit = 2; // sane default if /workers/me fails or is
1457
1484
  // unavailable on an older API; effective limit
1458
1485
  // is still bounded by server-side enforcement.
1486
+ let independentAccounts = false; // when true: renderers are SEPARATE Flow
1487
+ // accounts → run all concurrently, skip the
1488
+ // shared-account round-robin close-idle rotation.
1459
1489
  try {
1460
1490
  if (this.api.getMyWorker) {
1461
1491
  const me = await this.api.getMyWorker();
1462
1492
  if (me && typeof me.parallel_limit === 'number') {
1463
1493
  parallelLimit = Math.max(0, me.parallel_limit);
1464
1494
  }
1495
+ if (me && me.independent_accounts) independentAccounts = true;
1465
1496
  }
1466
1497
  } catch (e) {
1467
1498
  if (this.config.verbose) console.warn('[scene-dispatch] getMyWorker failed, using default:', e.message);
@@ -1472,7 +1503,19 @@ class CommandPoller {
1472
1503
  && (!r.pause_until || new Date(r.pause_until).getTime() > Date.now());
1473
1504
  let stillOffline = renderers.filter(r => !runningRenderers.includes(r) && !isPaused(r));
1474
1505
  const pausedCount = renderers.filter(isPaused).length;
1475
- console.log(`[scene-dispatch] running=${runningRenderers.length} cap=${parallelLimit} (flowkit=${flowkitQ} dom=${domQ}) offline=${stillOffline.length} paused=${pausedCount} queue=${queueCount} names=[${runningRenderers.map(r=>r.name)}]`);
1506
+ const pinSummary = unpinnedCount === null
1507
+ ? 'pins=n/a'
1508
+ : `unpinned=${unpinnedCount} pins=${JSON.stringify(pinnedMap)}`;
1509
+ console.log(`[scene-dispatch] running=${runningRenderers.length} cap=${parallelLimit} (flowkit=${flowkitQ} dom=${domQ}) offline=${stillOffline.length} paused=${pausedCount} queue=${queueCount} ${pinSummary} names=[${runningRenderers.map(r=>r.name)}]`);
1510
+
1511
+ // Offline renderers that could actually claim something. Everything else
1512
+ // must stay closed — opening it would only produce the flap described
1513
+ // above (open → claim nothing → closed as idle → reopen).
1514
+ let launchable = stillOffline.filter(canClaim);
1515
+ if (stillOffline.length > launchable.length) {
1516
+ const blocked = stillOffline.filter(r => !canClaim(r)).map(r => r.name || r.nst_profile_id);
1517
+ console.log(`[scene-dispatch] not launching [${blocked.join(',')}] — the whole queue is pinned to another renderer`);
1518
+ }
1476
1519
 
1477
1520
  // ROUND-ROBIN ROTATION — when a running renderer just finished its
1478
1521
  // scene (no in-flight cmd) AND there's at least one OFFLINE sibling
@@ -1483,7 +1526,13 @@ class CommandPoller {
1483
1526
  // browser idle on Flow page keeps refreshing tokens / pinging
1484
1527
  // telemetry → Veo flags "2 concurrent sessions per account" → captcha.
1485
1528
  // Closing it = 0 idle sessions, only 1 active at a time.
1486
- if (queueCount > 0 && stillOffline.length > 0 && runningRenderers.length > 0) {
1529
+ // Round-robin close-idle is a SHARED-ACCOUNT safety (keep ≤1 Flow session
1530
+ // live per Google account). With independent_accounts every renderer is
1531
+ // its own account → skip it entirely and let them all run concurrently.
1532
+ // Rotate only when there is a sibling that can actually take over the
1533
+ // remaining work — rotating toward a renderer the queue is not pinned to
1534
+ // just closes a useful profile and opens a useless one.
1535
+ if (!independentAccounts && queueCount > 0 && launchable.length > 0 && runningRenderers.length > 0) {
1487
1536
  const stoppedNames = [];
1488
1537
  for (const r of [...runningRenderers]) {
1489
1538
  // Skip externally-launched profiles (user opened manually via NST UI)
@@ -1516,7 +1565,9 @@ class CommandPoller {
1516
1565
  // the launcher picks the round-robin partner, not the one that just
1517
1566
  // finished. API populates last_command_assigned_at on every claim,
1518
1567
  // so this naturally implements turn-taking across siblings.
1519
- stillOffline = stillOffline.slice().sort((a, b) => {
1568
+ // Re-filter first: the round-robin above may have pushed a just-closed
1569
+ // renderer back into stillOffline.
1570
+ launchable = stillOffline.filter(canClaim).slice().sort((a, b) => {
1520
1571
  const ta = a.last_command_assigned_at ? new Date(a.last_command_assigned_at).getTime() : 0;
1521
1572
  const tb = b.last_command_assigned_at ? new Date(b.last_command_assigned_at).getTime() : 0;
1522
1573
  return ta - tb;
@@ -1536,11 +1587,11 @@ class CommandPoller {
1536
1587
  const needNew = Math.max(0, queueCount);
1537
1588
  const neededLaunches = Math.min(
1538
1589
  parallelLimit - runningRenderers.length,
1539
- stillOffline.length,
1590
+ launchable.length,
1540
1591
  needNew,
1541
1592
  );
1542
1593
  for (let li = 0; li < Math.max(0, neededLaunches); li++) {
1543
- const toLaunch = stillOffline[li];
1594
+ const toLaunch = launchable[li];
1544
1595
  console.log(`[scene-dispatch] Launching ${toLaunch.name} (${runningRenderers.length + li + 1}/${parallelLimit})`);
1545
1596
  try {
1546
1597
  await this._launchRendererProfile(toLaunch);
@@ -1786,14 +1837,33 @@ class CommandPoller {
1786
1837
  const running = await this.nst.getRunningBrowsers();
1787
1838
  if (running.length === 0) return;
1788
1839
 
1789
- // Check if there are any queued commands — if so, don't close anything
1840
+ // Check if there are any queued commands — if so, don't close anything.
1841
+ // Exception: a queue that is ENTIRELY pinned to specific renderers keeps
1842
+ // every other open profile idle forever, so it must not block the idle
1843
+ // close (each profile is still spared individually below if the queue
1844
+ // holds work pinned to it).
1790
1845
  const qi = await this.api.getSceneQueueCount();
1791
1846
  const queueCount = typeof qi === 'number' ? qi : (qi?.total || 0);
1792
- if (queueCount > 0) return;
1847
+ const unpinnedCount = (qi && typeof qi.unpinned_count === 'number') ? qi.unpinned_count : null;
1848
+ const pinnedMap = (qi && qi.pinned && typeof qi.pinned === 'object') ? qi.pinned : {};
1849
+ const pinnedForId = (id) => {
1850
+ if (!id) return 0;
1851
+ if (typeof pinnedMap[id] === 'number') return pinnedMap[id];
1852
+ const lower = String(id).toLowerCase();
1853
+ for (const k of Object.keys(pinnedMap)) {
1854
+ if (k.toLowerCase() === lower) return pinnedMap[k];
1855
+ }
1856
+ return 0;
1857
+ };
1858
+ // Old API (no breakdown) → previous behaviour: any queue blocks closing.
1859
+ if (queueCount > 0 && (unpinnedCount === null || unpinnedCount > 0)) return;
1793
1860
 
1794
1861
  for (const browser of running) {
1795
1862
  const profileId = browser.profileId;
1796
1863
  const name = browser.name?.toLowerCase();
1864
+ // Queue holds work pinned to THIS profile → it's about to be claimed,
1865
+ // leave the browser open.
1866
+ if (pinnedForId(profileId) > 0 || pinnedForId(name) > 0) continue;
1797
1867
  // Match by UUID or name (renderers use name as nst_profile_id)
1798
1868
  const lastActivity = this._profileLastActivity[profileId]
1799
1869
  || (name && this._profileLastActivity[name])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.39",
3
+ "version": "2.5.41",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {