channel-worker 2.5.138 → 2.5.140

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/bin/cli.js CHANGED
@@ -285,6 +285,93 @@ if (cmd === 'pair') {
285
285
  }
286
286
  }
287
287
 
288
+ } else if (cmd === 'drain') {
289
+ // Tắt NHẸ: daemon ngừng nhận việc mới, chờ việc đang chạy xong rồi tự tắt.
290
+ // Dùng thay `taskkill /f` / `stop` khi phát hành — xem lib/drain.js (soát
291
+ // 23/09/2026: 9/9 phiên nuôi "Timed out" còn log là bị giết giữa phiên lúc
292
+ // thay bản). Mã thoát: 0 = daemon đã tắt sạch (hoặc không có daemon);
293
+ // 2 = hết hạn, daemon QUAY LẠI làm việc, người quyết định dừng cứng hay chờ;
294
+ // 3 = daemon cũ tắt nhưng đã có daemon MỚI giữ khoá (watchdog/updater bật lại).
295
+ const singleton = require('../lib/singleton');
296
+ const drain = require('../lib/drain');
297
+ const lock = singleton.readLock(CONFIG_DIR);
298
+ if (args.cancel) {
299
+ drain.clearRequest(CONFIG_DIR);
300
+ console.log('[channel-worker] Đã huỷ yêu cầu xả — daemon nhận việc lại ở nhịp kế (≤5s).');
301
+ process.exit(0);
302
+ }
303
+ if (!lock || singleton.isStale(lock)) {
304
+ drain.clearRequest(CONFIG_DIR);
305
+ console.log(`[channel-worker] Không có daemon nào đang sống ở ${CONFIG_DIR} — không có gì để xả.`);
306
+ process.exit(0);
307
+ }
308
+ const timeoutMin = Number(args.timeout || drain.DEFAULT_TIMEOUT_MIN);
309
+ if (!Number.isFinite(timeoutMin) || timeoutMin <= 0) {
310
+ console.error('[channel-worker] --timeout phải là số phút > 0');
311
+ process.exit(1);
312
+ }
313
+ const req = drain.writeRequest(CONFIG_DIR, { timeoutMin });
314
+ console.log(`[channel-worker] Đã gửi yêu cầu xả tới daemon PID ${lock.pid} (hạn ${req.deadline_at}).`);
315
+ if (process.platform === 'win32') {
316
+ console.log('[channel-worker] Nhớ: watchdog bật lại daemon sau ≤5 phút nếu chưa có C:\\agents\\watchdog.pause.');
317
+ }
318
+ if (lock.supervisor === 'pm2') {
319
+ console.log('[channel-worker] Daemon này chạy dưới pm2: xả xong nó tự tắt nhưng pm2 dựng lại ngay (lệnh sẽ trả mã 3). '
320
+ + 'Trên máy pm2 cứ để bộ tự cập nhật lo — từ 2.5.139 nó tự xả trước khi cài.');
321
+ }
322
+ if (args['no-wait']) process.exit(0);
323
+
324
+ (async () => {
325
+ // Daemon soi yêu cầu mỗi 5s và tự quay lại làm việc khi hết hạn — chờ quá
326
+ // hạn thêm 30s là chắc chắn đã có kết cục.
327
+ const hardStop = Date.parse(req.deadline_at) + 30000;
328
+ let lastSay = 0;
329
+ let deadAt = 0;
330
+ for (;;) {
331
+ await new Promise((r) => setTimeout(r, 2000));
332
+ const cur = singleton.readLock(CONFIG_DIR);
333
+ const newHolder = cur && cur.pid !== lock.pid && !singleton.isStale(cur) ? cur : null;
334
+ if (!singleton.pidAlive(lock.pid)) {
335
+ // "Đã tắt" chỉ tin khi TIẾN TRÌNH chết thật — khoá nhả sớm hơn thế (tắt
336
+ // dở, hoặc bộ tự cập nhật nhả khoá rồi đẻ bản thay). Chết rồi thì soi
337
+ // thêm 2 nhịp: bản thay của updater giành khoá ngay sau đó, và báo
338
+ // "đã dừng" lúc ấy là để người phát hành cài đè lên cây một daemon MỚI
339
+ // đang nạp (đúng loại sự cố MODULE_NOT_FOUND 08/09).
340
+ if (newHolder) {
341
+ console.error(`[channel-worker] Daemon PID ${lock.pid} đã tắt, nhưng daemon MỚI PID ${newHolder.pid} đang giữ khoá `
342
+ + `(${newHolder.supervisor === 'pm2' ? 'pm2 dựng lại' : 'watchdog hoặc bộ tự cập nhật vừa bật lại'}) — chưa phải trạng thái "đã dừng".`);
343
+ process.exit(3);
344
+ }
345
+ if (!deadAt) deadAt = Date.now();
346
+ if (Date.now() - deadAt >= 4000) {
347
+ console.log(`[channel-worker] Xả xong — daemon PID ${lock.pid} đã tự tắt, không cắt việc nào.`);
348
+ process.exit(0);
349
+ }
350
+ continue;
351
+ }
352
+ if (Date.now() > hardStop) {
353
+ console.error(`[channel-worker] Quá hạn mà daemon PID ${lock.pid} vẫn sống `
354
+ + `(${cur && cur.pid === lock.pid ? 'vẫn giữ khoá — bản daemon cũ chưa biết lệnh drain?' : 'đã nhả khoá nhưng chưa thoát'}). `
355
+ + 'Kiểm "channel-worker logs".');
356
+ process.exit(2);
357
+ }
358
+ // Khoá đã nhả / sang tay khác mà tiến trình cũ còn sống = đang tắt dở →
359
+ // chờ nó thoát hẳn.
360
+ if (!cur || cur.pid !== lock.pid) continue;
361
+ const status = drain.readStatus(CONFIG_DIR);
362
+ if (!drain.readRequest(CONFIG_DIR)) {
363
+ console.error('[channel-worker] Daemon đã THÔI XẢ và quay lại làm việc (hết hạn hoặc bị huỷ). '
364
+ + `Việc còn dở lúc đó: ${drain.describeBusy(status && status.busy)}. `
365
+ + 'Chờ thêm: chạy lại "channel-worker drain". Chấp nhận cắt: "channel-worker stop".');
366
+ process.exit(2);
367
+ }
368
+ if (Date.now() - lastSay >= 30000) {
369
+ lastSay = Date.now();
370
+ console.log(`[channel-worker] … đang chờ: ${status ? drain.describeBusy(status.busy) : 'daemon chưa nhận yêu cầu (nhịp 5s)'}`);
371
+ }
372
+ }
373
+ })();
374
+
288
375
  } else if (cmd === 'logs') {
289
376
  const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
290
377
  if (!fs.existsSync(LOG_FILE)) {
@@ -337,7 +424,9 @@ Commands:
337
424
  pair Pair with dashboard using a one-time code (recommended)
338
425
  init Configure worker manually
339
426
  start Start the daemon in background
340
- stop Stop the daemon
427
+ stop Stop the daemon (cắt ngang việc đang chạy)
428
+ drain Xả: ngừng nhận việc mới, chờ việc đang chạy xong rồi tự tắt
429
+ (--timeout <phút>, mặc định 25 · --no-wait · --cancel)
341
430
  restart Restart the daemon
342
431
  logs Show recent daemon logs (--lines <n>, default 50)
343
432
  update Check and install updates manually
@@ -21,6 +21,17 @@ class CommandPoller {
21
21
  this.timer = null;
22
22
  this.pollIntervalMs = 3000;
23
23
  this.nst = null;
24
+ // XẢ (lib/drain.js): tạm ngừng NHẬN việc mới, việc đang chạy vẫn chạy tiếp.
25
+ this._paused = false;
26
+ // Mọi lệnh đang xử lý, MỌI loại (không riêng _pw): id → type. poll() chạy
27
+ // theo setInterval, không chờ lệnh trước xong, nên một phiên nuôi 15 phút
28
+ // chạy song song với vài lượt lấy số liệu — daemon cần sổ này để biết lúc
29
+ // nào hết việc thật.
30
+ this._handling = new Map();
31
+ // Lượt poll đang bay (đã gửi GET /workers/commands, chưa về). API lật lệnh
32
+ // pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
33
+ // "rảnh rồi, tắt" sẽ mang về một lệnh đã bị nhận — tắt lúc đó là giết nó.
34
+ this._pollsInFlight = 0;
24
35
 
25
36
  // Init Nstbrowser if API key available
26
37
  if (config.nst_api_key) {
@@ -69,10 +80,25 @@ class CommandPoller {
69
80
  if (this._profileTimeoutTimer) { clearInterval(this._profileTimeoutTimer); this._profileTimeoutTimer = null; }
70
81
  }
71
82
 
83
+ // Không dừng timer: đồng bộ profile và đóng profile rảnh vẫn phải chạy trong
84
+ // lúc xả, chỉ riêng việc NHẬN lệnh / phát cảnh là ngừng.
85
+ pauseClaims() { this._paused = true; }
86
+ resumeClaims() { this._paused = false; }
87
+
72
88
  async poll() {
89
+ if (this._paused) return;
90
+ let command = null;
91
+ this._pollsInFlight++;
73
92
  try {
74
- const command = await this.api.getNextCommand(this.config.worker_id);
93
+ try {
94
+ command = await this.api.getNextCommand(this.config.worker_id);
95
+ } finally {
96
+ this._pollsInFlight--;
97
+ }
75
98
  if (!command) return;
99
+ // Lượt poll đã gửi đi TRƯỚC lúc xả vẫn có thể nhận về một lệnh — nó được
100
+ // ghi sổ như mọi lệnh khác, và daemon chờ nó xong mới tắt.
101
+ this._handling.set(String(command._id), command.type);
76
102
 
77
103
  console.log(`[commands] Received: ${command.type} (${command._id})`);
78
104
 
@@ -146,6 +172,8 @@ class CommandPoller {
146
172
  if (this.config.verbose) {
147
173
  console.error(`[commands] Poll error: ${err.message}`);
148
174
  }
175
+ } finally {
176
+ if (command) this._handling.delete(String(command._id));
149
177
  }
150
178
  }
151
179
 
@@ -1860,7 +1888,7 @@ class CommandPoller {
1860
1888
  // Processes ONE command per cycle — sequential, no race conditions.
1861
1889
 
1862
1890
  async _dispatchScenes() {
1863
- if (this._dispatching) return;
1891
+ if (this._paused || this._dispatching) return;
1864
1892
  this._dispatching = true;
1865
1893
  try {
1866
1894
  // 1. Init Nstbrowser if needed
@@ -2270,7 +2298,7 @@ class CommandPoller {
2270
2298
  // generate_thumbnail command is pending, so Veo3 renderers stay untouched.
2271
2299
 
2272
2300
  async _dispatchChatgpt() {
2273
- if (this._chatgptDispatching) return;
2301
+ if (this._paused || this._chatgptDispatching) return;
2274
2302
  this._chatgptDispatching = true;
2275
2303
  try {
2276
2304
  // Lazy-init NstManager — the daemon config often ships with an empty
package/lib/daemon.js CHANGED
@@ -7,9 +7,19 @@ const { checkAndUpdateExtension } = require('./extension-updater');
7
7
  const { CacheServer } = require('./cache-server');
8
8
  const { agentAddressForUser } = require('./nst-agent-locator');
9
9
  const singleton = require('./singleton');
10
+ const drain = require('./drain');
10
11
  const os = require('os');
11
12
  const path = require('path');
12
13
 
14
+ // Bộ tự cập nhật thấy bản mới mà đang bận → xả tối đa ngần này rồi cài luôn.
15
+ // Quá hạn mà còn việc = việc dài bất thường; cài luôn còn hơn kẹt mãi bản cũ.
16
+ const UPDATE_DRAIN_MS = drain.DEFAULT_TIMEOUT_MIN * 60e3;
17
+ // Lượt cài sau khi xả mà hỏng (registry chưa công bố tarball, cây cài cụt…)
18
+ // thì chờ ngần này mới được xả lại để cập nhật. Không có nó, máy bận cứ quay
19
+ // vòng: xả → cài hỏng → nhận việc 10 giây → xả tiếp — tệ hơn bản trước, vốn
20
+ // cứ làm việc bình thường và thử lại 5 phút một lần.
21
+ const UPDATE_RETRY_MS = 15 * 60e3;
22
+
13
23
  class Daemon {
14
24
  constructor(config) {
15
25
  this.config = config;
@@ -30,20 +40,45 @@ class Daemon {
30
40
  // Pass daemon URL down so command-poller can write it into per-profile config
31
41
  config.daemon_url = this.cacheServer.url();
32
42
  this.commandPoller = new CommandPoller(this.api, config);
33
- this.updateChecker = new UpdateChecker(5 * 60 * 1000); // check every 5min
43
+ // Có bản mới mà đang bận thì KHÔNG khởi động lại ngang — xả trước (xem
44
+ // _tickDrain), rảnh rồi mới cài.
45
+ this.updateChecker = new UpdateChecker(5 * 60 * 1000, {
46
+ isBusy: () => this._isBusy(),
47
+ onDeferred: (r) => this._onUpdateDeferred(r),
48
+ });
49
+ this._updateRetryAt = 0;
34
50
  this.extCheckTimer = null;
35
51
  this.nstAgentTimer = null;
36
52
  this.lockTimer = null;
53
+ this.drainTimer = null;
37
54
  this.configDir = path.join(os.homedir(), '.channel-worker');
38
55
  this._evictLogged = false;
56
+ // Yêu cầu xả ghi trước mốc này là của đời daemon trước — xem drain.isForMe.
57
+ this.startedAt = Date.now();
58
+ this._drain = null; // { reason: 'cli'|'update', since, deadline, latest }
59
+ this._drainFinishing = false;
39
60
  }
40
61
 
41
- /** Đang bận = có job đang chạy, hoặc có phiên Playwright đang giữ profile. */
62
+ /**
63
+ * Đang bận = có job đang chạy, có phiên Playwright đang giữ profile, hoặc có
64
+ * lệnh nào đang xử lý dở (mọi loại — sổ _handling của command poller).
65
+ */
42
66
  _isBusy() {
67
+ const b = this._busyDetail();
68
+ return b.jobs > 0 || b.pw > 0 || b.commands.length > 0 || b.polls > 0;
69
+ }
70
+
71
+ _busyDetail() {
43
72
  const jobs = (this.poller && this.poller.running) ? this.poller.running.size : 0;
44
73
  const pw = (this.commandPoller && this.commandPoller._pwInFlight)
45
74
  ? this.commandPoller._pwInFlight.size : 0;
46
- return jobs > 0 || pw > 0;
75
+ const commands = (this.commandPoller && this.commandPoller._handling)
76
+ ? [...this.commandPoller._handling.values()] : [];
77
+ // Lượt hỏi việc đang bay: API nhận lệnh/job ngay lúc trả về, tắt lúc này là
78
+ // giết một việc vừa bị nhận (xem CommandPoller._pollsInFlight).
79
+ const polls = ((this.commandPoller && this.commandPoller._pollsInFlight) || 0)
80
+ + ((this.poller && this.poller.pollsInFlight) || 0);
81
+ return { jobs, pw, commands, polls };
47
82
  }
48
83
 
49
84
  /**
@@ -76,6 +111,158 @@ class Daemon {
76
111
  else process.exit(0);
77
112
  }
78
113
 
114
+ // ─── XẢ: ngừng nhận việc mới, chờ việc đang chạy xong rồi mới tắt ──────────
115
+ // Hai đường vào:
116
+ // 'cli' — người phát hành chạy `channel-worker drain` (ghi drain.request).
117
+ // Xả xong thì TẮT. Hết hạn mà còn việc thì QUAY LẠI làm việc —
118
+ // người quyết định dừng cứng hay chờ thêm, đàn không bao giờ đứng
119
+ // im chỉ vì một lệnh xả bị bỏ quên.
120
+ // 'update' — bộ tự cập nhật thấy bản mới mà đang bận. Xả xong thì cài +
121
+ // khởi động lại. Hết hạn thì cài luôn (cắt việc dài bất thường).
122
+ // Trong lúc xả: heartbeat, nhịp khoá, đồng bộ/đóng profile rảnh vẫn chạy;
123
+ // chỉ ngừng nhận lệnh, nhận job, phát cảnh, và tắt bộ tự cập nhật (một lượt
124
+ // cài + khởi động lại lọt vào giữa là cắt đúng việc đang chờ).
125
+ _enterDrain({ reason, deadline, latest = null }) {
126
+ if (this._drain) return;
127
+ this._drain = { reason, since: Date.now(), deadline, latest };
128
+ if (this.commandPoller.pauseClaims) this.commandPoller.pauseClaims();
129
+ if (this.poller.pause) this.poller.pause();
130
+ this.updateChecker.stop();
131
+ const why = reason === 'update' ? `để cập nhật lên ${latest}` : 'theo lệnh "channel-worker drain"';
132
+ console.log(`[daemon] XẢ ${why}: ngừng nhận việc mới, chờ ${drain.describeBusy(this._busyDetail())} `
133
+ + `— hạn ${new Date(deadline).toISOString()}`);
134
+ this._writeDrainStatus();
135
+ }
136
+
137
+ _resumeFromDrain(why) {
138
+ console.warn(`[daemon] Thôi xả (${why}) — nhận việc lại bình thường.`);
139
+ const wasCli = !!this._drain && this._drain.reason === 'cli';
140
+ this._drain = null;
141
+ // Chỉ dọn yêu cầu khi đang xả VÌ nó (hết hạn / huỷ) — đó là tín hiệu cho lệnh
142
+ // drain đang chờ. Thôi xả để cập nhật mà xoá luôn là nuốt mất lệnh drain
143
+ // người phát hành vừa gửi giữa lúc npm đang cài; để nguyên thì nhịp sau
144
+ // nhận nó và xả để TẮT.
145
+ if (wasCli) drain.clearRequest(this.configDir);
146
+ drain.clearStatus(this.configDir);
147
+ if (this.commandPoller.resumeClaims) this.commandPoller.resumeClaims();
148
+ if (this.poller.resume) this.poller.resume();
149
+ // Không bật lại lượt kiểm 10 giây đầu: vừa thôi xả vì cài hỏng mà kiểm ngay
150
+ // là quay lại đúng vòng xả-cài-hỏng.
151
+ this.updateChecker.start({ initialDelay: false });
152
+ }
153
+
154
+ _onUpdateDeferred(r, now = Date.now()) {
155
+ if (now < this._updateRetryAt) return; // vừa cài hỏng — đợi, đừng xả lại liên tục
156
+ this._enterDrain({ reason: 'update', deadline: now + UPDATE_DRAIN_MS, latest: r.latest });
157
+ }
158
+
159
+ _writeDrainStatus() {
160
+ if (!this._drain) return;
161
+ drain.writeStatus(this.configDir, {
162
+ pid: process.pid,
163
+ reason: this._drain.reason,
164
+ since: new Date(this._drain.since).toISOString(),
165
+ deadline: new Date(this._drain.deadline).toISOString(),
166
+ busy: this._busyDetail(),
167
+ });
168
+ }
169
+
170
+ async _tickDrain(now = Date.now()) {
171
+ if (this._drainFinishing) return;
172
+ // Lệnh drain là cho CHỦ KHOÁ. Daemon thừa (mất khoá) đã tự tắt lúc rảnh ở
173
+ // _tickLock; để nó nghe lệnh là nó xả xong trước rồi xoá mất yêu cầu, chủ
174
+ // khoá đang xả dở thấy "bị huỷ" và quay lại nhận việc.
175
+ if (!singleton.owns(this.configDir)) return;
176
+ const req = drain.readRequest(this.configDir);
177
+ if (req && !drain.isForMe(req, this.startedAt)) {
178
+ console.log(`[daemon] Bỏ yêu cầu xả cũ (${req.requested_at}) — của daemon đời trước.`);
179
+ drain.clearRequest(this.configDir);
180
+ } else if (req && (!this._drain || this._drain.reason !== 'cli')) {
181
+ // Người phát hành thắng bộ tự cập nhật: họ muốn daemon TẮT, không muốn
182
+ // nó tự cài rồi bật lại ngay dưới tay họ.
183
+ if (this._drain) {
184
+ this._drain.reason = 'cli';
185
+ this._drain.deadline = Date.parse(req.deadline_at);
186
+ console.log('[daemon] Lệnh drain đến giữa lúc xả để cập nhật — chuyển sang xả để TẮT.');
187
+ } else {
188
+ this._enterDrain({ reason: 'cli', deadline: Date.parse(req.deadline_at) });
189
+ }
190
+ } else if (!req && this._drain && this._drain.reason === 'cli') {
191
+ this._resumeFromDrain('yêu cầu xả đã bị huỷ');
192
+ return;
193
+ }
194
+ if (!this._drain) return;
195
+
196
+ this._writeDrainStatus();
197
+ if (!this._isBusy()) {
198
+ await this._finishDrain();
199
+ return;
200
+ }
201
+ if (now > this._drain.deadline) {
202
+ const left = drain.describeBusy(this._busyDetail());
203
+ if (this._drain.reason === 'update') {
204
+ console.warn(`[daemon] Hết hạn xả mà vẫn còn ${left} — cài bản mới luôn, việc đó sẽ bị cắt.`);
205
+ await this._finishDrain();
206
+ } else {
207
+ this._resumeFromDrain(`hết hạn xả, vẫn còn ${left}`);
208
+ }
209
+ }
210
+ }
211
+
212
+ async _finishDrain() {
213
+ this._drainFinishing = true;
214
+ try {
215
+ if (this._drain.reason === 'update') {
216
+ console.log('[daemon] Xả xong — cài bản mới rồi khởi động lại.');
217
+ // Cài xong thì check() tự process.exit. Tới được dòng sau = KHÔNG khởi
218
+ // động lại (registry chưa công bố tarball, cây cài hỏng…) → làm tiếp.
219
+ const r = await this.updateChecker.check({ ignoreBusy: true });
220
+ this._updateRetryAt = Date.now() + UPDATE_RETRY_MS;
221
+ this._resumeFromDrain(`chưa cập nhật được${r && r.error ? `: ${r.error}` : ''}, `
222
+ + `${UPDATE_RETRY_MS / 60e3} phút nữa mới xả lại để cập nhật`);
223
+ return;
224
+ }
225
+ console.log('[daemon] Xả xong — không còn việc dở, tự tắt.');
226
+ // shutdown nhả khoá rồi mới dọn drain.request (xem ghi chú ở đó).
227
+ if (this._shutdown) await this._shutdown();
228
+ else process.exit(0);
229
+ } finally {
230
+ this._drainFinishing = false;
231
+ }
232
+ }
233
+
234
+ /** Dừng mọi nhịp, nhả khoá, dọn file xả, báo offline — mọi thứ trừ process.exit. */
235
+ async _teardown() {
236
+ this.heartbeat.stop();
237
+ this.poller.stop();
238
+ this.commandPoller.stop();
239
+ this.updateChecker.stop();
240
+ if (this.extCheckTimer) clearInterval(this.extCheckTimer);
241
+ if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
242
+ if (this.lockTimer) clearInterval(this.lockTimer);
243
+ if (this.drainTimer) clearInterval(this.drainTimer);
244
+ try { this.cacheServer.stop(); } catch {}
245
+ // Nhả khoá để con kế tiếp vào ngay, khỏi phải chờ hết hạn STALE.
246
+ let wasOwner = false;
247
+ try { wasOwner = singleton.release(this.configDir); } catch {}
248
+ // Dọn file xả SAU khi nhả khoá: lệnh drain đang chờ thấy khoá nhả trước
249
+ // → "xả xong"; ngược thứ tự nó thấy yêu cầu biến mất khi khoá còn đó và
250
+ // báo nhầm "daemon thôi xả". Chỉ dọn khi CHÍNH mình là chủ khoá đang xả —
251
+ // daemon thừa tự tắt (kể cả con từng xả dở trước khi bị `start --force`
252
+ // cướp khoá) mà xoá thì yêu cầu gửi cho chủ khoá MỚI biến mất giữa chừng.
253
+ if (wasOwner && this._drain) {
254
+ drain.clearRequest(this.configDir);
255
+ drain.clearStatus(this.configDir);
256
+ }
257
+
258
+ // Mark offline
259
+ try {
260
+ await this.api.request('PUT', `/workers/${this.config.worker_id}`, {
261
+ status: 'offline',
262
+ });
263
+ } catch { /* ignore */ }
264
+ }
265
+
79
266
  /**
80
267
  * Point NST_API_ADDRESS at the agent of the Windows user we're paired with.
81
268
  *
@@ -195,6 +382,14 @@ class Daemon {
195
382
  this.lockTimer = setInterval(() => { this._tickLock().catch(() => {}); }, singleton.REFRESH_MS);
196
383
  console.log(`[daemon] Singleton lock watcher started (every ${singleton.REFRESH_MS / 1000}s)`);
197
384
 
385
+ // Nhịp xả — soi drain.request (lệnh "channel-worker drain") và theo dõi
386
+ // việc dở trong lúc xả. Trạng thái xả của đời trước là rác, dọn luôn.
387
+ drain.clearStatus(this.configDir);
388
+ this.drainTimer = setInterval(() => {
389
+ this._tickDrain().catch((e) => console.warn(`[daemon] nhịp xả lỗi: ${e.message}`));
390
+ }, drain.TICK_MS);
391
+ console.log(`[daemon] Drain watcher started (every ${drain.TICK_MS / 1000}s)`);
392
+
198
393
  // Start auto-update checker
199
394
  this.updateChecker.start();
200
395
  console.log('[daemon] Auto-update checker started (every 5min)');
@@ -219,24 +414,7 @@ class Daemon {
219
414
  // Graceful shutdown
220
415
  const shutdown = async () => {
221
416
  console.log('\n[daemon] Shutting down...');
222
- this.heartbeat.stop();
223
- this.poller.stop();
224
- this.commandPoller.stop();
225
- this.updateChecker.stop();
226
- if (this.extCheckTimer) clearInterval(this.extCheckTimer);
227
- if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
228
- if (this.lockTimer) clearInterval(this.lockTimer);
229
- try { this.cacheServer.stop(); } catch {}
230
- // Nhả khoá để con kế tiếp vào ngay, khỏi phải chờ hết hạn STALE.
231
- try { singleton.release(this.configDir); } catch {}
232
-
233
- // Mark offline
234
- try {
235
- await this.api.request('PUT', `/workers/${this.config.worker_id}`, {
236
- status: 'offline',
237
- });
238
- } catch { /* ignore */ }
239
-
417
+ await this._teardown();
240
418
  console.log('[daemon] Goodbye.');
241
419
  process.exit(0);
242
420
  };
package/lib/drain.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * XẢ daemon: ngừng nhận việc mới, chờ việc đang chạy xong rồi mới tắt.
3
+ *
4
+ * Vì sao có (soát 23/09/2026): phát hành worker tắt daemon bằng
5
+ * `taskkill /f /im node.exe`, còn bộ tự cập nhật thấy bản mới là cài + khởi
6
+ * động lại ngay — cả hai đều không nhìn việc đang chạy. 9/9 phiên nuôi FB
7
+ * "Timed out … crash giữa chừng" còn log đều là daemon bị thay bằng bản mới
8
+ * GIỮA phiên (31 bản trong 10 ngày), và cùng cơ chế đã cắt ngang lượt đăng bài
9
+ * (kenh-71 lúc 22:29 ngày 23/09, đang ở khung soạn Reel).
10
+ *
11
+ * Giao tiếp qua FILE trong CONFIG_DIR, không qua tín hiệu: trên Windows
12
+ * `process.kill(pid, 'SIGTERM')` là TerminateProcess — không có cách nào nhờ
13
+ * tiến trình khác "tắt nhẹ nhàng". Người phát hành chạy `channel-worker drain`,
14
+ * lệnh đó ghi `drain.request`; daemon soi file mỗi TICK_MS.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const REQUEST_FILE = 'drain.request';
21
+ const STATUS_FILE = 'drain.status';
22
+ // Phiên nuôi dài nhất đo được 18 phút, lượt đăng FB có thể chờ nút Đăng 10 phút
23
+ // cộng các bước trước — 25 phút phủ cả hai mà không treo lượt phát hành cả buổi.
24
+ const DEFAULT_TIMEOUT_MIN = 25;
25
+ const TICK_MS = 5000;
26
+
27
+ const requestPath = (dir) => path.join(dir, REQUEST_FILE);
28
+ const statusPath = (dir) => path.join(dir, STATUS_FILE);
29
+
30
+ function writeRequest(dir, { timeoutMin = DEFAULT_TIMEOUT_MIN, now = Date.now() } = {}) {
31
+ const req = {
32
+ requested_at: new Date(now).toISOString(),
33
+ deadline_at: new Date(now + timeoutMin * 60e3).toISOString(),
34
+ by_pid: process.pid,
35
+ };
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ fs.writeFileSync(requestPath(dir), JSON.stringify(req));
38
+ return req;
39
+ }
40
+
41
+ function readRequest(dir) {
42
+ try {
43
+ const req = JSON.parse(fs.readFileSync(requestPath(dir), 'utf-8'));
44
+ if (!req || !Number.isFinite(Date.parse(req.requested_at)) || !Number.isFinite(Date.parse(req.deadline_at))) return null;
45
+ return req;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Yêu cầu có phải cho daemon này không. Yêu cầu ghi TRƯỚC lúc daemon khởi
53
+ * động là rác của đời trước (daemon cũ chết trước khi kịp dọn) — nghe theo nó
54
+ * thì daemon mới vừa lên đã tự tắt, và watchdog bật lại vô tận.
55
+ */
56
+ function isForMe(req, startedAt) {
57
+ return !!req && Date.parse(req.requested_at) >= startedAt;
58
+ }
59
+
60
+ function clearRequest(dir) {
61
+ try { fs.unlinkSync(requestPath(dir)); } catch { /* đã không có */ }
62
+ }
63
+
64
+ function writeStatus(dir, status) {
65
+ try {
66
+ fs.writeFileSync(statusPath(dir), JSON.stringify({ ...status, at: new Date().toISOString() }));
67
+ } catch { /* trạng thái chỉ để người xem, hỏng thì thôi */ }
68
+ }
69
+
70
+ function readStatus(dir) {
71
+ try { return JSON.parse(fs.readFileSync(statusPath(dir), 'utf-8')); } catch { return null; }
72
+ }
73
+
74
+ function clearStatus(dir) {
75
+ try { fs.unlinkSync(statusPath(dir)); } catch { /* đã không có */ }
76
+ }
77
+
78
+ /** "2 lệnh (nurture_facebook_pw, upload_facebook_pw) · 1 job" — cho log và lệnh drain. */
79
+ function describeBusy(busy) {
80
+ if (!busy) return 'không rõ';
81
+ const parts = [];
82
+ const cmds = busy.commands || [];
83
+ if (cmds.length) parts.push(`${cmds.length} lệnh (${cmds.join(', ')})`);
84
+ if (busy.jobs) parts.push(`${busy.jobs} job`);
85
+ if (busy.polls) parts.push(`${busy.polls} lượt hỏi việc đang bay`);
86
+ return parts.length ? parts.join(' · ') : 'không còn việc';
87
+ }
88
+
89
+ module.exports = {
90
+ REQUEST_FILE, STATUS_FILE, DEFAULT_TIMEOUT_MIN, TICK_MS,
91
+ requestPath, statusPath, writeRequest, readRequest, isForMe, clearRequest,
92
+ writeStatus, readStatus, clearStatus, describeBusy,
93
+ };
package/lib/job-poller.js CHANGED
@@ -8,8 +8,14 @@ class JobPoller {
8
8
  this.running = new Set(); // job IDs currently executing
9
9
  this.timer = null;
10
10
  this.pollIntervalMs = 5000;
11
+ this.paused = false; // XẢ: ngừng nhận job mới, job đang chạy vẫn chạy tiếp
12
+ // Lượt hỏi/nhận job đang bay — xem CommandPoller._pollsInFlight.
13
+ this.pollsInFlight = 0;
11
14
  }
12
15
 
16
+ pause() { this.paused = true; }
17
+ resume() { this.paused = false; }
18
+
13
19
  start() {
14
20
  console.log(`[poller] Started — max concurrent: ${this.config.max_concurrent}`);
15
21
  this.poll(); // immediate first poll
@@ -28,8 +34,11 @@ class JobPoller {
28
34
  }
29
35
 
30
36
  async poll() {
31
- if (this.availableSlots <= 0) return;
37
+ if (this.paused || this.availableSlots <= 0) return;
32
38
 
39
+ this.pollsInFlight++;
40
+ let inFlight = true;
41
+ const settle = () => { if (inFlight) { inFlight = false; this.pollsInFlight--; } };
33
42
  try {
34
43
  const job = await this.api.getNextJob(this.config.worker_id);
35
44
  if (!job) return; // no jobs in queue
@@ -39,7 +48,7 @@ class JobPoller {
39
48
 
40
49
  // Claim the job
41
50
  await this.api.claimJob(jobId, this.config.worker_id);
42
- this.running.add(jobId);
51
+ this.running.add(jobId); // từ đây `running` giữ nó trong sổ bận (finally trả lượt bay)
43
52
 
44
53
  console.log(`[poller] Claimed job ${jobId} (${this.running.size}/${this.config.max_concurrent} slots used)`);
45
54
 
@@ -56,6 +65,8 @@ class JobPoller {
56
65
  if (this.config.verbose) {
57
66
  console.error(`[poller] Poll error: ${err.message}`);
58
67
  }
68
+ } finally {
69
+ settle();
59
70
  }
60
71
  }
61
72
  }
@@ -21,6 +21,43 @@ function nstLaunchError(response, { profileId = '', runningCount = 0 } = {}) {
21
21
  return error;
22
22
  }
23
23
 
24
+ // Every proxy in the fleet exits through Viettel VN, and GeoIP answers
25
+ // "Vietnam / Asia/Bangkok" for those IPs — so letting NST derive locale from
26
+ // the proxy turned every new profile into a Thai browser (th-TH, Asia/Bangkok).
27
+ // Measured 2026-09-25 on win-worker: 6/6 running profiles, FB login page in
28
+ // Thai for Vietnamese accounts. Pin what a Vietnamese user actually runs.
29
+ // If a non-VN proxy is ever added, this has to follow the proxy's country.
30
+ const VN_LOCALIZATION = {
31
+ basedOnProxy: false,
32
+ locale: 'vi-VN',
33
+ languages: ['vi-VN', 'vi', 'en-US', 'en'],
34
+ timezone: 'Asia/Ho_Chi_Minh',
35
+ };
36
+
37
+ // Only pin the noise flags and the locale. CPU count, RAM, kernel version and
38
+ // UA stay with Nstbrowser's own randomiser: hard-coding hardwareConcurrency=8 /
39
+ // deviceMemory=8 / kernel 132 for EVERY profile made 22/22 running browsers
40
+ // identical (win-worker, 2026-08-22), which is exactly what lets a platform
41
+ // cluster a fleet of accounts. All FB/YT selectors are bilingual (vi + en).
42
+ function profileCreateBody(name, platform) {
43
+ return {
44
+ name,
45
+ platform,
46
+ fingerprint: {
47
+ flags: {
48
+ audio: 'Noise',
49
+ canvas: 'Noise',
50
+ fonts: 'Masked',
51
+ gpu: 'Allow',
52
+ webgl: 'Noise',
53
+ localization: 'Custom',
54
+ timezone: 'Custom',
55
+ },
56
+ localization: VN_LOCALIZATION,
57
+ },
58
+ };
59
+ }
60
+
24
61
  class NstManager {
25
62
  constructor(apiKey, options = {}) {
26
63
  this.apiKey = apiKey;
@@ -232,32 +269,9 @@ class NstManager {
232
269
 
233
270
  const platform = (options.os || 'windows').toLowerCase() === 'mac' ? 'MacOS' : 'Windows';
234
271
  console.log(`[nst] WARNING: Profile "${name}" NOT FOUND after 3 retries — creating new profile (${platform})...`);
235
- // Only pin the noise flags. Everything else (CPU count, RAM, kernel
236
- // version, UA, locale/timezone) is left to Nstbrowser's own randomiser.
237
- // Previously this hard-coded hardwareConcurrency=8 / deviceMemory=8 /
238
- // kernel 132 / en-US for EVERY profile, and NST honoured it — measured
239
- // 2026-08-22 on win-worker: 22 running browsers, 22/22 identical on
240
- // (chrome, cpu, ram, language, timezone, screen). Same machine fingerprint
241
- // across a fleet of accounts is exactly what lets a platform cluster them.
242
- // localization 'BasedOnProxy' lets NST derive locale + timezone from the
243
- // proxy's geo, so a VN account on a VN proxy stops claiming it only speaks
244
- // English. All FB/YT selectors are already bilingual (vi + en).
245
272
  const res = await this.api('/profiles', {
246
273
  method: 'POST',
247
- body: JSON.stringify({
248
- name,
249
- platform,
250
- fingerprint: {
251
- flags: {
252
- audio: 'Noise',
253
- canvas: 'Noise',
254
- fonts: 'Masked',
255
- gpu: 'Allow',
256
- webgl: 'Noise',
257
- localization: 'BasedOnProxy',
258
- },
259
- },
260
- }),
274
+ body: JSON.stringify(profileCreateBody(name, platform)),
261
275
  });
262
276
 
263
277
  const profileId = res?.data?.profileId || res?.data?._id;
@@ -446,4 +460,4 @@ class NstManager {
446
460
  }
447
461
  }
448
462
 
449
- module.exports = { NstManager, nstLaunchError };
463
+ module.exports = { NstManager, nstLaunchError, profileCreateBody };
package/lib/singleton.js CHANGED
@@ -70,6 +70,10 @@ function writeLock(configDir, info, { overwrite = false } = {}) {
70
70
  version: info.version || '',
71
71
  started_at: info.started_at || new Date().toISOString(),
72
72
  refreshed_at: new Date().toISOString(),
73
+ // Ai dựng lại daemon khi nó tắt: 'pm2' thì `channel-worker drain` biết
74
+ // trước là xả xong pm2 sẽ bật lại ngay (relabs03). Làm tươi khoá chép
75
+ // nguyên giá trị cũ; lần ghi đầu đọc từ môi trường của chính daemon.
76
+ supervisor: info.supervisor !== undefined ? info.supervisor : (process.env.pm_id !== undefined ? 'pm2' : ''),
73
77
  });
74
78
  // 'wx' = tạo mới, thất bại nếu đã có → chính là phép thử nguyên tử.
75
79
  const fd = fs.openSync(lockPath(configDir), overwrite ? 'w' : 'wx');
package/lib/updater.js CHANGED
@@ -38,18 +38,26 @@ function restartBlocker() {
38
38
  // Quyết định THUẦN của một lượt kiểm (test được, không đụng đĩa/mạng):
39
39
  // 'none' — đang chạy bản mới nhất
40
40
  // 'blocked' — có bản mới nhưng không khởi động lại được → KHÔNG cài
41
+ // 'deferred-busy'— có bản mới nhưng daemon đang có việc dở → CHƯA cài, CHƯA
42
+ // khởi động lại; daemon chuyển sang XẢ rồi quay lại (drain.js)
41
43
  // 'restart-only' — bản mới đã có sẵn trên đĩa → chỉ kiểm + khởi động lại
42
44
  // 'install' — cài rồi kiểm rồi khởi động lại
43
45
  //
46
+ // Vì sao có 'deferred-busy' (soát 23/09/2026): khởi động lại = process.exit, và
47
+ // mọi việc đang chạy chết theo — 9/9 phiên nuôi FB "Timed out … crash giữa
48
+ // chừng" còn log là daemon bị thay bằng bản mới giữa phiên. Cài khi bận cũng
49
+ // không được: npm xoá cây đang chạy trước khi ghi cây mới.
50
+ //
44
51
  // Vì sao phải có 'blocked' và 'restart-only' (sự cố 17-19/09/2026, win-worker):
45
52
  // worker-1 được bật tay qua start-worker.bat nên restart luôn chết EBUSY. Bản cũ
46
53
  // so bản ĐANG CHẠY (2.5.117) với registry (2.5.120) nên lượt nào cũng
47
54
  // `npm i -g` lại từ đầu rồi mới vấp EBUSY: 413 lần cài đè trong ~34 giờ, lên
48
55
  // đúng thư mục cài mà worker-2 đang chạy. Cài đè cây đang chạy chính là thứ đã
49
56
  // giết worker Windows nhiều lần (lib/ bị xoá giữa chừng).
50
- function planUpdate({ running, onDisk, latest, restartBlocked = '' }) {
57
+ function planUpdate({ running, onDisk, latest, restartBlocked = '', busy = false }) {
51
58
  if (!latest || !isNewer(latest, running)) return 'none';
52
59
  if (restartBlocked) return 'blocked';
60
+ if (busy) return 'deferred-busy';
53
61
  if (onDisk && !isNewer(latest, onDisk)) return 'restart-only';
54
62
  return 'install';
55
63
  }
@@ -136,7 +144,7 @@ function verifyInstall() {
136
144
  return { ok: out.status === 0, detail: detail.slice(-600) };
137
145
  }
138
146
 
139
- async function checkAndUpdate({ autoRestart = false } = {}) {
147
+ async function checkAndUpdate({ autoRestart = false, isBusy = null } = {}) {
140
148
  const local = getLocalVersion();
141
149
  const latest = await getLatestVersion();
142
150
 
@@ -148,7 +156,11 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
148
156
  // update` gõ tay thì cứ cài như cũ.
149
157
  const onDisk = getInstalledVersion();
150
158
  const blocker = autoRestart ? restartBlocker() : '';
151
- const plan = planUpdate({ running: local, onDisk, latest, restartBlocked: blocker });
159
+ const busy = !!(autoRestart && typeof isBusy === 'function' && isBusy());
160
+ const plan = planUpdate({ running: local, onDisk, latest, restartBlocked: blocker, busy });
161
+ if (plan === 'deferred-busy') {
162
+ return { updated: false, local, latest, deferredBusy: true };
163
+ }
152
164
  if (plan === 'blocked') {
153
165
  if (Date.now() - lastBlockedWarnAt > BLOCKED_WARN_EVERY_MS) {
154
166
  lastBlockedWarnAt = Date.now();
@@ -238,33 +250,52 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
238
250
  }
239
251
 
240
252
  class UpdateChecker {
241
- constructor(intervalMs = 5 * 60 * 1000) {
253
+ /**
254
+ * @param {object} [opts]
255
+ * @param {() => boolean} [opts.isBusy] daemon còn việc dở không
256
+ * @param {(r: object) => void} [opts.onDeferred] có bản mới mà đang bận —
257
+ * daemon chuyển sang XẢ, rảnh rồi gọi lại check({ ignoreBusy: true })
258
+ * @param {Function} [opts.checkFn] thay checkAndUpdate (test)
259
+ */
260
+ constructor(intervalMs = 5 * 60 * 1000, { isBusy = null, onDeferred = null, checkFn = checkAndUpdate } = {}) {
242
261
  this.intervalMs = intervalMs;
243
262
  this.timer = null;
263
+ this.startTimer = null;
264
+ this.isBusy = isBusy;
265
+ this.onDeferred = onDeferred;
266
+ this.checkFn = checkFn;
244
267
  }
245
268
 
246
- start() {
247
- // Check on startup after 10s delay
248
- setTimeout(() => this.check(), 10000);
269
+ start({ initialDelay = true } = {}) {
270
+ // Check on startup after 10s delay (bỏ qua khi daemon vừa thôi xả — xem
271
+ // Daemon._resumeFromDrain)
272
+ if (initialDelay) this.startTimer = setTimeout(() => this.check(), 10000);
249
273
  // Then periodically
250
274
  this.timer = setInterval(() => this.check(), this.intervalMs);
251
275
  }
252
276
 
253
277
  stop() {
278
+ // Dọn cả lượt kiểm 10s đầu: dừng ngay sau start() mà để nó chạy là một lượt
279
+ // cài + khởi động lại lọt ra giữa lúc daemon đang xả.
280
+ if (this.startTimer) { clearTimeout(this.startTimer); this.startTimer = null; }
254
281
  if (this.timer) {
255
282
  clearInterval(this.timer);
256
283
  this.timer = null;
257
284
  }
258
285
  }
259
286
 
260
- async check() {
287
+ /** @param {{ignoreBusy?: boolean}} [opts] ignoreBusy = daemon đã xả xong (hoặc hết hạn xả) */
288
+ async check({ ignoreBusy = false } = {}) {
261
289
  try {
262
- const result = await checkAndUpdate({ autoRestart: true });
263
- if (!result.updated) {
264
- // Silent — only log in verbose or when there's an update
290
+ const result = await this.checkFn({ autoRestart: true, isBusy: ignoreBusy ? null : this.isBusy });
291
+ if (result && result.deferredBusy) {
292
+ console.log(`[updater] Có bản ${result.latest} nhưng đang có việc dở — xả trước, rảnh rồi mới cài.`);
293
+ if (this.onDeferred) this.onDeferred(result);
265
294
  }
295
+ return result;
266
296
  } catch (err) {
267
297
  console.warn(`[updater] Check failed: ${err.message}`);
298
+ return { updated: false, error: err.message };
268
299
  }
269
300
  }
270
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.138",
3
+ "version": "2.5.140",
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": {