@dr-co/dsh-node-client 0.0.0-stage → 0.0.60

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/lib/runner.mjs ADDED
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Соединение с одним сервером: заявка, объявление, цикл команд.
3
+ *
4
+ * Состояние держится per URL (план клиента): курсор, epoch, токен. Реконнект —
5
+ * экспоненциальная пауза, но без потери уже полученного токена.
6
+ *
7
+ * @module dsh-node-client/runner
8
+ */
9
+ import * as api from './api.mjs'
10
+ import { runBash } from './bash.mjs'
11
+ import { realpathSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { normalizeUrl } from './config.mjs'
14
+ import { home, openStore } from './store.mjs'
15
+ import { openPresence } from './presence.mjs'
16
+ import { classify, DSH_MODES, MODES, narrower, profileArgs, probe } from './sandbox.mjs'
17
+ import { createSessionStore } from './sessions.mjs'
18
+ import { askLocally } from './approver.mjs'
19
+ import { apisOf, loadPlugins, resolvePlugin } from './plugins/index.mjs'
20
+ import { t } from './i18n.mjs'
21
+
22
+ /**
23
+ * Пауза с уважением к отмене.
24
+ *
25
+ * Слушатель снимается в finally: демон живёт сутками и делает паузу раз в
26
+ * несколько секунд. Иначе один AbortSignal копит тысячи слушателей — это и
27
+ * предупреждение MaxListenersExceeded, и растущая память.
28
+ */
29
+ /** Код возврата из результата команды: клиент кладёт его последним блоком. */
30
+ export function codeOf (outcome) {
31
+ const blocks = Array.isArray(outcome?.results) ? outcome.results : []
32
+ for (let i = blocks.length - 1; i >= 0; i--) {
33
+ const text = blocks[i]?.text
34
+ if (typeof text !== 'string' || !text.startsWith('{')) continue
35
+ try {
36
+ const parsed = JSON.parse(text)
37
+ if (typeof parsed?.code === 'number') return parsed.code
38
+ } catch {}
39
+ }
40
+ return null
41
+ }
42
+
43
+ /**
44
+ * Исполнить пачку команд.
45
+ *
46
+ * `session.chdir` применяется первым и по порядку: он меняет каталог сессии, и
47
+ * все остальные команды пачки должны стартовать уже в новом. Остальное идёт
48
+ * параллельно, но с потолком: один ход модели может принести десяток вызовов, и
49
+ * поднимать десяток процессов разом — плохая идея.
50
+ *
51
+ * @param {object[]} commands команды из выдачи
52
+ * @param {object} options
53
+ * @param {(command: object) => Promise<object>} options.run исполнить одну
54
+ * @param {number} [options.concurrency]
55
+ */
56
+ export async function runConcurrently (commands, run, { concurrency = 5 } = {}) {
57
+ const queue = [...commands]
58
+ const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
59
+ for (;;) {
60
+ const command = queue.shift()
61
+ if (!command) return
62
+ try {
63
+ await run(command)
64
+ } catch {
65
+ // одна упавшая команда не должна утащить за собой всю пачку: иначе
66
+ // остальные останутся `processing` и сервер сочтёт их потерянными
67
+ }
68
+ }
69
+ })
70
+ await Promise.all(workers)
71
+ }
72
+
73
+ export const sleep = (ms, signal) => new Promise((resolve, reject) => {
74
+ const timer = setTimeout(() => {
75
+ signal?.removeEventListener?.('abort', onAbort)
76
+ resolve()
77
+ }, ms)
78
+ const onAbort = () => {
79
+ clearTimeout(timer)
80
+ signal?.removeEventListener?.('abort', onAbort)
81
+ reject(new Error('aborted'))
82
+ }
83
+ signal?.addEventListener?.('abort', onAbort, { once: true })
84
+ })
85
+
86
+ /**
87
+ * Подключение по ссылке с кодом: подтверждать нечего, код всё связывает сам.
88
+ * @returns {Promise<object>} запись сервера с токеном
89
+ */
90
+ /**
91
+ * Сохранить рантайм сервера: курсор и прочее — в state.sqlite, токен — в
92
+ * credentials.yaml. В config.json не пишем ничего: там только настройки.
93
+ */
94
+ function persist (store, url, state) {
95
+ store.writeState(url, {
96
+ cursor: state.cursor ?? null,
97
+ epoch: state.epoch ?? null,
98
+ boot: state.boot ?? null,
99
+ node: state.node ?? null,
100
+ alias: state.alias ?? null,
101
+ apiIds: state.apiIds ?? {},
102
+ })
103
+ if ('token' in state) store.setToken(url, state.token ?? null)
104
+ }
105
+
106
+ /**
107
+ * Песочница для одной команды.
108
+ *
109
+ * Режим приходит от сервера (`sandbox.mode`), но владелец узла ограничивает его
110
+ * сверху через `config.max`: эффективный режим — более узкий из двух. Каталог
111
+ * сессии становится рабочим каталогом команды, а в режиме `rw` — единственным
112
+ * местом, куда вообще можно писать (плюс `/tmp`).
113
+ *
114
+ * @param {object} config настройки узла
115
+ * @param {object} command команда из очереди
116
+ * @param {object} sessions хранилище каталогов сессий
117
+ * @returns {object|null} описание песочницы или null, если команда вне песочницы
118
+ */
119
+ /** Правила узла для конкретного сервера: policy[url] поверх общих настроек. */
120
+ export function policyFor (config, url) {
121
+ const common = {
122
+ max: config.access?.max ?? 'rw',
123
+ roots: config.roots ?? null,
124
+ workdir: config.workdir ?? null,
125
+ maxConcurrent: config.maxConcurrent ?? 5,
126
+ // режим, в котором команда стартует: `full` = не понижать (как было раньше)
127
+ base: config.access?.base ?? 'full',
128
+ approver: config.access?.approver ?? 'none',
129
+ approvalTtlSeconds: config.approvalTtlSeconds ?? 86400,
130
+ // `ask` — каналы (web/local). Это не то же, что «разрешено ли спрашивать»:
131
+ // булево для сервера считается из approver, см. askEnabled ниже.
132
+ ask: config.ask ?? null,
133
+ }
134
+ const own = url ? config.policy?.[url] ?? {} : {}
135
+ return { ...common, ...Object.fromEntries(Object.entries(own).filter(([, value]) => value !== undefined && value !== null)) }
136
+ }
137
+
138
+ /**
139
+ * Разрешено ли серверу спрашивать человека на этом узле.
140
+ *
141
+ * В протокол уходит именно булево (`allowEscalation`), а `approver` описывает
142
+ * канал: `local` — спрашиваем на машине, `server` — через DSH, `none` — никогда.
143
+ *
144
+ * @param {object} policy правила узла
145
+ * @returns {boolean}
146
+ */
147
+ export function askEnabled (policy) {
148
+ return policy?.approver === 'local' || policy?.approver === 'server'
149
+ }
150
+
151
+ /**
152
+ * Команда локального апрува из настроек узла.
153
+ *
154
+ * `ask.local.enabled: false` или пустой шаблон означают «спросить некого» — тогда
155
+ * плагин вернёт `no_local_approver`, и сервер не будет делать вид, что вопрос
156
+ * задан.
157
+ *
158
+ * @param {object} config настройки клиента
159
+ * @returns {string|null}
160
+ */
161
+ export function askLocalCommand (policy) {
162
+ const local = policy?.ask?.local
163
+ if (!local?.enabled) return null
164
+ const command = local.tool?.command
165
+ return typeof command === 'string' && command.trim() ? command.trim() : null
166
+ }
167
+
168
+ export function sandboxFor (config, command, sessions, url) {
169
+ const policy = policyFor(config, url)
170
+ const requested = MODES.includes(command.sandbox?.mode) ? command.sandbox.mode : policy.max
171
+ // потолок узла режет запрошенное всегда
172
+ const want = narrower(requested, policy.max)
173
+ // `base` — это «в каком режиме стартуем», но подтверждённую человеком команду
174
+ // понижать нельзя: иначе апрув ничего не значит
175
+ const approved = Boolean(command.approved)
176
+ const mode = approved || policy.approver === 'none' ? want : narrower(want, policy.base)
177
+ let cwd = null
178
+ if (command.session && sessions) {
179
+ try {
180
+ cwd = sessions.cwd(command.session, { workdir: policy.workdir, roots: policy.roots })
181
+ } catch {
182
+ cwd = policy.workdir ?? null
183
+ }
184
+ } else {
185
+ cwd = policy.workdir ?? null
186
+ }
187
+ const workdir = policy.workdir ? canonicalWorkdir(policy.workdir) : null
188
+ const roots = policy.roots?.length ? policy.roots : (workdir ? [workdir] : ['/'])
189
+ return {
190
+ mode,
191
+ dshMode: DSH_MODES[mode],
192
+ order: config.access?.order ?? [],
193
+ args: profileArgs({ mode, workdir, writable: roots, cwd }),
194
+ cwd,
195
+ probe,
196
+ classify,
197
+ }
198
+ }
199
+
200
+ function canonicalWorkdir (workdir) {
201
+ try {
202
+ return realpathSync(workdir)
203
+ } catch {
204
+ return workdir
205
+ }
206
+ }
207
+
208
+ export async function pairServer ({ url, config, print }) {
209
+ const { base, code } = api.parseConnectUrl(url)
210
+ if (!code) throw new Error(t('connect.noCode'))
211
+ const store = openStore()
212
+ const state = { ...store.readState(base), token: store.token(base) }
213
+ const response = await api.pair(base, { code, uuid: config.id, name: config.name, aliases: config.aliases })
214
+ state.token = response.body.token
215
+ state.cursor = 0
216
+ state.epoch = null
217
+ state.alias = response.body.alias ?? null
218
+ state.node = response.body.node ?? null
219
+ delete state.announced
220
+ persist(store, base, state)
221
+ print(t('connect.paired', { node: response.body.node, alias: response.body.alias }))
222
+ return state
223
+ }
224
+
225
+ /**
226
+ * Первичная регистрация: заявка и ожидание одобрения человеком.
227
+ * @param {object} options
228
+ * @param {string} options.url
229
+ * @param {object} options.config
230
+ * @param {(line: string) => void} options.print
231
+ * @param {AbortSignal} [options.signal]
232
+ * @returns {Promise<object>} запись сервера с токеном
233
+ */
234
+ export async function enrollServer ({ url, config, print, signal, forever = false }) {
235
+ const base = normalizeUrl(url)
236
+ const store = openStore()
237
+ const state = { ...store.readState(base), token: store.token(base) }
238
+ const uuid = config.id
239
+ let announced = false
240
+ let attempts = 0
241
+ let repost = 0
242
+ let retryMs = 5000
243
+ for (;;) {
244
+ if (signal?.aborted) throw new Error('aborted')
245
+ // заявку повторяем, если она старая: у неё свой TTL в админке
246
+ if (!announced || repost >= 30) {
247
+ try {
248
+ await api.enroll(base, { uuid, name: config.name, aliases: config.aliases, nonce: store.clientNonce() })
249
+ if (!announced) {
250
+ print(t('connect.requested', { name: config.name, short: uuid.slice(0, 8) }))
251
+ print(t('connect.waiting', { base }))
252
+ }
253
+ announced = true
254
+ repost = 0
255
+ } catch (error) {
256
+ if (error.code === 'unauthorized') throw error
257
+ announced = false
258
+ }
259
+ }
260
+ repost += 1
261
+ try {
262
+ const response = await api.enrollStatus(base, uuid, store.clientNonce())
263
+ if (response.status === 200 && response.body?.token) {
264
+ state.token = response.body.token
265
+ state.cursor = 0
266
+ state.epoch = null
267
+ state.alias = response.body.alias ?? null
268
+ state.node = response.body.node ?? null
269
+ delete state.announced
270
+ persist(store, base, state)
271
+ print(t('connect.approved', { node: response.body.node ?? '?', alias: response.body.alias ?? '—' }))
272
+ return state
273
+ }
274
+ if (response.body?.status === 'rejected') throw new Error(t('connect.rejected'))
275
+ if (response.body?.status === 'handed') throw new Error(t('connect.handed'))
276
+ } catch (error) {
277
+ if (error.code === 'unauthorized') throw error
278
+ if (/отклон|rejected/.test(error.message)) throw error
279
+ // 409 «уже выдан» — сервер не знает, что нам нужен новый токен: ждём
280
+ // одобрения в админке и напоминаем о себе всё реже, а не каждые 5 секунд
281
+ print(t('run.retry', { base, message: error.message, seconds: Math.round(retryMs / 1000) }))
282
+ await sleep(retryMs, signal)
283
+ retryMs = Math.min(Math.round(retryMs * 2), 60000)
284
+ continue
285
+ }
286
+ attempts += 1
287
+ if (!forever && attempts > 360) throw new Error(t('connect.timeout'))
288
+ await sleep(2000, signal)
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Цикл одного сервера. Возвращается только по отмене или фатальной ошибке.
294
+ */
295
+ export async function runServer ({ url, config, print, signal, shell, pollIdleMs = 5000, plugins = null }) {
296
+ const base = normalizeUrl(url)
297
+ const store = openStore()
298
+ const state = { ...store.readState(base), token: store.token(base) }
299
+ const sessions = createSessionStore(store)
300
+ const loaded = plugins ?? await loadPlugins({ dir: join(home(), 'plugins'), log: (level, message) => print(message) })
301
+ /**
302
+ * Активные диалоги апрува. Живут отдельно от пачки: их не ждут, но при смене
303
+ * эпохи (сервер перезапустился, очередь пуста) их надо закрыть — иначе человек
304
+ * увидит вопрос, на который уже некому ответить.
305
+ */
306
+ const pendingApprovals = new Map()
307
+ let reloadRequested = false
308
+ // SIGHUP читает конфиг заново и будит цикл: без пробуждения reload оставался
309
+ // бы «принятым», но не применённым — цикл спит до кадра от сервера
310
+ const onHup = () => {
311
+ reloadRequested = true
312
+ wake?.()
313
+ }
314
+ process.on('SIGHUP', onHup)
315
+ let backoff = 1000
316
+ // SIGHUP меняет config на месте и ставит отметку: по ней понимаем, что пора
317
+ // переобъявить API и пересоздать сокет с новыми правилами
318
+ let reloadToken = config.reloadToken ?? null
319
+ // `announced` живёт в памяти процесса, а не в конфиге: после рестарта клиента
320
+ // объявляться надо снова — сервер мог перезапуститься, контракт мог измениться.
321
+ let announced = false
322
+ let presence = null
323
+ let wake = null
324
+ /** Разбудить цикл: пришёл кадр `state`, опрос ждать не надо. */
325
+ const nudge = () => {
326
+ const resolve = wake
327
+ wake = null
328
+ resolve?.()
329
+ }
330
+ /**
331
+ * Пауза, которую может прервать уведомление с сервера.
332
+ *
333
+ * Таймер НЕ unref: idle-ожидание — единственное, что держит процесс живым,
334
+ * когда сокета нет (например, присутствие не поднялось). С unref демон
335
+ * молча выходил с кодом 0 после первой же паузы.
336
+ */
337
+ /**
338
+ * Ждать кадр без таймера.
339
+ *
340
+ * Единственный способ проснуться — уведомление от сокета или остановка
341
+ * демона. Если сокет умрёт молча, клиент так и будет спать: это цена
342
+ * событийной схемы, и она приемлема, пока страховочный опрос выключен.
343
+ */
344
+ const waitForWake = () => new Promise((resolve) => {
345
+ wake = resolve
346
+ signal?.addEventListener?.('abort', () => {
347
+ wake = null
348
+ resolve()
349
+ }, { once: true })
350
+ })
351
+
352
+ const pause = (ms) => new Promise((resolve) => {
353
+ wake = resolve
354
+ const timer = setTimeout(() => {
355
+ wake = null
356
+ resolve()
357
+ }, ms)
358
+ signal?.addEventListener?.('abort', () => {
359
+ clearTimeout(timer)
360
+ wake = null
361
+ resolve()
362
+ }, { once: true })
363
+ })
364
+
365
+ while (!signal?.aborted) {
366
+ try {
367
+ if (reloadRequested) {
368
+ reloadRequested = false
369
+ print(t('daemon.reload'))
370
+ const fresh = openStore().settings ?? {}
371
+ for (const key of Object.keys(config)) delete config[key]
372
+ Object.assign(config, fresh)
373
+ // новые правила должны доехать до сервера: переобъявляем и пересоздаём
374
+ // сокет, иначе изменившиеся base/approver/ping останутся на бумаге
375
+ announced = false
376
+ presence?.close()
377
+ presence = null
378
+ }
379
+ if ((config.reloadToken ?? null) !== reloadToken) {
380
+ reloadToken = config.reloadToken ?? null
381
+ // политика, права и каналы вопросов могли измениться: объявляем заново,
382
+ // сокет пересоздаём — иначе новые pingInterval/approver не подхватятся
383
+ announced = false
384
+ presence?.close()
385
+ presence = null
386
+ }
387
+ // демон без токена сам идёт в заявку и ждёт одобрения: тогда из админки
388
+ // связь появляется сама, без перезапуска клиента
389
+ if (!state.token) {
390
+ await enrollServer({ url: base, config, print, signal, forever: true })
391
+ // заявку одобрили в другом вызове: перечитываем состояние, иначе
392
+ // дальше пойдём со старым пустым токеном и получим 401
393
+ Object.assign(state, store.readState(base), { token: store.token(base) })
394
+ }
395
+ if (!announced) {
396
+ const response = await api.announce(base, state.token, apisOf(loaded), {
397
+ workdir: config.workdir ?? null,
398
+ access: policyFor(config, base).max,
399
+ allowEscalation: askEnabled(policyFor(config, base)),
400
+ uptimeSeconds: process.uptime(),
401
+ approver: policyFor(config, base).approver,
402
+ startMode: policyFor(config, base).base,
403
+ // прикладной пинг: 0 — выключен, factor — потолок растяжения
404
+ pingInterval: config.pingInterval ?? 0,
405
+ maxPingIntervalFactor: config.maxPingIntervalFactor ?? 0,
406
+ })
407
+ announced = true
408
+ state.epoch = response.body.epoch ?? null
409
+ state.apiIds = {}
410
+ for (const entry of response.body.apis ?? []) state.apiIds[entry.name] = entry.id
411
+ persist(store, base, state)
412
+ const names = (response.body.apis ?? []).map((entry) => `${entry.name}#${entry.id}`).join(', ')
413
+ print(t('run.announced', { base, apis: names, epoch: state.epoch }))
414
+ for (const warning of response.body.warnings ?? []) print(t('run.warning', { warning }))
415
+ }
416
+
417
+ if (!presence) presence = openPresence({
418
+ workdir: policyFor(config, base).workdir ?? config.workdir ?? null,
419
+ access: policyFor(config, base).max,
420
+ allowEscalation: askEnabled(policyFor(config, base)),
421
+ // время старта процесса: сервер посчитает аптайм по своему clock
422
+ uptimeSeconds: process.uptime(),
423
+ approver: policyFor(config, base).approver,
424
+ // `base` — стартовый режим: сервер покажет его в панели рядом с потолком.
425
+ // В presence параметр назван startMode, чтобы не спорить с именем url-базы
426
+ startMode: policyFor(config, base).base,
427
+ base,
428
+ token: state.token,
429
+ epoch: state.epoch,
430
+ known: state.cursor ?? null,
431
+ log: (level, message) => print(`${base}: ${message}`),
432
+ onEvent: (event) => {
433
+ if (event.type === 'hello-ok') {
434
+ if (event.reconnect) {
435
+ print(t('run.reconnect', { base, from: state.epoch, to: event.epoch }))
436
+ state.epoch = event.epoch
437
+ state.cursor = 0
438
+ persist(store, base, state)
439
+ } else {
440
+ state.epoch = event.epoch ?? state.epoch
441
+ }
442
+ // сразу сообщаем курсор: сервер запомнит, что узел ждёт работы,
443
+ // и первая же команда придёт кадром, а не через период опроса
444
+ presence?.sendAlive(state.cursor ?? null)
445
+ nudge()
446
+ }
447
+ if (event.type === 'state') nudge()
448
+ if (event.type === 'closed') {
449
+ print(t('run.presenceClosed', { base, code: event.code }))
450
+ presence = null
451
+ }
452
+ },
453
+ })
454
+
455
+ // сверка поколений: несовпадение = реконнект, курсор в ноль (§4.7)
456
+ const current = await api.state(base, state.token)
457
+ if (state.epoch != null && current.body.epoch !== state.epoch) {
458
+ print(t('run.restarted', { base, from: state.epoch, to: current.body.epoch }))
459
+ state.epoch = current.body.epoch
460
+ state.cursor = null
461
+ // диалоги прошлой жизни: сервер их уже не помнит, ответить некому
462
+ for (const controller of pendingApprovals.values()) controller.abort()
463
+ pendingApprovals.clear()
464
+ persist(store, base, state)
465
+ }
466
+
467
+ /**
468
+ * Контекст для плагина: каталог сессии, корни из правил сервера и песочница.
469
+ * Считается на каждую команду — политика и каталог могут отличаться.
470
+ */
471
+ const pluginContext = (command) => {
472
+ const policy = policyFor(config, base)
473
+ return {
474
+ shell,
475
+ // у диалога апрува свой сигнал: его отменяют отдельно от всего цикла
476
+ signal: command.signal ?? signal,
477
+ workdir: policy.workdir ?? config.workdir,
478
+ roots: policy.roots?.length ? policy.roots : (policy.workdir ? [policy.workdir] : ['/']),
479
+ // `files` не должен получать «/» как разрешение на всё: пустой список
480
+ // означает «каталоги не настроены», и это отказ, а не свобода
481
+ fileRoots: policy.roots?.length ? policy.roots : (policy.workdir ? [policy.workdir] : []),
482
+ session: command.session ?? null,
483
+ sessions,
484
+ approver: {
485
+ command: askLocalCommand(policyFor(config, base)),
486
+ run: (options) => askLocally({ ...options, timeout: options.timeout ?? config.approvalTtlSeconds ?? 86400 }),
487
+ },
488
+ sandbox: sandboxFor(config, command, sessions, base),
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Исполнить одну команду и отчитаться. Вынесено, чтобы пачка могла идти
494
+ * параллельно, а отчёт по каждой команде оставался прежним.
495
+ */
496
+ const executeOne = async (command) => {
497
+ const ctx = pluginContext(command)
498
+ const plugin = resolvePlugin(loaded, command, state.apiIds ?? {})
499
+ const outcome = plugin
500
+ ? await plugin.handle(command, ctx)
501
+ : { ok: false, code: 'unknown_method', message: t('run.noPlugin', { method: command.method }) }
502
+ try {
503
+ await api.result(base, state.token, command.seq, outcome)
504
+ } catch (error) {
505
+ print(t('run.resultFailed', { seq: command.seq, message: error.message }))
506
+ }
507
+ // режим full ничего не ограничивает, поэтому оставляем след
508
+ if (plugin && ctx.sandbox?.mode === 'full') {
509
+ try {
510
+ store.writeAudit({
511
+ session: command.session ?? null,
512
+ mode: 'full',
513
+ command: typeof command.args?.command === 'string' ? command.args.command : JSON.stringify(command.args ?? {}),
514
+ code: codeOf(outcome),
515
+ at: Date.now(),
516
+ })
517
+ } catch (error) {
518
+ print(t('run.auditFailed', { message: error.message }))
519
+ }
520
+ }
521
+ const flag = outcome.ok ? t('run.ok') : t('run.error', { code: outcome.code ?? '' })
522
+ print(t('run.command', { base, seq: command.seq, method: command.method, flag }))
523
+ }
524
+
525
+ /**
526
+ * Пачка: сначала `session.chdir` по порядку (он меняет каталог сессии, и
527
+ * остальные команды должны стартовать уже в новом), затем остальное
528
+ * параллельно с потолком.
529
+ */
530
+ const runBatch = async (batch) => {
531
+ // `chdir` меняет каталог сессии, поэтому он первый и по порядку:
532
+ // остальные команды пачки должны стартовать уже в новом каталоге
533
+ for (const command of batch.filter((cmd) => cmd.method === 'chdir')) await executeOne(command)
534
+
535
+ // `approve` — это диалог с человеком, он может висеть сутки. Держать им
536
+ // слот пула нельзя: иначе пачка не закроется и опрос встанет
537
+ for (const command of batch.filter((cmd) => cmd.method === 'approve')) {
538
+ const controller = new AbortController()
539
+ pendingApprovals.set(command.seq, controller)
540
+ void executeOne({ ...command, signal: controller.signal })
541
+ .catch(() => {})
542
+ .finally(() => pendingApprovals.delete(command.seq))
543
+ }
544
+
545
+ const rest = batch.filter((cmd) => cmd.method !== 'chdir' && cmd.method !== 'approve')
546
+ // потолок одновременных процессов: один ход модели может принести
547
+ // десяток вызовов, и заваливать узел нагрузкой нельзя
548
+ await runConcurrently(rest, executeOne, { concurrency: policyFor(config, base).maxConcurrent ?? 5 })
549
+ }
550
+
551
+ const response = await api.commands(base, state.token, { limit: config.batch ?? 5 })
552
+ presence?.noteSeen?.()
553
+ const commands = response.body.commands ?? []
554
+ if (state.epoch !== response.body.epoch) {
555
+ state.epoch = response.body.epoch
556
+ state.cursor = null
557
+ }
558
+ // `known` — до какого номера я в курсе. Сервер отдаёт `state` (последний
559
+ // выданный номер), но опираться на него нельзя: между выдачами он растёт.
560
+ // Поэтому помним максимум из полученных команд — это ровно то, что
561
+ // спрашивается в `check_state`.
562
+ for (const command of commands) {
563
+ if (Number.isFinite(command.seq) && (state.cursor == null || command.seq > state.cursor)) state.cursor = command.seq
564
+ }
565
+ persist(store, base, state)
566
+ presence?.sendAlive(state.cursor ?? null)
567
+
568
+ if (!commands.length) {
569
+ // Периодического опроса нет вовсе: `pollIdleMs: 0` выключает его
570
+ // полностью, и тогда единственный повод проснуться — кадр от сервера
571
+ // или восстановление сокета. Ненулевое значение оставляет страховку.
572
+ const idlePoll = config.pollIdleMs ?? pollIdleMs
573
+ if (!idlePoll) {
574
+ await waitForWake()
575
+ continue
576
+ }
577
+ await pause(presence?.online?.() ? (config.pollFallbackMs ?? idlePoll) : idlePoll)
578
+ continue
579
+ }
580
+ backoff = 1000
581
+ presence?.noteCommands?.()
582
+ await runBatch(commands)
583
+ presence?.sendAlive(state.cursor ?? null)
584
+ } catch (error) {
585
+ presence?.close()
586
+ presence = null
587
+ if (signal?.aborted) return
588
+ if (error.code === 'unauthorized' || error.status === 401) {
589
+ print(t('run.tokenRejected', { base }))
590
+ state.token = null
591
+ announced = false
592
+ persist(store, base, state)
593
+ continue
594
+ }
595
+ print(t('run.retry', { base, message: error.message, seconds: Math.round(backoff / 1000) }))
596
+ await pause(backoff)
597
+ backoff = Math.min(backoff * 2, 30000)
598
+ }
599
+ }
600
+ }