dsh-autorestart 0.6.2 → 0.8.0

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.
Files changed (47) hide show
  1. package/README.md +460 -12
  2. package/assets/screenshot-1.png +0 -0
  3. package/assets/screenshot-2.png +0 -0
  4. package/assets/screenshot-3.png +0 -0
  5. package/assets/screenshot-4.png +0 -0
  6. package/assets/screenshot-5.png +0 -0
  7. package/client.js +6329 -3863
  8. package/cordis.patch.yml +44 -1
  9. package/docs/README.zh.md +297 -9
  10. package/docs/platform-matrix.md +92 -0
  11. package/docs/rescue-drill.md +606 -0
  12. package/index.js +2867 -199
  13. package/lib/access.js +212 -0
  14. package/lib/autostart-advice.js +198 -0
  15. package/lib/backup.js +131 -4
  16. package/lib/blockers.js +228 -0
  17. package/lib/checkpoint.js +147 -0
  18. package/lib/diagnostics.js +188 -0
  19. package/lib/health.js +264 -0
  20. package/lib/host-kind.js +145 -0
  21. package/lib/node-runtime.js +144 -0
  22. package/lib/rescue-paths.js +253 -0
  23. package/lib/rescue.js +596 -0
  24. package/lib/restart-verdict.js +327 -0
  25. package/lib/restart.js +823 -56
  26. package/lib/scheduler-capability.js +300 -0
  27. package/lib/sleep.js +46 -49
  28. package/lib/startup-mode.js +491 -0
  29. package/lib/supervisor-config.js +735 -0
  30. package/lib/supervisor-install.js +557 -0
  31. package/lib/supervisor.js +1276 -0
  32. package/lib/watchdog-arbiter.js +117 -0
  33. package/lib/watchdog-schedule.js +310 -0
  34. package/lib/watchdog-tier.js +182 -0
  35. package/lib/watchdog.js +479 -0
  36. package/locale/en.json +274 -64
  37. package/locale/zh.json +274 -64
  38. package/package.json +7 -2
  39. package/screenshots.json +6 -4
  40. package/scripts/README.md +65 -0
  41. package/scripts/drill-rescue.ps1 +508 -0
  42. package/scripts/emergency-disable.cmd +27 -0
  43. package/scripts/emergency-toggle.ps1 +373 -0
  44. package/scripts/restore-profile.cmd +44 -0
  45. package/scripts/restore-profile.ps1 +608 -0
  46. package/scripts/run-tests.ps1 +46 -0
  47. package/supervisor/supervisor.js +884 -0
