channel-worker 2.5.40 → 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
@@ -1476,7 +1503,19 @@ class CommandPoller {
1476
1503
  && (!r.pause_until || new Date(r.pause_until).getTime() > Date.now());
1477
1504
  let stillOffline = renderers.filter(r => !runningRenderers.includes(r) && !isPaused(r));
1478
1505
  const pausedCount = renderers.filter(isPaused).length;
1479
- 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
+ }
1480
1519
 
1481
1520
  // ROUND-ROBIN ROTATION — when a running renderer just finished its
1482
1521
  // scene (no in-flight cmd) AND there's at least one OFFLINE sibling
@@ -1490,7 +1529,10 @@ class CommandPoller {
1490
1529
  // Round-robin close-idle is a SHARED-ACCOUNT safety (keep ≤1 Flow session
1491
1530
  // live per Google account). With independent_accounts every renderer is
1492
1531
  // its own account → skip it entirely and let them all run concurrently.
1493
- if (!independentAccounts && queueCount > 0 && stillOffline.length > 0 && runningRenderers.length > 0) {
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) {
1494
1536
  const stoppedNames = [];
1495
1537
  for (const r of [...runningRenderers]) {
1496
1538
  // Skip externally-launched profiles (user opened manually via NST UI)
@@ -1523,7 +1565,9 @@ class CommandPoller {
1523
1565
  // the launcher picks the round-robin partner, not the one that just
1524
1566
  // finished. API populates last_command_assigned_at on every claim,
1525
1567
  // so this naturally implements turn-taking across siblings.
1526
- 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) => {
1527
1571
  const ta = a.last_command_assigned_at ? new Date(a.last_command_assigned_at).getTime() : 0;
1528
1572
  const tb = b.last_command_assigned_at ? new Date(b.last_command_assigned_at).getTime() : 0;
1529
1573
  return ta - tb;
@@ -1543,11 +1587,11 @@ class CommandPoller {
1543
1587
  const needNew = Math.max(0, queueCount);
1544
1588
  const neededLaunches = Math.min(
1545
1589
  parallelLimit - runningRenderers.length,
1546
- stillOffline.length,
1590
+ launchable.length,
1547
1591
  needNew,
1548
1592
  );
1549
1593
  for (let li = 0; li < Math.max(0, neededLaunches); li++) {
1550
- const toLaunch = stillOffline[li];
1594
+ const toLaunch = launchable[li];
1551
1595
  console.log(`[scene-dispatch] Launching ${toLaunch.name} (${runningRenderers.length + li + 1}/${parallelLimit})`);
1552
1596
  try {
1553
1597
  await this._launchRendererProfile(toLaunch);
@@ -1793,14 +1837,33 @@ class CommandPoller {
1793
1837
  const running = await this.nst.getRunningBrowsers();
1794
1838
  if (running.length === 0) return;
1795
1839
 
1796
- // 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).
1797
1845
  const qi = await this.api.getSceneQueueCount();
1798
1846
  const queueCount = typeof qi === 'number' ? qi : (qi?.total || 0);
1799
- 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;
1800
1860
 
1801
1861
  for (const browser of running) {
1802
1862
  const profileId = browser.profileId;
1803
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;
1804
1867
  // Match by UUID or name (renderers use name as nst_profile_id)
1805
1868
  const lastActivity = this._profileLastActivity[profileId]
1806
1869
  || (name && this._profileLastActivity[name])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.40",
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": {