@@ -0,0 +1,300 @@
1
+ /**
2
+ * CAN THIS MACHINE CREATE A ONE-OFF SCHEDULED TASK?
3
+ *
4
+ * The automatic rollback of this plugin works through a scheduled task: the
5
+ * host is restarted, and a task that fires a minute later brings it back if
6
+ * it did not come up. On a machine where such a task cannot be created
7
+ * (a corporate policy, a machine without the rights, a locked-down service
8
+ * account) that safety net is silently missing — and the user has to be told
9
+ * what they lose.
10
+ *
11
+ * WHAT THIS DOES NOT DO (on purpose):
12
+ *
13
+ * - it does NOT design a fallback chain ("use the startup folder instead",
14
+ * "use a systemd timer instead", ...): the user decided against it;
15
+ * - it does NOT try to bypass the policy ("maybe another way works"): if
16
+ * the machine says no, the answer is no.
17
+ *
18
+ * It only DETECTS, once, and tells the truth.
19
+ *
20
+ * THE PROBE NEVER LEAVES A TASK BEHIND:
21
+ *
22
+ * 1. a task with a unique name is registered (it fires in two minutes and
23
+ * does nothing: `cmd /c exit 0`);
24
+ * 2. it is read back and verified (the trigger is the one we asked for);
25
+ * 3. it is deleted;
26
+ * 4. it is read back AGAIN — the task must be gone.
27
+ *
28
+ * The verification is the XML of the task, not its human readable listing:
29
+ * the XML has no translated words, so the check does not depend on the
30
+ * language of the machine ("Ready" in English, "就绪" in Chinese).
31
+ *
32
+ * Everything that touches the system is injected, so a test never has to
33
+ * create a real task.
34
+ */
35
+
36
+ import { execFileSync } from 'node:child_process';
37
+
38
+ /** The scheduler works on this machine. */
39
+ export const SCHEDULER_OK = 'ok';
40
+ /** The scheduler is refused here — the automatic rollback is not available. */
41
+ export const SCHEDULER_BLOCKED = 'blocked';
42
+ /** Nobody checked yet. */
43
+ export const SCHEDULER_UNKNOWN = 'unknown';
44
+
45
+ /** The name of the probe task. It is unique per run, so two probes cannot collide. */
46
+ export const PROBE_TASK_PREFIX = 'dsh-autorestart-capability-probe';
47
+
48
+ /**
49
+ * The name of the probe task for this run.
50
+ *
51
+ * @param {number} now
52
+ * @param {number|string} pid
53
+ * @returns {string}
54
+ */
55
+ export function probeTaskName(now = Date.now(), pid = 0) {
56
+ const stamp = Math.trunc(Number(now) || 0).toString(36);
57
+ const who = String(pid === 0 ? '0' : pid).replace(/[^A-Za-z0-9]/g, '');
58
+ return `${PROBE_TASK_PREFIX}-${who}-${stamp}`;
59
+ }
60
+
61
+ /**
62
+ * The trigger of the probe: two minutes from now, in the formats schtasks
63
+ * wants and in the format the XML reports.
64
+ *
65
+ * @param {number} now
66
+ * @returns {{ sd: string, st: string, iso: string }}
67
+ */
68
+ export function probeTrigger(now = Date.now()) {
69
+ const date = new Date(Math.trunc(Number(now) || 0) + 2 * 60 * 1000);
70
+ const pad = (value) => String(value).padStart(2, '0');
71
+ const yyyy = date.getFullYear();
72
+ const mm = pad(date.getMonth() + 1);
73
+ const dd = pad(date.getDate());
74
+ const hh = pad(date.getHours());
75
+ const mi = pad(date.getMinutes());
76
+ return { sd: `${yyyy}/${mm}/${dd}`, st: `${hh}:${mi}`, iso: `${yyyy}-${mm}-${dd}T${hh}:${mi}` };
77
+ }
78
+
79
+ /**
80
+ * The default executor: run a command and report what it said.
81
+ *
82
+ * @param {string} file
83
+ * @param {string[]} args
84
+ * @returns {{ ok: boolean, stdout: string, error: string|null }}
85
+ */
86
+ function defaultExec(file, args) {
87
+ try {
88
+ const stdout = execFileSync(file, args, { encoding: 'utf8', timeout: 20000, windowsHide: true });
89
+ return { ok: true, stdout: typeof stdout === 'string' ? stdout : '', error: null };
90
+ } catch (error) {
91
+ return {
92
+ ok: false,
93
+ stdout: error && typeof error.stdout === 'string' ? error.stdout : '',
94
+ error: error && error.message ? error.message : String(error),
95
+ };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The trigger of the task, read out of its XML.
101
+ *
102
+ * @param {string} xml
103
+ * @returns {string} the ISO start boundary, or an empty string
104
+ */
105
+ export function readStartBoundary(xml) {
106
+ const text = typeof xml === 'string' ? xml : '';
107
+ const match = /<StartBoundary>([^<]+)<\/StartBoundary>/i.exec(text);
108
+ return match ? match[1].trim() : '';
109
+ }
110
+
111
+ /**
112
+ * The trigger of the task, read out of its XML, compared with what we asked
113
+ * for (the XML writes seconds, we asked without them).
114
+ *
115
+ * @param {string} xml
116
+ * @param {string} expectedIso
117
+ * @returns {boolean}
118
+ */
119
+ export function triggerMatches(xml, expectedIso) {
120
+ const boundary = readStartBoundary(xml);
121
+ if (boundary === '') return false;
122
+ return boundary.slice(0, 16) === String(expectedIso).slice(0, 16);
123
+ }
124
+
125
+ /**
126
+ * Would the task fire?
127
+ *
128
+ * The XML does NOT always carry an `<Enabled>` element: a task without one is
129
+ * enabled (that is the default), and only an explicit `false` disables it.
130
+ * (A real run on a Chinese Windows answered with a `<TimeTrigger>` that has
131
+ * no `<Enabled>` at all — a check that demanded `<Enabled>true</Enabled>` would
132
+ * have called a perfectly working machine "blocked".)
133
+ *
134
+ * @param {string} xml
135
+ * @returns {boolean}
136
+ */
137
+ export function taskEnabled(xml) {
138
+ const text = typeof xml === 'string' ? xml : '';
139
+ return !/<Enabled>false<\/Enabled>/i.test(text);
140
+ }
141
+
142
+ /**
143
+ * Does the task carry an action (is it a real one-off task)?
144
+ *
145
+ * @param {string} xml
146
+ * @returns {boolean}
147
+ */
148
+ export function taskHasAction(xml) {
149
+ const text = typeof xml === 'string' ? xml : '';
150
+ return /<Command>/i.test(text);
151
+ }
152
+
153
+ /**
154
+ * THE PROBE.
155
+ *
156
+ * @param {{
157
+ * platform?: string,
158
+ * exec?: (file: string, args: string[]) => { ok: boolean, stdout: string, error: string|null },
159
+ * now?: number,
160
+ * taskName?: string,
161
+ * pid?: number|string,
162
+ * }} [options]
163
+ * @returns {{ scheduler: string, checkedAt: number, evidence: object }}
164
+ */
165
+ export function probeSchedulerCapability({
166
+ platform = process.platform,
167
+ exec = defaultExec,
168
+ now = Date.now(),
169
+ taskName = probeTaskName(now, process.pid),
170
+ pid = process.pid,
171
+ } = {}) {
172
+ const checkedAt = Math.trunc(Number(now) || Date.now());
173
+ const evidence = { platform, taskName, steps: [] };
174
+
175
+ // Only Windows uses a one-off scheduled task. Everywhere else the question
176
+ // does not apply (the advice for those platforms is elsewhere).
177
+ if (platform !== 'win32') {
178
+ return {
179
+ scheduler: SCHEDULER_OK,
180
+ checkedAt,
181
+ evidence: {
182
+ ...evidence,
183
+ applicable: false,
184
+ note: 'not applicable: this platform does not use a one-off scheduled task',
185
+ },
186
+ };
187
+ }
188
+
189
+ const trigger = probeTrigger(checkedAt);
190
+ const step = (kind, ok, detail) => {
191
+ evidence.steps.push({ kind, ok, detail: detail === undefined ? null : detail });
192
+ return ok;
193
+ };
194
+
195
+ // (0) Idempotence: a task of an earlier run (or of another instance) must
196
+ // not be in the way.
197
+ const preClean = exec('schtasks', ['/Delete', '/TN', taskName, '/F']);
198
+ step('pre-clean', true, preClean.ok ? 'deleted a leftover probe task' : 'no leftover probe task');
199
+
200
+ try {
201
+ // (1) Register the probe task: it fires in two minutes and does nothing.
202
+ const created = exec('schtasks', [
203
+ '/Create', '/TN', taskName, '/SC', 'ONCE', '/SD', trigger.sd, '/ST', trigger.st,
204
+ '/TR', 'cmd /c exit 0', '/F',
205
+ ]);
206
+ if (!step('create', created.ok, created.ok ? 'the task was registered' : created.error)) {
207
+ return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
208
+ }
209
+
210
+ // (2) Read it back: the trigger must be the one we asked for, and the
211
+ // task must be enabled (that is what "Ready" means in the XML).
212
+ const query = exec('schtasks', ['/Query', '/TN', taskName, '/XML']);
213
+ if (!step('verify', query.ok, query.ok ? 'the task was read back' : query.error)) {
214
+ return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
215
+ }
216
+ if (!step('trigger', triggerMatches(query.stdout, trigger.iso), 'expected ' + trigger.iso + ', got ' + readStartBoundary(query.stdout))) {
217
+ return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
218
+ }
219
+ if (!step('enabled', taskEnabled(query.stdout), 'the task would fire')) {
220
+ return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
221
+ }
222
+ if (!step('action', taskHasAction(query.stdout), 'the task carries an action')) {
223
+ return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
224
+ }
225
+ } catch (error) {
226
+ step('error', false, error && error.message ? error.message : String(error));
227
+ return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
228
+ } finally {
229
+ // (3) Delete it — ALWAYS, also when a step above failed. A registration
230
+ // that was refused can still have left a task behind (the command may
231
+ // have created it and then failed), so the cleanup is not conditional.
232
+ const removed = exec('schtasks', ['/Delete', '/TN', taskName, '/F']);
233
+ step('delete', removed.ok, removed.ok ? 'the probe task was deleted' : removed.error);
234
+ }
235
+
236
+ // (4) Read it back once more: it must be GONE. A task that survives the
237
+ // probe is a task the user would find in his task scheduler.
238
+ const after = exec('schtasks', ['/Query', '/TN', taskName, '/XML']);
239
+ const gone = !after.ok;
240
+ step('gone', gone, gone ? 'the task is gone' : 'the task still exists: ' + after.error);
241
+ if (!gone) {
242
+ return { scheduler: SCHEDULER_BLOCKED, checkedAt, evidence };
243
+ }
244
+
245
+ return { scheduler: SCHEDULER_OK, checkedAt, evidence };
246
+ }
247
+
248
+ /**
249
+ * Must the user be warned now?
250
+ *
251
+ * The warning is for ONE moment: the transition into "blocked". A machine
252
+ * that was blocked and stays blocked does not nag, and a user who has seen it
253
+ * is not asked again.
254
+ *
255
+ * @param {object|null} previous the capabilities before the check
256
+ * @param {object|null} current the capabilities after the check
257
+ * @returns {boolean}
258
+ */
259
+ export function shouldWarnScheduler(previous, current) {
260
+ if (!current || typeof current !== 'object') return false;
261
+ if (current.scheduler !== SCHEDULER_BLOCKED) return false;
262
+ if (current.warnedAt) return false;
263
+ const before = previous && typeof previous === 'object' ? previous.scheduler : SCHEDULER_UNKNOWN;
264
+ // Only the TRANSITION warns: from ok or unknown into blocked.
265
+ return before !== SCHEDULER_BLOCKED;
266
+ }
267
+
268
+ /**
269
+ * The capabilities with the warning marked as seen.
270
+ *
271
+ * @param {object} capabilities
272
+ * @param {number} [now]
273
+ * @returns {object}
274
+ */
275
+ export function markSchedulerWarned(capabilities, now = Date.now()) {
276
+ const record = capabilities && typeof capabilities === 'object' ? capabilities : {};
277
+ if (record.warnedAt) return record;
278
+ return { ...record, warnedAt: Math.trunc(Number(now) || Date.now()) };
279
+ }
280
+
281
+ /**
282
+ * A capabilities record that says nothing yet.
283
+ *
284
+ * @returns {{ scheduler: string, checkedAt: number|null, evidence: object|null, warnedAt: number|null }}
285
+ */
286
+ export function emptyCapabilities() {
287
+ return { scheduler: SCHEDULER_UNKNOWN, checkedAt: null, evidence: null, warnedAt: null };
288
+ }
289
+
290
+ /**
291
+ * Is the capabilities record readable?
292
+ *
293
+ * @param {unknown} raw
294
+ * @returns {boolean}
295
+ */
296
+ export function isCapabilities(raw) {
297
+ if (!raw || typeof raw !== 'object') return false;
298
+ const scheduler = raw.scheduler;
299
+ return scheduler === SCHEDULER_OK || scheduler === SCHEDULER_BLOCKED || scheduler === SCHEDULER_UNKNOWN;
300
+ }
package/lib/sleep.js CHANGED
@@ -30,11 +30,17 @@
30
30
  import { spawn as spawnProcess } from 'node:child_process';
31
31
  import fs from 'node:fs';
32
32
 
33
- /** The Windows shutdown binary as it is reachable from WSL. */
34
- export const WSL_SHUTDOWN_EXE = '/mnt/c/Windows/System32/shutdown.exe';
33
+ import { blockerTaskKeys, countBlockerTasks } from './blockers.js';
34
+ import { detectHostKind, detectWslEvidence, HOST_KINDS, WSL_PROC_VERSION, WSL_SHUTDOWN_EXE } from './host-kind.js';
35
35
 
36
- /** The file that tells a WSL kernel from a real Linux kernel. */
37
- export const WSL_PROC_VERSION = '/proc/version';
36
+ /**
37
+ * The Windows shutdown binary as it is reachable from WSL.
38
+ *
39
+ * The detection itself lives in `host-kind.js` (the shutdown and the autostart
40
+ * halves of the plugin must agree on what machine this is); it is re-exported
41
+ * here so the old import path keeps working.
42
+ */
43
+ export { WSL_SHUTDOWN_EXE, WSL_PROC_VERSION, detectWslEvidence };
38
44
 
39
45
  /** How many seconds the countdown lasts by default. */
40
46
  export const DEFAULT_COUNTDOWN_SECONDS = 20;
@@ -119,41 +125,6 @@ export function snapCountdownChoice(value) {
119
125
  return best;
120
126
  }
121
127
 
122
- /**
123
- * The evidence that this Linux is really a WSL distribution.
124
- *
125
- * Any ONE of them is enough:
126
- * 1. `WSL_DISTRO_NAME` is set (WSL exports it into every process),
127
- * 2. `WSL_INTEROP` is set (WSL exports it into every process),
128
- * 3. `/proc/version` mentions "microsoft" (the WSL kernel says so),
129
- * 4. the Windows shutdown binary is reachable under `/mnt/c`.
130
- *
131
- * @param {object} env
132
- * @param {(file: string) => string} readFile
133
- * @param {(file: string) => boolean} exists
134
- * @returns {string|null} the name of the evidence, or null
135
- */
136
- export function detectWslEvidence(env, readFile, exists) {
137
- const environ = env && typeof env === 'object' ? env : {};
138
- if (nonEmptyString(environ.WSL_DISTRO_NAME) !== '') return 'WSL_DISTRO_NAME';
139
- if (nonEmptyString(environ.WSL_INTEROP) !== '') return 'WSL_INTEROP';
140
- let version = '';
141
- try {
142
- version = String(readFile(WSL_PROC_VERSION) || '');
143
- } catch {
144
- version = '';
145
- }
146
- if (version.toLowerCase().includes('microsoft')) return WSL_PROC_VERSION;
147
- let found = false;
148
- try {
149
- found = exists(WSL_SHUTDOWN_EXE) === true;
150
- } catch {
151
- found = false;
152
- }
153
- if (found) return WSL_SHUTDOWN_EXE;
154
- return null;
155
- }
156
-
157
128
  /**
158
129
  * Detect the host and build the command that switches the physical machine off.
159
130
  *
@@ -203,12 +174,12 @@ export function detectShutdownPlan({
203
174
  }
204
175
 
205
176
  if (plat === 'linux') {
206
- const evidence = detectWslEvidence(env, readFileSafe, existsSafe);
207
- if (evidence !== null) {
177
+ const host = detectHostKind({ platform: plat, env, readFile: readFileSafe, exists: existsSafe });
178
+ if (host.kind === HOST_KINDS.WSL) {
208
179
  // The mounted Windows binary is only a HINT: a real Linux with a
209
180
  // manually mounted C: drive looks exactly the same. When that is the
210
181
  // only evidence, the plan says so instead of pretending to be sure.
211
- const suspect = evidence === WSL_SHUTDOWN_EXE;
182
+ const suspect = host.suspect === true;
212
183
  return {
213
184
  kind: 'wsl',
214
185
  label: suspect
@@ -216,7 +187,7 @@ export function detectShutdownPlan({
216
187
  : 'WSL 里的 DSH → 关掉整台 Windows',
217
188
  source: suspect
218
189
  ? `${WSL_SHUTDOWN_EXE}(疑似 WSL,仅凭 /mnt/c 探测)`
219
- : evidence,
190
+ : host.evidence,
220
191
  // The Windows binary, reached through the mounted C: drive: this shuts
221
192
  // the whole machine down, not the WSL distribution.
222
193
  command: WSL_SHUTDOWN_EXE,
@@ -550,9 +521,14 @@ export function createSleepController({
550
521
  * that created the subagent). A raw session id never reaches the
551
522
  * interface: an unknown name is a generic one.
552
523
  *
524
+ * `parentSessionId` is kept: it is the exact identity of the session that
525
+ * created a subagent, so the card can count a parent and its subagents as
526
+ * ONE task (the rule of `lib/blockers.js`). It is null when the Host did
527
+ * not report one — the title is then the fallback.
528
+ *
553
529
  * @param {any} entry the entry the Host produced
554
530
  * @param {string} sessionId
555
- * @returns {{ sessionId: string, kind: string, label: string|null, name: string, parentTitle: string|null, title: string }}
531
+ * @returns {{ sessionId: string, kind: string, label: string|null, name: string, parentTitle: string|null, parentSessionId: string|null, title: string }}
556
532
  */
557
533
  const blockerEntry = (entry, sessionId) => {
558
534
  const kind = entry.kind === 'subagent' ? 'subagent' : 'session';
@@ -569,12 +545,17 @@ export function createSleepController({
569
545
  // writes the generic line when the name is empty.
570
546
  if (name === '') name = label;
571
547
  const parentTitle = typeof entry.parentTitle === 'string' ? entry.parentTitle.trim() : '';
548
+ const parentSessionId = typeof entry.parentSessionId === 'string' ? entry.parentSessionId.trim() : '';
572
549
  return {
573
550
  sessionId,
574
551
  kind,
575
552
  label: label !== '' ? label : null,
576
553
  name,
577
554
  parentTitle: parentTitle !== '' ? parentTitle : null,
555
+ // The identity of the session that created the subagent: it is what lets
556
+ // the card count a parent and its subagents as ONE task. It is only
557
+ // there when the Host knows it — a plain session keeps its old shape.
558
+ ...(parentSessionId !== '' ? { parentSessionId } : {}),
578
559
  // `title` is the old name of `label`: it stays, so an older client
579
560
  // keeps working.
580
561
  title: label !== '' ? label : name,
@@ -686,18 +667,31 @@ export function createSleepController({
686
667
  * bug, so a list with a nameless entry in it is left out entirely: the
687
668
  * caller then writes the line without the parenthetical.
688
669
  *
670
+ * The names are the names of the TASKS: a session and its own subagents are
671
+ * ONE task, and it is named after the session itself (after its subagent
672
+ * when the session is idle and only the subagent works). So the list has as
673
+ * many names as the count has tasks.
674
+ *
689
675
  * @param {any[]} list
690
676
  * @returns {string} the joined names, or '' when the list cannot be named
691
677
  */
692
678
  const names = (list) => {
693
- const out = [];
694
- for (const entry of Array.isArray(list) ? list : []) {
679
+ const entries = Array.isArray(list) ? list : [];
680
+ const keys = blockerTaskKeys(entries);
681
+ const byTask = new Map();
682
+ for (let index = 0; index < entries.length; index += 1) {
683
+ const entry = entries[index];
695
684
  const label = entry && typeof entry.label === 'string' ? entry.label.trim() : '';
696
685
  const name = entry && typeof entry.name === 'string' ? entry.name.trim() : '';
697
686
  if (label === '' || name === '') return '';
698
- out.push(name);
687
+ const key = keys[index] !== '' ? keys[index] : ('entry:' + index);
688
+ const isSubagent = entry.kind === 'subagent';
689
+ const known = byTask.get(key);
690
+ if (!known || (known.isSubagent && !isSubagent)) {
691
+ byTask.set(key, { name, isSubagent });
692
+ }
699
693
  }
700
- return out.join('、');
694
+ return Array.from(byTask.values()).map((item) => item.name).join('、');
701
695
  };
702
696
 
703
697
  /**
@@ -707,12 +701,15 @@ export function createSleepController({
707
701
  * - one blocker with a title → the singular form ("等「甲」收工");
708
702
  * - otherwise → the plain count, without a parenthetical.
709
703
  *
704
+ * The count is about TASKS: a session and its own subagents are ONE task
705
+ * (the rule of `lib/blockers.js`), the same number the left sidebar shows.
706
+ *
710
707
  * @param {any[]} list the blockers
711
708
  * @param {{ immediate?: boolean }} options `immediate` = no countdown at all
712
709
  * @returns {string}
713
710
  */
714
711
  const waitingFor = (list, { immediate = false } = {}) => {
715
- const count = Array.isArray(list) ? list.length : 0;
712
+ const count = countBlockerTasks(list);
716
713
  const tail = immediate ? '收工后立即关机。' : '收工后开始倒数。';
717
714
  const named = names(list);
718
715
  if (count === 1 && named !== '') return `等「${named}」收工,${tail}`;