@taskless-app/swarm 0.4.7 → 0.4.10

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 (3) hide show
  1. package/dist/cli.js +1243 -992
  2. package/dist/overmind.js +2744 -2334
  3. package/package.json +1 -4
package/dist/cli.js CHANGED
@@ -1,102 +1,101 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/cli.ts
4
- import { basename as basename4 } from "path";
5
- import { existsSync as existsSync9, readdirSync as readdirSync6, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
6
- import { createInterface } from "readline/promises";
7
- import { setTimeout as sleep2 } from "timers/promises";
8
-
9
- // src/db.ts
10
- import { createRequire } from "module";
11
- import { existsSync } from "fs";
12
- import { resolve as resolve2 } from "path";
13
-
14
- // src/config.ts
15
- import { homedir } from "os";
16
- import { basename, join, resolve } from "path";
17
- import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
18
11
 
19
12
  // ../swarm-core/src/types.ts
20
- var EVENT_KINDS = [
21
- "spawned",
22
- "step",
23
- "blocked",
24
- "answered",
25
- "pr_opened",
26
- "merged",
27
- "done",
28
- "failed",
29
- "stalled",
30
- "respawned",
31
- // дрон умер, рой авто-оживил (respawn/--continue) — счётчик respawns
32
- "paused",
33
- // дрон замер: overmind недоступен (heartbeat устарел) — сохранился и вышел (TASWM-41)
34
- "resumed",
35
- // новый overmind разбудил замерший прогон (--continue/fresh) (TASWM-41)
36
- "rescued",
37
- // worktree мёртвого дрона с несохранённой работой сохранён (не снесён) — забрать руками
38
- "conflict",
39
- // review-PR конфликтует с main — рой вливает main / поднял резолвера / разрешил (merge-main-in)
40
- "died",
41
- // работающий дрон умер (dead pid) — семпл для скользящего окна детекции шторма (TASWM-23)
42
- "storm_start",
43
- // рой вошёл в штормовой режим — Anthropic лежит, пауза (глобальное событие, run_id NULL) (TASWM-23)
44
- "storm_end",
45
- // отбой шторма — Anthropic вернулся, рой поднимается (глобальное событие, run_id NULL) (TASWM-23)
46
- "storm_dead",
47
- // дрон заморожен штормом (worktree сохранён, respawn не тронут) (TASWM-23)
48
- "storm_probe",
49
- // запись health-probe шторма: {probe, ext} — троттлинг 60с + счётчик успехов (глобальное, run_id NULL) (TASWM-23)
50
- "stopped",
51
- // прогон остановлен оператором (с причиной)
52
- "superseded",
53
- // прогон переигран на другой вариант
54
- "question",
55
- // вопрос дрона оператору (прод: SwarmEvent kind=question, TASWM-74)
56
- "answer",
57
- // ответ оператора на вопрос дрона (прод: SwarmEvent kind=answer)
58
- "ci_wake",
59
- // review-PR покраснел (gh pr checks) — рой разбудил дрона со сжатым summary фейла (TASWM-102)
60
- "ci_checked",
61
- // опрос `gh pr checks` без побудки (не fail/gh недоступен) — только для cooldown, не веха (TASWM-102)
62
- "anomaly",
63
- // встроенный надзор overmind засёк аномалию прогона — дубль нотификации в инбокс SwarmEvent'ом (TASWM-194)
64
- // Заметка дрона «замечено по пути» (TASWM-279): НЕблокирующий канал к оператору рядом с ask.
65
- // ask останавливает дрона и ждёт ответа — дорого, и оправдано только на развилках «без ответа
66
- // делать нечего». Находки вроде «в дизайн-системе нет иконки микрофона» раньше либо
67
- // превращались в такой дорогой вопрос, либо тонули в отчёте о сдаче (дрон LAMPYRIS сдал
68
- // заглушку «Мик» текстом на кнопке — уехало в прод). note уезжает оператору, дрон работает дальше.
69
- "note"
70
- ];
71
- var MILESTONE_KINDS = [
72
- "spawned",
73
- "pr_opened",
74
- "merged",
75
- "done",
76
- "failed",
77
- "stalled",
78
- "conflict",
79
- "ci_wake",
80
- // CI-побудка (TASWM-102) — тоже реконсайл-решение, веха как conflict
81
- "stopped",
82
- "superseded",
83
- "paused",
84
- "resumed",
85
- "storm_start",
86
- "storm_end",
87
- "storm_dead",
88
- "anomaly",
89
- // надзор overmind (TASWM-194) — та же нотификация, что улетела в инбокс, вехой в Истории
90
- "note"
91
- // заметка дрона «замечено по пути» (TASWM-279) — веха, иначе находка тонет в ленте step'ов
92
- ];
93
- var MILESTONE_KIND_SET = new Set(MILESTONE_KINDS);
94
13
  function isMilestoneKind(kind) {
95
14
  return MILESTONE_KIND_SET.has(kind);
96
15
  }
97
16
  function toRunKind(role) {
98
17
  return role === "hero" || role === "reviewer" ? role : "drone";
99
18
  }
19
+ var EVENT_KINDS, MILESTONE_KINDS, MILESTONE_KIND_SET;
20
+ var init_types = __esm({
21
+ "../swarm-core/src/types.ts"() {
22
+ "use strict";
23
+ EVENT_KINDS = [
24
+ "spawned",
25
+ "step",
26
+ "blocked",
27
+ "answered",
28
+ "pr_opened",
29
+ "merged",
30
+ "done",
31
+ "failed",
32
+ "stalled",
33
+ "respawned",
34
+ // дрон умер, рой авто-оживил (respawn/--continue) — счётчик respawns
35
+ "paused",
36
+ // дрон замер: overmind недоступен (heartbeat устарел) — сохранился и вышел (TASWM-41)
37
+ "resumed",
38
+ // новый overmind разбудил замерший прогон (--continue/fresh) (TASWM-41)
39
+ "rescued",
40
+ // worktree мёртвого дрона с несохранённой работой сохранён (не снесён) — забрать руками
41
+ "conflict",
42
+ // review-PR конфликтует с main — рой вливает main / поднял резолвера / разрешил (merge-main-in)
43
+ "died",
44
+ // работающий дрон умер (dead pid) — семпл для скользящего окна детекции шторма (TASWM-23)
45
+ "storm_start",
46
+ // рой вошёл в штормовой режим — Anthropic лежит, пауза (глобальное событие, run_id NULL) (TASWM-23)
47
+ "storm_end",
48
+ // отбой шторма — Anthropic вернулся, рой поднимается (глобальное событие, run_id NULL) (TASWM-23)
49
+ "storm_dead",
50
+ // дрон заморожен штормом (worktree сохранён, respawn не тронут) (TASWM-23)
51
+ "storm_probe",
52
+ // запись health-probe шторма: {probe, ext} — троттлинг 60с + счётчик успехов (глобальное, run_id NULL) (TASWM-23)
53
+ "stopped",
54
+ // прогон остановлен оператором (с причиной)
55
+ "superseded",
56
+ // прогон переигран на другой вариант
57
+ "question",
58
+ // вопрос дрона оператору (прод: SwarmEvent kind=question, TASWM-74)
59
+ "answer",
60
+ // ответ оператора на вопрос дрона (прод: SwarmEvent kind=answer)
61
+ "ci_wake",
62
+ // review-PR покраснел (gh pr checks) — рой разбудил дрона со сжатым summary фейла (TASWM-102)
63
+ "ci_checked",
64
+ // опрос `gh pr checks` без побудки (не fail/gh недоступен) — только для cooldown, не веха (TASWM-102)
65
+ "anomaly",
66
+ // встроенный надзор overmind засёк аномалию прогона — дубль нотификации в инбокс SwarmEvent'ом (TASWM-194)
67
+ // Заметка дрона «замечено по пути» (TASWM-279): НЕблокирующий канал к оператору рядом с ask.
68
+ // ask останавливает дрона и ждёт ответа — дорого, и оправдано только на развилках «без ответа
69
+ // делать нечего». Находки вроде «в дизайн-системе нет иконки микрофона» раньше либо
70
+ // превращались в такой дорогой вопрос, либо тонули в отчёте о сдаче (дрон LAMPYRIS сдал
71
+ // заглушку «Мик» текстом на кнопке — уехало в прод). note уезжает оператору, дрон работает дальше.
72
+ "note"
73
+ ];
74
+ MILESTONE_KINDS = [
75
+ "spawned",
76
+ "pr_opened",
77
+ "merged",
78
+ "done",
79
+ "failed",
80
+ "stalled",
81
+ "conflict",
82
+ "ci_wake",
83
+ // CI-побудка (TASWM-102) — тоже реконсайл-решение, веха как conflict
84
+ "stopped",
85
+ "superseded",
86
+ "paused",
87
+ "resumed",
88
+ "storm_start",
89
+ "storm_end",
90
+ "storm_dead",
91
+ "anomaly",
92
+ // надзор overmind (TASWM-194) — та же нотификация, что улетела в инбокс, вехой в Истории
93
+ "note"
94
+ // заметка дрона «замечено по пути» (TASWM-279) — веха, иначе находка тонет в ленте step'ов
95
+ ];
96
+ MILESTONE_KIND_SET = new Set(MILESTONE_KINDS);
97
+ }
98
+ });
100
99
 
101
100
  // ../swarm-core/src/timeline.ts
102
101
  function resolveDepRef(ref, lk) {
@@ -265,29 +264,22 @@ function computeCounters(lanes) {
265
264
  }
266
265
  return c;
267
266
  }
268
- var DEFAULT_RUN_MS = 25 * 6e4;
267
+ var DEFAULT_RUN_MS;
268
+ var init_timeline = __esm({
269
+ "../swarm-core/src/timeline.ts"() {
270
+ "use strict";
271
+ DEFAULT_RUN_MS = 25 * 6e4;
272
+ }
273
+ });
274
+
275
+ // ../swarm-core/src/reviewPhase.ts
276
+ var init_reviewPhase = __esm({
277
+ "../swarm-core/src/reviewPhase.ts"() {
278
+ "use strict";
279
+ }
280
+ });
269
281
 
270
282
  // ../swarm-core/src/status.ts
271
- var STATUS_META = {
272
- queued: { pill: "queued", label: "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438", color: "--muted-foreground", terminal: false },
273
- running: { pill: "run", label: "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", color: "--primary", terminal: false },
274
- blocked: { pill: "blocked", label: "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430", color: "--warning", terminal: false },
275
- paused: { pill: "paused", label: "\u0437\u0430\u043C\u0435\u0440", color: "--warning", terminal: false },
276
- // overmind был недоступен — ждёт пробуждения (TASWM-41)
277
- review: { pill: "review", label: "\u043D\u0430 \u0440\u0435\u0432\u044C\u044E", color: "--info", terminal: false },
278
- done: { pill: "done", label: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", color: "--success", terminal: true },
279
- failed: { pill: "failed", label: "\u0443\u043F\u0430\u043B", color: "--destructive", terminal: true },
280
- stalled: { pill: "stalled", label: "\u043F\u043E\u0433\u0438\u0431/\u0437\u0430\u0432\u0438\u0441", color: "--destructive", terminal: false },
281
- // молчит/погиб, но ещё разруливается — не терминал
282
- storm_dead: { pill: "storm", label: "\u0448\u0442\u043E\u0440\u043C \u2014 \u0437\u0430\u043C\u043E\u0440\u043E\u0436\u0435\u043D", color: "--warning", terminal: false },
283
- // умер в шторм Anthropic, поднимется после отбоя (TASWM-23)
284
- stopped: { pill: "stopped", label: "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", color: "--muted-foreground", terminal: true },
285
- superseded: { pill: "superseded", label: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D", color: "--muted-foreground", terminal: true }
286
- };
287
- var FALLBACK = STATUS_META.queued;
288
- var TERMINAL_STATUSES = Object.keys(STATUS_META).filter(
289
- (s) => STATUS_META[s].terminal
290
- );
291
283
  function statusMeta(status) {
292
284
  return STATUS_META[status] ?? FALLBACK;
293
285
  }
@@ -301,6 +293,32 @@ function statusTuples() {
301
293
  }
302
294
  return out;
303
295
  }
296
+ var STATUS_META, FALLBACK, TERMINAL_STATUSES;
297
+ var init_status = __esm({
298
+ "../swarm-core/src/status.ts"() {
299
+ "use strict";
300
+ STATUS_META = {
301
+ queued: { pill: "queued", label: "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438", color: "--muted-foreground", terminal: false },
302
+ running: { pill: "run", label: "\u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442", color: "--primary", terminal: false },
303
+ blocked: { pill: "blocked", label: "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430", color: "--warning", terminal: false },
304
+ paused: { pill: "paused", label: "\u0437\u0430\u043C\u0435\u0440", color: "--warning", terminal: false },
305
+ // overmind был недоступен — ждёт пробуждения (TASWM-41)
306
+ review: { pill: "review", label: "\u043D\u0430 \u0440\u0435\u0432\u044C\u044E", color: "--info", terminal: false },
307
+ done: { pill: "done", label: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", color: "--success", terminal: true },
308
+ failed: { pill: "failed", label: "\u0443\u043F\u0430\u043B", color: "--destructive", terminal: true },
309
+ stalled: { pill: "stalled", label: "\u043F\u043E\u0433\u0438\u0431/\u0437\u0430\u0432\u0438\u0441", color: "--destructive", terminal: false },
310
+ // молчит/погиб, но ещё разруливается — не терминал
311
+ storm_dead: { pill: "storm", label: "\u0448\u0442\u043E\u0440\u043C \u2014 \u0437\u0430\u043C\u043E\u0440\u043E\u0436\u0435\u043D", color: "--warning", terminal: false },
312
+ // умер в шторм Anthropic, поднимется после отбоя (TASWM-23)
313
+ stopped: { pill: "stopped", label: "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", color: "--muted-foreground", terminal: true },
314
+ superseded: { pill: "superseded", label: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D", color: "--muted-foreground", terminal: true }
315
+ };
316
+ FALLBACK = STATUS_META.queued;
317
+ TERMINAL_STATUSES = Object.keys(STATUS_META).filter(
318
+ (s) => STATUS_META[s].terminal
319
+ );
320
+ }
321
+ });
304
322
 
305
323
  // ../swarm-core/src/time.ts
306
324
  function stamp(d = /* @__PURE__ */ new Date()) {
@@ -318,10 +336,15 @@ function toStamp(v) {
318
336
  if (Number.isNaN(d.getTime())) return v.includes("T") ? v.slice(0, 19).replace("T", " ") : v;
319
337
  return stamp(d);
320
338
  }
321
- var DAY_MS = 864e5;
339
+ var DAY_MS;
340
+ var init_time = __esm({
341
+ "../swarm-core/src/time.ts"() {
342
+ "use strict";
343
+ DAY_MS = 864e5;
344
+ }
345
+ });
322
346
 
323
347
  // ../swarm-core/src/windows.ts
324
- var ROY_WINDOW_DEFAULTS = { windowH: 5, maxDone: 6, axisH: 6 };
325
348
  function resolveRoyWindows(env = {}) {
326
349
  return {
327
350
  windowMs: Number(env.ROY_WINDOW_H || ROY_WINDOW_DEFAULTS.windowH) * 36e5,
@@ -329,46 +352,50 @@ function resolveRoyWindows(env = {}) {
329
352
  maxAxisMs: Number(env.ROY_AXIS_H || ROY_WINDOW_DEFAULTS.axisH) * 36e5
330
353
  };
331
354
  }
355
+ var ROY_WINDOW_DEFAULTS;
356
+ var init_windows = __esm({
357
+ "../swarm-core/src/windows.ts"() {
358
+ "use strict";
359
+ ROY_WINDOW_DEFAULTS = { windowH: 5, maxDone: 6, axisH: 6 };
360
+ }
361
+ });
362
+
363
+ // ../swarm-core/src/slug.ts
364
+ var init_slug = __esm({
365
+ "../swarm-core/src/slug.ts"() {
366
+ "use strict";
367
+ }
368
+ });
332
369
 
333
370
  // ../swarm-core/src/text.ts
334
- var ANSI_CSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
335
- var CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/g;
336
371
  function sanitizeEventText(text) {
337
372
  if (text == null) return null;
338
373
  const cleaned = text.replace(ANSI_CSI_RE, "").replace(CONTROL_CHARS_RE, " ").replace(/ {2,}/g, " ").trim();
339
- return cleaned.length ? cleaned : null;
340
- }
374
+ if (!cleaned.length) return null;
375
+ return cleaned.length <= EVENT_TEXT_MAX ? cleaned : clampEventText(cleaned);
376
+ }
377
+ function clampEventText(text) {
378
+ const note = ` \u2026 (\u043E\u0431\u0440\u0435\u0437\u0430\u043D\u043E, \u0431\u044B\u043B\u043E ${text.length} \u0441\u0438\u043C\u0432\u043E\u043B\u043E\u0432)`;
379
+ return text.slice(0, Math.max(0, EVENT_TEXT_MAX - note.length)) + note;
380
+ }
381
+ var ANSI_CSI_RE, CONTROL_CHARS_RE, EVENT_TEXT_MAX;
382
+ var init_text = __esm({
383
+ "../swarm-core/src/text.ts"() {
384
+ "use strict";
385
+ ANSI_CSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
386
+ CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/g;
387
+ EVENT_TEXT_MAX = 4e3;
388
+ }
389
+ });
390
+
391
+ // ../swarm-core/src/comment.ts
392
+ var init_comment = __esm({
393
+ "../swarm-core/src/comment.ts"() {
394
+ "use strict";
395
+ }
396
+ });
341
397
 
342
398
  // ../swarm-core/src/names.ts
343
- var DRONE_POOL = [
344
- // Латинский бестиарий (15)
345
- "SCARAB",
346
- "VESPA",
347
- "MANTIS",
348
- "HORNET",
349
- "CICADA",
350
- "LOCUST",
351
- "ATTA",
352
- "FORMICA",
353
- "MYRMEX",
354
- "APIS",
355
- "TERMES",
356
- "GRYLLUS",
357
- "LAMPYRIS",
358
- "ARACHNE",
359
- "SPHINX",
360
- // Мифо-стаи (9)
361
- "TIAMAT",
362
- "JORMUNGAND",
363
- "GRENDEL",
364
- "TYPHON",
365
- "FENRIS",
366
- "GARM",
367
- "KUKULKAN",
368
- "LEVIATHAN",
369
- "SURTUR"
370
- ];
371
- var ACTIVE_NAME_STATUSES = ["queued", "running", "blocked", "review"];
372
399
  function toRoman(n) {
373
400
  if (n < 1 || n > 3999) return String(n);
374
401
  const table = [
@@ -396,10 +423,43 @@ function genName(base3, g) {
396
423
  function baseName(name) {
397
424
  return name.split(" ")[0];
398
425
  }
426
+ var DRONE_POOL, ACTIVE_NAME_STATUSES;
427
+ var init_names = __esm({
428
+ "../swarm-core/src/names.ts"() {
429
+ "use strict";
430
+ DRONE_POOL = [
431
+ // Латинский бестиарий (15)
432
+ "SCARAB",
433
+ "VESPA",
434
+ "MANTIS",
435
+ "HORNET",
436
+ "CICADA",
437
+ "LOCUST",
438
+ "ATTA",
439
+ "FORMICA",
440
+ "MYRMEX",
441
+ "APIS",
442
+ "TERMES",
443
+ "GRYLLUS",
444
+ "LAMPYRIS",
445
+ "ARACHNE",
446
+ "SPHINX",
447
+ // Мифо-стаи (9)
448
+ "TIAMAT",
449
+ "JORMUNGAND",
450
+ "GRENDEL",
451
+ "TYPHON",
452
+ "FENRIS",
453
+ "GARM",
454
+ "KUKULKAN",
455
+ "LEVIATHAN",
456
+ "SURTUR"
457
+ ];
458
+ ACTIVE_NAME_STATUSES = ["queued", "running", "blocked", "review"];
459
+ }
460
+ });
399
461
 
400
462
  // ../swarm-core/src/baseBranch.ts
401
- var BASE_BRANCH_MAX = 255;
402
- var BASE_BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
403
463
  function normalizeBaseBranch(raw) {
404
464
  if (raw == null) return void 0;
405
465
  if (typeof raw !== "string") return null;
@@ -409,19 +469,22 @@ function normalizeBaseBranch(raw) {
409
469
  if (v.includes("..") || v.endsWith("/") || v.endsWith(".lock")) return null;
410
470
  return BASE_BRANCH_RE.test(v) ? v : null;
411
471
  }
472
+ var BASE_BRANCH_MAX, BASE_BRANCH_RE;
473
+ var init_baseBranch = __esm({
474
+ "../swarm-core/src/baseBranch.ts"() {
475
+ "use strict";
476
+ BASE_BRANCH_MAX = 255;
477
+ BASE_BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
478
+ }
479
+ });
412
480
 
413
481
  // ../swarm-core/src/hosts.ts
414
- var ANY_HOST_TOKENS = /* @__PURE__ */ new Set(["any", "none", "null", "free", "-", "\u043B\u044E\u0431\u0430\u044F", "\u043B\u044E\u0431\u043E\u0439"]);
415
482
  function normalizeHostRef(value) {
416
483
  if (value == null) return null;
417
484
  const trimmed = value.trim();
418
485
  if (!trimmed) return null;
419
486
  return ANY_HOST_TOKENS.has(trimmed.toLowerCase()) ? null : trimmed;
420
487
  }
421
- var UNKNOWN_HOST_KEY = "__no-host__";
422
- var UNKNOWN_HOST_LABEL = "\u043C\u0430\u0448\u0438\u043D\u0430 \u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u0430";
423
- var QUEUE_LABEL = "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438";
424
- var NOT_STARTED_STATUSES = /* @__PURE__ */ new Set(["queued"]);
425
488
  function splitQueue(items, ref) {
426
489
  const queue = [];
427
490
  const hosted = [];
@@ -432,7 +495,6 @@ function splitQueue(items, ref) {
432
495
  }
433
496
  return { queue, hosted };
434
497
  }
435
- var HOST_HEARTBEAT_STALE_MS = 45e3;
436
498
  function groupByHost(items, ref, opts = {}) {
437
499
  const { hosts = [], nowMs = Date.now(), staleMs = HOST_HEARTBEAT_STALE_MS } = opts;
438
500
  const infoById = new Map(hosts.map((h) => [h.hostId, h]));
@@ -488,10 +550,65 @@ function hostStateText(host, nowMs = Date.now(), staleMs = HOST_HEARTBEAT_STALE_
488
550
  function shouldGroupByHost(groups) {
489
551
  return groups.length > 1;
490
552
  }
553
+ var ANY_HOST_TOKENS, UNKNOWN_HOST_KEY, UNKNOWN_HOST_LABEL, QUEUE_LABEL, NOT_STARTED_STATUSES, HOST_HEARTBEAT_STALE_MS;
554
+ var init_hosts = __esm({
555
+ "../swarm-core/src/hosts.ts"() {
556
+ "use strict";
557
+ ANY_HOST_TOKENS = /* @__PURE__ */ new Set(["any", "none", "null", "free", "-", "\u043B\u044E\u0431\u0430\u044F", "\u043B\u044E\u0431\u043E\u0439"]);
558
+ UNKNOWN_HOST_KEY = "__no-host__";
559
+ UNKNOWN_HOST_LABEL = "\u043C\u0430\u0448\u0438\u043D\u0430 \u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u0430";
560
+ QUEUE_LABEL = "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438";
561
+ NOT_STARTED_STATUSES = /* @__PURE__ */ new Set(["queued"]);
562
+ HOST_HEARTBEAT_STALE_MS = 45e3;
563
+ }
564
+ });
565
+
566
+ // ../swarm-core/src/hostCommands.ts
567
+ var HOST_COMMAND_STATUSES, TERMINAL, HOST_COMMAND_OPEN_STATUSES, HOST_COMMAND_TTL_MS, HOST_COMMAND_KEEPALIVE_MS;
568
+ var init_hostCommands = __esm({
569
+ "../swarm-core/src/hostCommands.ts"() {
570
+ "use strict";
571
+ HOST_COMMAND_STATUSES = ["pending", "delivered", "running", "done", "failed", "rejected", "expired"];
572
+ TERMINAL = /* @__PURE__ */ new Set(["done", "failed", "rejected", "expired"]);
573
+ HOST_COMMAND_OPEN_STATUSES = HOST_COMMAND_STATUSES.filter((s) => !TERMINAL.has(s));
574
+ HOST_COMMAND_TTL_MS = 30 * 6e4;
575
+ HOST_COMMAND_KEEPALIVE_MS = 10 * 6e4;
576
+ }
577
+ });
578
+
579
+ // ../swarm-core/src/relief.ts
580
+ var init_relief = __esm({
581
+ "../swarm-core/src/relief.ts"() {
582
+ "use strict";
583
+ init_time();
584
+ }
585
+ });
586
+
587
+ // ../swarm-core/src/index.ts
588
+ var init_src = __esm({
589
+ "../swarm-core/src/index.ts"() {
590
+ "use strict";
591
+ init_types();
592
+ init_timeline();
593
+ init_reviewPhase();
594
+ init_status();
595
+ init_time();
596
+ init_windows();
597
+ init_slug();
598
+ init_text();
599
+ init_comment();
600
+ init_names();
601
+ init_baseBranch();
602
+ init_hosts();
603
+ init_hostCommands();
604
+ init_relief();
605
+ }
606
+ });
491
607
 
492
608
  // src/config.ts
493
- var SWARM_HOME = process.env.SWARM_HOME || join(homedir(), ".taskless-swarm");
494
- mkdirSync(SWARM_HOME, { recursive: true });
609
+ import { homedir } from "os";
610
+ import { basename, join, resolve } from "path";
611
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs";
495
612
  function normalizeRepo(repo) {
496
613
  const r = (repo ?? "").trim();
497
614
  if (!r) return "";
@@ -501,15 +618,6 @@ function normalizeRepo(repo) {
501
618
  return resolve(r);
502
619
  }
503
620
  }
504
- var DB_PATH = process.env.SWARM_DB || join(SWARM_HOME, "swarm.db");
505
- var DRONE_HOME = process.env.SWARM_DRONE_HOME || join(SWARM_HOME, "drone-home");
506
- var DRONE_HOME_ISOLATION = process.env.SWARM_DRONE_ISOLATE_HOME !== "0" && process.platform !== "win32";
507
- var DRONE_ISOLATION = DRONE_HOME_ISOLATION ? { home: DRONE_HOME, swarmHome: SWARM_HOME } : void 0;
508
- var WORKTREES_NOINDEX_DIR = join(SWARM_HOME, "worktrees.noindex");
509
- var WORKTREES_LEGACY_DIR = join(SWARM_HOME, "worktrees");
510
- var WORKTREES_DIR = process.env.SWARM_WORKTREES || WORKTREES_NOINDEX_DIR;
511
- var RUN_LOGS_DIR = process.env.SWARM_RUN_LOGS || join(SWARM_HOME, "run-logs");
512
- var DAEMON_LOG_PATH = process.env.SWARM_DAEMON_LOG || join(SWARM_HOME, "overmind.log");
513
621
  function droneWorktreePath(worktreesDir, repo, slug) {
514
622
  return join(worktreesDir, basename(normalizeRepo(repo)), slug);
515
623
  }
@@ -519,49 +627,12 @@ function resolveEffectiveWorktreesDir(deps) {
519
627
  if (deps.liveDronesUnder(deps.legacyDir)) return deps.legacyDir;
520
628
  return deps.targetDir;
521
629
  }
522
- var DIST_BUILD = true;
523
- var SWARM_BACKEND = process.env.SWARM_BACKEND === "client" ? "client" : process.env.SWARM_BACKEND === "sqlite" ? "sqlite" : DIST_BUILD ? "client" : "sqlite";
524
- var SWARM_OUTBOX = process.env.SWARM_OUTBOX || join(SWARM_HOME, "outbox.jsonl");
525
630
  function envNum(name, def) {
526
631
  const raw = process.env[name];
527
632
  if (raw === void 0 || raw === "") return def;
528
633
  const n = Number(raw);
529
634
  return Number.isFinite(n) && n >= 0 ? n : def;
530
635
  }
531
- var SWARM_MAX_IN_FLIGHT = Math.trunc(envNum("SWARM_MAX_IN_FLIGHT", 3));
532
- var SWARM_CLAIM_INTERVAL_MS = envNum("SWARM_CLAIM_INTERVAL_MS", 6e4);
533
- var SWARM_MAX_LOAD_FACTOR = envNum("SWARM_MAX_LOAD_FACTOR", 0.9);
534
- var SWARM_MIN_FREE_GB = envNum("SWARM_MIN_FREE_GB", 1.5);
535
- var SWARM_MAX_FD_LOAD_FACTOR = envNum("SWARM_MAX_FD_LOAD_FACTOR", 0.9);
536
- var SWARM_MAX_CPU_BUSY = envNum("SWARM_MAX_CPU_BUSY", 0.85);
537
- var SWARM_RELIEF_LOAD_FACTOR = envNum("SWARM_RELIEF_LOAD_FACTOR", 1.3);
538
- var SWARM_RELIEF_MIN_FREE_GB = envNum("SWARM_RELIEF_MIN_FREE_GB", 0.8);
539
- var SWARM_RELIEF_MAX_FD_LOAD_FACTOR = envNum("SWARM_RELIEF_MAX_FD_LOAD_FACTOR", 0.95);
540
- var SWARM_RELIEF_CPU_BUSY = envNum("SWARM_RELIEF_CPU_BUSY", 0.95);
541
- var SWARM_RELIEF_COST_WINDOW_MS = envNum("SWARM_RELIEF_COST_WINDOW_MS", 60 * 6e4);
542
- var SWARM_RELIEF_ALERT_PAUSES = Math.trunc(envNum("SWARM_RELIEF_ALERT_PAUSES", 6));
543
- var SWARM_RELIEF_ALERT_LOST_MS = envNum("SWARM_RELIEF_ALERT_LOST_MS", 10 * 6e4);
544
- var SWARM_RELIEF_ALERT_MIN_STEPS = envNum("SWARM_RELIEF_ALERT_MIN_STEPS", 1);
545
- var SWARM_RELIEF_ALERT_SUSTAIN_MS = envNum("SWARM_RELIEF_ALERT_SUSTAIN_MS", 10 * 6e4);
546
- var SWARM_LOW_DISK_GB = envNum("SWARM_LOW_DISK_GB", 10);
547
- var SWARM_REHYDRATE_MS = envNum("SWARM_REHYDRATE_MS", 15e3);
548
- var SWARM_DRONE_BRIDGE_MS = envNum("SWARM_DRONE_BRIDGE_MS", 5e3);
549
- var SWARM_PROD_HEARTBEAT_MS = envNum("SWARM_PROD_HEARTBEAT_MS", 15e3);
550
- var SWARM_VERSION_CHECK_MS = envNum("SWARM_VERSION_CHECK_MS", 5 * 6e4);
551
- var SWARM_SWEEP_INTERVAL_MS = envNum("SWARM_SWEEP_INTERVAL_MS", 10 * 6e4);
552
- var DRONE_CHANNEL_ENABLED = process.env.SWARM_DRONE_CHANNEL !== "0";
553
- var DRONE_CHANNEL_PORT = Number(process.env.SWARM_DRONE_CHANNEL_PORT) || 7421;
554
- var DRONE_CHANNEL_URL = process.env.SWARM_DRONE_CHANNEL_URL || `http://127.0.0.1:${DRONE_CHANNEL_PORT}`;
555
- var DRONE_CHANNEL_SECRET_PATH = process.env.SWARM_DRONE_CHANNEL_SECRET || join(SWARM_HOME, "drone-channel.key");
556
- var DRONE_CHANNEL_GRACE_MS = envNum("SWARM_DRONE_CHANNEL_GRACE_MS", 12e4);
557
- var HEARTBEAT_PATH = process.env.SWARM_HEARTBEAT || join(SWARM_HOME, "overmind.heartbeat");
558
- var HEARTBEAT_STALE_MS = Number(process.env.SWARM_HEARTBEAT_STALE_MS) || 3e4;
559
- var SWARM_REPO = process.env.SWARM_REPO || "";
560
- var SWARM_SCAN_DEPTH = Number(process.env.SWARM_SCAN_DEPTH) || 3;
561
- var SWARM_REPO_PATHS = (process.env.SWARM_REPOS || SWARM_REPO).split(",").map((s) => s.trim()).filter(Boolean);
562
- var TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
563
- var SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
564
- var TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
565
636
  function mcpEndpoint(api, path = TASKLESS_MCP_PATH) {
566
637
  const base3 = api.replace(/\/$/, "");
567
638
  const p = path.startsWith("/") ? path : `/${path}`;
@@ -574,32 +645,86 @@ function tokenFromFile() {
574
645
  return "";
575
646
  }
576
647
  }
577
- var TASKLESS_TOKEN = process.env.TASKLESS_TOKEN || tokenFromFile();
578
- var TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
579
- var TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
580
- var TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
581
- var SWARM_VERIFY = process.env.SWARM_VERIFY === "ci" ? "ci" : "local";
582
- var SWARM_AUTO_REVIEW = process.env.SWARM_AUTO_REVIEW !== "0";
583
- var REVIEW_CUTOFF_PATH = process.env.SWARM_REVIEW_CUTOFF || join(SWARM_HOME, "review-cutoff.json");
584
- var SWARM_REVIEW_CAP = Math.trunc(envNum("SWARM_REVIEW_CAP", 1));
585
- var SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
586
- var WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
587
- var WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
588
- var SWARM_ANOMALY_RENOTIFY_MS = envNum("SWARM_ANOMALY_RENOTIFY_MS", 60 * 6e4);
589
- var SWARM_ANOMALY_MAX_REPEATS = Math.trunc(envNum("SWARM_ANOMALY_MAX_REPEATS", 3));
590
- var SWARM_ANOMALY_RUN_DOWN_WINDOW_MS = envNum("SWARM_ANOMALY_RUN_DOWN_WINDOW_MS", 24 * 60 * 6e4);
591
- var SWARM_ANOMALY_FAILED_SPIKE_COUNT = Math.trunc(envNum("SWARM_ANOMALY_FAILED_SPIKE_COUNT", 3));
592
- var SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS = envNum("SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS", 30 * 6e4);
593
- var SWARM_ANOMALY_QUEUE_STALLED_MS = envNum("SWARM_ANOMALY_QUEUE_STALLED_MS", 10 * 6e4);
594
- var SWARM_ANOMALY_PR_STUCK_MERGE_MS = envNum("SWARM_ANOMALY_PR_STUCK_MERGE_MS", 30 * 6e4);
595
- var SWARM_ANOMALY_PR_NO_REVIEWER_MS = envNum("SWARM_ANOMALY_PR_NO_REVIEWER_MS", 20 * 6e4);
596
- var SWARM_ANOMALY_QUESTION_UNANSWERED_MS = envNum("SWARM_ANOMALY_QUESTION_UNANSWERED_MS", 20 * 6e4);
597
- var SWARM_ANOMALY_OUTBOX_STUCK_MS = envNum("SWARM_ANOMALY_OUTBOX_STUCK_MS", 10 * 6e4);
598
- var SWARM_ANOMALY_RESOURCE_GATE_STALL_MS = envNum("SWARM_ANOMALY_RESOURCE_GATE_STALL_MS", 5 * 6e4);
599
- var SWARM_WATCHDOG_STALL_MS = envNum("SWARM_WATCHDOG_STALL_MS", 15 * 6e4);
648
+ var SWARM_HOME, DB_PATH, DRONE_HOME, DRONE_HOME_ISOLATION, DRONE_ISOLATION, WORKTREES_NOINDEX_DIR, WORKTREES_LEGACY_DIR, WORKTREES_DIR, RUN_LOGS_DIR, DAEMON_LOG_PATH, DIST_BUILD, SWARM_BACKEND, SWARM_OUTBOX, SWARM_MAX_IN_FLIGHT, SWARM_CLAIM_INTERVAL_MS, SWARM_MAX_LOAD_FACTOR, SWARM_MIN_FREE_GB, SWARM_MAX_FD_LOAD_FACTOR, SWARM_MAX_CPU_BUSY, SWARM_RELIEF_LOAD_FACTOR, SWARM_RELIEF_MIN_FREE_GB, SWARM_RELIEF_MAX_FD_LOAD_FACTOR, SWARM_RELIEF_CPU_BUSY, SWARM_RELIEF_COST_WINDOW_MS, SWARM_RELIEF_ALERT_PAUSES, SWARM_RELIEF_ALERT_LOST_MS, SWARM_RELIEF_ALERT_MIN_STEPS, SWARM_RELIEF_ALERT_SUSTAIN_MS, SWARM_LOW_DISK_GB, SWARM_REHYDRATE_MS, SWARM_DRONE_BRIDGE_MS, SWARM_PROD_HEARTBEAT_MS, SWARM_VERSION_CHECK_MS, SWARM_SWEEP_INTERVAL_MS, DRONE_CHANNEL_ENABLED, DRONE_CHANNEL_PORT, DRONE_CHANNEL_URL, DRONE_CHANNEL_SECRET_PATH, DRONE_CHANNEL_GRACE_MS, HEARTBEAT_PATH, HEARTBEAT_STALE_MS, SWARM_REPO, SWARM_SCAN_DEPTH, SWARM_REPO_PATHS, TASKLESS_API, SWARM_SHOT_TIMEOUT_MS, TASKLESS_MCP_PATH, TASKLESS_TOKEN, TASKLESS_WORKSPACE, TASKLESS_WORKSPACE_SLUG, TASKLESS_TASK_URL_BASE, SWARM_VERIFY, SWARM_AUTO_REVIEW, REVIEW_CUTOFF_PATH, SWARM_REVIEW_CAP, SWARM_STORM_URL, WEB_PORT, WEB_REDIRECT_URI, SWARM_ANOMALY_RENOTIFY_MS, SWARM_ANOMALY_MAX_REPEATS, SWARM_ANOMALY_RUN_DOWN_WINDOW_MS, SWARM_ANOMALY_FAILED_SPIKE_COUNT, SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS, SWARM_ANOMALY_QUEUE_STALLED_MS, SWARM_ANOMALY_PR_STUCK_MERGE_MS, SWARM_ANOMALY_PR_NO_REVIEWER_MS, SWARM_ANOMALY_QUESTION_UNANSWERED_MS, SWARM_ANOMALY_OUTBOX_STUCK_MS, SWARM_ANOMALY_RESOURCE_GATE_STALL_MS, SWARM_WATCHDOG_STALL_MS;
649
+ var init_config = __esm({
650
+ "src/config.ts"() {
651
+ "use strict";
652
+ init_src();
653
+ SWARM_HOME = process.env.SWARM_HOME || join(homedir(), ".taskless-swarm");
654
+ mkdirSync(SWARM_HOME, { recursive: true });
655
+ DB_PATH = process.env.SWARM_DB || join(SWARM_HOME, "swarm.db");
656
+ DRONE_HOME = process.env.SWARM_DRONE_HOME || join(SWARM_HOME, "drone-home");
657
+ DRONE_HOME_ISOLATION = process.env.SWARM_DRONE_ISOLATE_HOME !== "0" && process.platform !== "win32";
658
+ DRONE_ISOLATION = DRONE_HOME_ISOLATION ? { home: DRONE_HOME, swarmHome: SWARM_HOME } : void 0;
659
+ WORKTREES_NOINDEX_DIR = join(SWARM_HOME, "worktrees.noindex");
660
+ WORKTREES_LEGACY_DIR = join(SWARM_HOME, "worktrees");
661
+ WORKTREES_DIR = process.env.SWARM_WORKTREES || WORKTREES_NOINDEX_DIR;
662
+ RUN_LOGS_DIR = process.env.SWARM_RUN_LOGS || join(SWARM_HOME, "run-logs");
663
+ DAEMON_LOG_PATH = process.env.SWARM_DAEMON_LOG || join(SWARM_HOME, "overmind.log");
664
+ DIST_BUILD = true;
665
+ SWARM_BACKEND = process.env.SWARM_BACKEND === "client" ? "client" : process.env.SWARM_BACKEND === "sqlite" ? "sqlite" : DIST_BUILD ? "client" : "sqlite";
666
+ SWARM_OUTBOX = process.env.SWARM_OUTBOX || join(SWARM_HOME, "outbox.jsonl");
667
+ SWARM_MAX_IN_FLIGHT = Math.trunc(envNum("SWARM_MAX_IN_FLIGHT", 3));
668
+ SWARM_CLAIM_INTERVAL_MS = envNum("SWARM_CLAIM_INTERVAL_MS", 6e4);
669
+ SWARM_MAX_LOAD_FACTOR = envNum("SWARM_MAX_LOAD_FACTOR", 0.9);
670
+ SWARM_MIN_FREE_GB = envNum("SWARM_MIN_FREE_GB", 1.5);
671
+ SWARM_MAX_FD_LOAD_FACTOR = envNum("SWARM_MAX_FD_LOAD_FACTOR", 0.9);
672
+ SWARM_MAX_CPU_BUSY = envNum("SWARM_MAX_CPU_BUSY", 0.85);
673
+ SWARM_RELIEF_LOAD_FACTOR = envNum("SWARM_RELIEF_LOAD_FACTOR", 1.3);
674
+ SWARM_RELIEF_MIN_FREE_GB = envNum("SWARM_RELIEF_MIN_FREE_GB", 0.8);
675
+ SWARM_RELIEF_MAX_FD_LOAD_FACTOR = envNum("SWARM_RELIEF_MAX_FD_LOAD_FACTOR", 0.95);
676
+ SWARM_RELIEF_CPU_BUSY = envNum("SWARM_RELIEF_CPU_BUSY", 0.95);
677
+ SWARM_RELIEF_COST_WINDOW_MS = envNum("SWARM_RELIEF_COST_WINDOW_MS", 60 * 6e4);
678
+ SWARM_RELIEF_ALERT_PAUSES = Math.trunc(envNum("SWARM_RELIEF_ALERT_PAUSES", 6));
679
+ SWARM_RELIEF_ALERT_LOST_MS = envNum("SWARM_RELIEF_ALERT_LOST_MS", 10 * 6e4);
680
+ SWARM_RELIEF_ALERT_MIN_STEPS = envNum("SWARM_RELIEF_ALERT_MIN_STEPS", 1);
681
+ SWARM_RELIEF_ALERT_SUSTAIN_MS = envNum("SWARM_RELIEF_ALERT_SUSTAIN_MS", 10 * 6e4);
682
+ SWARM_LOW_DISK_GB = envNum("SWARM_LOW_DISK_GB", 10);
683
+ SWARM_REHYDRATE_MS = envNum("SWARM_REHYDRATE_MS", 15e3);
684
+ SWARM_DRONE_BRIDGE_MS = envNum("SWARM_DRONE_BRIDGE_MS", 5e3);
685
+ SWARM_PROD_HEARTBEAT_MS = envNum("SWARM_PROD_HEARTBEAT_MS", 15e3);
686
+ SWARM_VERSION_CHECK_MS = envNum("SWARM_VERSION_CHECK_MS", 5 * 6e4);
687
+ SWARM_SWEEP_INTERVAL_MS = envNum("SWARM_SWEEP_INTERVAL_MS", 10 * 6e4);
688
+ DRONE_CHANNEL_ENABLED = process.env.SWARM_DRONE_CHANNEL !== "0";
689
+ DRONE_CHANNEL_PORT = Number(process.env.SWARM_DRONE_CHANNEL_PORT) || 7421;
690
+ DRONE_CHANNEL_URL = process.env.SWARM_DRONE_CHANNEL_URL || `http://127.0.0.1:${DRONE_CHANNEL_PORT}`;
691
+ DRONE_CHANNEL_SECRET_PATH = process.env.SWARM_DRONE_CHANNEL_SECRET || join(SWARM_HOME, "drone-channel.key");
692
+ DRONE_CHANNEL_GRACE_MS = envNum("SWARM_DRONE_CHANNEL_GRACE_MS", 12e4);
693
+ HEARTBEAT_PATH = process.env.SWARM_HEARTBEAT || join(SWARM_HOME, "overmind.heartbeat");
694
+ HEARTBEAT_STALE_MS = Number(process.env.SWARM_HEARTBEAT_STALE_MS) || 3e4;
695
+ SWARM_REPO = process.env.SWARM_REPO || "";
696
+ SWARM_SCAN_DEPTH = Number(process.env.SWARM_SCAN_DEPTH) || 3;
697
+ SWARM_REPO_PATHS = (process.env.SWARM_REPOS || SWARM_REPO).split(",").map((s) => s.trim()).filter(Boolean);
698
+ TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
699
+ SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
700
+ TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
701
+ TASKLESS_TOKEN = process.env.TASKLESS_TOKEN || tokenFromFile();
702
+ TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
703
+ TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
704
+ TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
705
+ SWARM_VERIFY = process.env.SWARM_VERIFY === "ci" ? "ci" : "local";
706
+ SWARM_AUTO_REVIEW = process.env.SWARM_AUTO_REVIEW !== "0";
707
+ REVIEW_CUTOFF_PATH = process.env.SWARM_REVIEW_CUTOFF || join(SWARM_HOME, "review-cutoff.json");
708
+ SWARM_REVIEW_CAP = Math.trunc(envNum("SWARM_REVIEW_CAP", 1));
709
+ SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
710
+ WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
711
+ WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
712
+ SWARM_ANOMALY_RENOTIFY_MS = envNum("SWARM_ANOMALY_RENOTIFY_MS", 60 * 6e4);
713
+ SWARM_ANOMALY_MAX_REPEATS = Math.trunc(envNum("SWARM_ANOMALY_MAX_REPEATS", 3));
714
+ SWARM_ANOMALY_RUN_DOWN_WINDOW_MS = envNum("SWARM_ANOMALY_RUN_DOWN_WINDOW_MS", 24 * 60 * 6e4);
715
+ SWARM_ANOMALY_FAILED_SPIKE_COUNT = Math.trunc(envNum("SWARM_ANOMALY_FAILED_SPIKE_COUNT", 3));
716
+ SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS = envNum("SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS", 30 * 6e4);
717
+ SWARM_ANOMALY_QUEUE_STALLED_MS = envNum("SWARM_ANOMALY_QUEUE_STALLED_MS", 10 * 6e4);
718
+ SWARM_ANOMALY_PR_STUCK_MERGE_MS = envNum("SWARM_ANOMALY_PR_STUCK_MERGE_MS", 30 * 6e4);
719
+ SWARM_ANOMALY_PR_NO_REVIEWER_MS = envNum("SWARM_ANOMALY_PR_NO_REVIEWER_MS", 20 * 6e4);
720
+ SWARM_ANOMALY_QUESTION_UNANSWERED_MS = envNum("SWARM_ANOMALY_QUESTION_UNANSWERED_MS", 20 * 6e4);
721
+ SWARM_ANOMALY_OUTBOX_STUCK_MS = envNum("SWARM_ANOMALY_OUTBOX_STUCK_MS", 10 * 6e4);
722
+ SWARM_ANOMALY_RESOURCE_GATE_STALL_MS = envNum("SWARM_ANOMALY_RESOURCE_GATE_STALL_MS", 5 * 6e4);
723
+ SWARM_WATCHDOG_STALL_MS = envNum("SWARM_WATCHDOG_STALL_MS", 15 * 6e4);
724
+ }
725
+ });
600
726
 
601
727
  // src/names.ts
602
- var NAME_LIVE = new Set(ACTIVE_NAME_STATUSES);
603
728
  function pickName(rows) {
604
729
  const live = /* @__PURE__ */ new Set();
605
730
  const lastUse = /* @__PURE__ */ new Map();
@@ -619,21 +744,16 @@ function pickName(rows) {
619
744
  }
620
745
  }
621
746
  }
747
+ var NAME_LIVE;
748
+ var init_names2 = __esm({
749
+ "src/names.ts"() {
750
+ "use strict";
751
+ init_src();
752
+ NAME_LIVE = new Set(ACTIVE_NAME_STATUSES);
753
+ }
754
+ });
622
755
 
623
756
  // src/storm.ts
624
- var numEnv = (name, def) => {
625
- const v = Number(process.env[name]);
626
- return Number.isFinite(v) && v > 0 ? v : def;
627
- };
628
- var DEFAULT_STORM_CONFIG = {
629
- deathThreshold: numEnv("SWARM_STORM_DEATHS", 2),
630
- windowMs: numEnv("SWARM_STORM_WINDOW_MS", 5 * 60 * 1e3),
631
- spawnFailThreshold: numEnv("SWARM_STORM_SPAWN_FAILS", 2),
632
- probeIntervalMs: numEnv("SWARM_STORM_PROBE_INTERVAL_MS", 60 * 1e3),
633
- probeSuccessesToClear: numEnv("SWARM_STORM_PROBE_CLEAR", 2),
634
- probeTimeoutMs: numEnv("SWARM_STORM_PROBE_TIMEOUT_MS", 20 * 1e3),
635
- restartCooldownMs: numEnv("SWARM_STORM_RESTART_COOLDOWN_MS", 3 * 60 * 1e3)
636
- };
637
757
  function encodeProbe(rec) {
638
758
  return JSON.stringify(rec);
639
759
  }
@@ -657,76 +777,35 @@ function lastProbeReason(recs) {
657
777
  }
658
778
  return null;
659
779
  }
780
+ var numEnv, DEFAULT_STORM_CONFIG;
781
+ var init_storm = __esm({
782
+ "src/storm.ts"() {
783
+ "use strict";
784
+ init_src();
785
+ numEnv = (name, def) => {
786
+ const v = Number(process.env[name]);
787
+ return Number.isFinite(v) && v > 0 ? v : def;
788
+ };
789
+ DEFAULT_STORM_CONFIG = {
790
+ deathThreshold: numEnv("SWARM_STORM_DEATHS", 2),
791
+ windowMs: numEnv("SWARM_STORM_WINDOW_MS", 5 * 60 * 1e3),
792
+ spawnFailThreshold: numEnv("SWARM_STORM_SPAWN_FAILS", 2),
793
+ probeIntervalMs: numEnv("SWARM_STORM_PROBE_INTERVAL_MS", 60 * 1e3),
794
+ probeSuccessesToClear: numEnv("SWARM_STORM_PROBE_CLEAR", 2),
795
+ probeTimeoutMs: numEnv("SWARM_STORM_PROBE_TIMEOUT_MS", 20 * 1e3),
796
+ restartCooldownMs: numEnv("SWARM_STORM_RESTART_COOLDOWN_MS", 3 * 60 * 1e3)
797
+ };
798
+ }
799
+ });
660
800
 
661
801
  // src/db.ts
662
- var TERMINAL_SQL = TERMINAL_STATUSES.map((s) => `'${s}'`).join(",");
663
- var RESOLVER_TERMINAL_SQL = [...TERMINAL_STATUSES, "stalled"].map((s) => `'${s}'`).join(",");
664
- var SCHEMA = `
665
- CREATE TABLE IF NOT EXISTS runs (
666
- id INTEGER PRIMARY KEY AUTOINCREMENT,
667
- task_slug TEXT NOT NULL,
668
- name TEXT,
669
- kind TEXT NOT NULL DEFAULT 'drone',
670
- hero_run_id INTEGER,
671
- repo TEXT,
672
- worktree_path TEXT,
673
- branch TEXT,
674
- status TEXT NOT NULL DEFAULT 'queued',
675
- pid INTEGER,
676
- area TEXT,
677
- depends_on TEXT,
678
- model TEXT,
679
- cost_usd REAL NOT NULL DEFAULT 0,
680
- pr_url TEXT,
681
- superseded_by INTEGER,
682
- decision TEXT,
683
- stop_reason TEXT,
684
- respawns INTEGER NOT NULL DEFAULT 0,
685
- resolves_run_id INTEGER,
686
- reviews_run_id INTEGER,
687
- review_verdict TEXT,
688
- review_cycle INTEGER NOT NULL DEFAULT 0,
689
- task_title TEXT,
690
- task_desc TEXT,
691
- task_url TEXT,
692
- priority INTEGER NOT NULL DEFAULT 0,
693
- base_branch TEXT,
694
- workspace_id TEXT,
695
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
696
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
697
- );
698
- CREATE TABLE IF NOT EXISTS questions (
699
- id INTEGER PRIMARY KEY AUTOINCREMENT,
700
- run_id INTEGER NOT NULL REFERENCES runs(id),
701
- text TEXT NOT NULL,
702
- options TEXT,
703
- status TEXT NOT NULL DEFAULT 'open',
704
- answer TEXT,
705
- answered_by TEXT,
706
- escalated INTEGER NOT NULL DEFAULT 0,
707
- asked_at TEXT NOT NULL DEFAULT (datetime('now')),
708
- answered_at TEXT
709
- );
710
- CREATE TABLE IF NOT EXISTS events (
711
- id INTEGER PRIMARY KEY AUTOINCREMENT,
712
- run_id INTEGER REFERENCES runs(id),
713
- kind TEXT NOT NULL,
714
- text TEXT,
715
- actor TEXT,
716
- at TEXT NOT NULL DEFAULT (datetime('now'))
717
- );
718
- CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
719
- CREATE INDEX IF NOT EXISTS idx_runs_area ON runs(area);
720
- CREATE INDEX IF NOT EXISTS idx_questions_run ON questions(run_id);
721
- CREATE INDEX IF NOT EXISTS idx_questions_status ON questions(status);
722
- CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id);
723
- -- \u043C\u0430\u0440\u043A\u0435\u0440 \u0432\u044B\u043F\u043E\u043B\u043D\u0435\u043D\u043D\u044B\u0445 \u0438\u043C\u043F\u043E\u0440\u0442\u043E\u0432 \u0438\u0441\u0442\u043E\u0440\u0438\u0438 \u0438\u0437 \u0434\u0440\u0443\u0433\u0438\u0445 \u0411\u0414 (\u0438\u0434\u0435\u043C\u043F\u043E\u0442\u0435\u043D\u0442\u043D\u043E\u0441\u0442\u044C importDb)
724
- CREATE TABLE IF NOT EXISTS imports (
725
- source TEXT PRIMARY KEY,
726
- run_count INTEGER NOT NULL DEFAULT 0,
727
- imported_at TEXT NOT NULL DEFAULT (datetime('now'))
728
- );
729
- `;
802
+ var db_exports = {};
803
+ __export(db_exports, {
804
+ SwarmDb: () => SwarmDb
805
+ });
806
+ import { createRequire as createRequire2 } from "module";
807
+ import { existsSync as existsSync8 } from "fs";
808
+ import { resolve as resolve2 } from "path";
730
809
  function rowToQuestion(row) {
731
810
  return {
732
811
  id: row.id,
@@ -789,7 +868,7 @@ function isForeignKeyError(err) {
789
868
  return err instanceof Error && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY";
790
869
  }
791
870
  function loadBetterSqlite3() {
792
- const require2 = createRequire(import.meta.url);
871
+ const require2 = createRequire2(import.meta.url);
793
872
  try {
794
873
  return require2("better-sqlite3");
795
874
  } catch (err) {
@@ -798,668 +877,753 @@ function loadBetterSqlite3() {
798
877
  );
799
878
  }
800
879
  }
801
- var SwarmDb = class {
802
- db;
803
- defaultRepo;
804
- databaseCtor;
805
- // для importDb (второй Database, source)
806
- constructor(path = "swarm.db", defaultRepo = "") {
807
- this.defaultRepo = normalizeRepo(defaultRepo);
808
- this.databaseCtor = loadBetterSqlite3();
809
- this.db = new this.databaseCtor(path);
810
- this.db.exec("PRAGMA journal_mode = WAL;");
811
- this.db.exec(SCHEMA);
812
- for (const alter of [
813
- "ALTER TABLE questions ADD COLUMN options TEXT",
814
- "ALTER TABLE questions ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0",
815
- "ALTER TABLE questions ADD COLUMN answered_by TEXT",
816
- "ALTER TABLE runs ADD COLUMN superseded_by INTEGER",
817
- "ALTER TABLE runs ADD COLUMN decision TEXT",
818
- "ALTER TABLE runs ADD COLUMN stop_reason TEXT",
819
- "ALTER TABLE runs ADD COLUMN repo TEXT",
820
- "ALTER TABLE runs ADD COLUMN name TEXT",
821
- "ALTER TABLE runs ADD COLUMN respawns INTEGER NOT NULL DEFAULT 0",
822
- "ALTER TABLE runs ADD COLUMN resolves_run_id INTEGER",
823
- "ALTER TABLE runs ADD COLUMN depends_on TEXT",
824
- "ALTER TABLE runs ADD COLUMN task_title TEXT",
825
- "ALTER TABLE runs ADD COLUMN task_desc TEXT",
826
- "ALTER TABLE runs ADD COLUMN task_url TEXT",
827
- // Иерархия роя (TASWM-78): роль прогона (drone|hero) и ссылка на прогон-героя.
828
- "ALTER TABLE runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'drone'",
829
- "ALTER TABLE runs ADD COLUMN hero_run_id INTEGER",
830
- "ALTER TABLE events ADD COLUMN actor TEXT",
831
- // Авто-ревью (TASWM-148): прогон-ревьюер (reviews_run_id → review-прогон) и
832
- // вердикт (approve|changes), который ревьюер пишет на проверяемый прогон.
833
- "ALTER TABLE runs ADD COLUMN reviews_run_id INTEGER",
834
- "ALTER TABLE runs ADD COLUMN review_verdict TEXT",
835
- // Авто-ревью v2 (TASWM-162): номер цикла ревью — пере-ревью после фикс-итерации.
836
- "ALTER TABLE runs ADD COLUMN review_cycle INTEGER NOT NULL DEFAULT 0",
837
- // Вычисляемая фаза ревью (TASWM-230): человеко-фраза «чего ждёт PR», её считает демон.
838
- "ALTER TABLE runs ADD COLUMN review_phase TEXT",
839
- // Приоритетная очередь (TASWM-269): явный приоритет прогона, выше — раньше из queued.
840
- "ALTER TABLE runs ADD COLUMN priority INTEGER NOT NULL DEFAULT 0",
841
- // Базовая ветка PR (TASWM-285): куда дрон открывает PR и от чего ответвляется.
842
- "ALTER TABLE runs ADD COLUMN base_branch TEXT",
843
- // Воркспейс задачи прогона (TASWM-288): им пинятся задачные MCP-вызовы прогона.
844
- "ALTER TABLE runs ADD COLUMN workspace_id TEXT"
845
- ]) {
846
- try {
847
- this.db.exec(alter);
848
- } catch {
880
+ var TERMINAL_SQL, RESOLVER_TERMINAL_SQL, SCHEMA, SwarmDb;
881
+ var init_db = __esm({
882
+ "src/db.ts"() {
883
+ "use strict";
884
+ init_config();
885
+ init_names2();
886
+ init_storm();
887
+ init_src();
888
+ TERMINAL_SQL = TERMINAL_STATUSES.map((s) => `'${s}'`).join(",");
889
+ RESOLVER_TERMINAL_SQL = [...TERMINAL_STATUSES, "stalled"].map((s) => `'${s}'`).join(",");
890
+ SCHEMA = `
891
+ CREATE TABLE IF NOT EXISTS runs (
892
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
893
+ task_slug TEXT NOT NULL,
894
+ name TEXT,
895
+ kind TEXT NOT NULL DEFAULT 'drone',
896
+ hero_run_id INTEGER,
897
+ repo TEXT,
898
+ worktree_path TEXT,
899
+ branch TEXT,
900
+ status TEXT NOT NULL DEFAULT 'queued',
901
+ pid INTEGER,
902
+ area TEXT,
903
+ depends_on TEXT,
904
+ model TEXT,
905
+ cost_usd REAL NOT NULL DEFAULT 0,
906
+ pr_url TEXT,
907
+ superseded_by INTEGER,
908
+ decision TEXT,
909
+ stop_reason TEXT,
910
+ respawns INTEGER NOT NULL DEFAULT 0,
911
+ resolves_run_id INTEGER,
912
+ reviews_run_id INTEGER,
913
+ review_verdict TEXT,
914
+ review_cycle INTEGER NOT NULL DEFAULT 0,
915
+ task_title TEXT,
916
+ task_desc TEXT,
917
+ task_url TEXT,
918
+ priority INTEGER NOT NULL DEFAULT 0,
919
+ base_branch TEXT,
920
+ workspace_id TEXT,
921
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
922
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
923
+ );
924
+ CREATE TABLE IF NOT EXISTS questions (
925
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
926
+ run_id INTEGER NOT NULL REFERENCES runs(id),
927
+ text TEXT NOT NULL,
928
+ options TEXT,
929
+ status TEXT NOT NULL DEFAULT 'open',
930
+ answer TEXT,
931
+ answered_by TEXT,
932
+ escalated INTEGER NOT NULL DEFAULT 0,
933
+ asked_at TEXT NOT NULL DEFAULT (datetime('now')),
934
+ answered_at TEXT
935
+ );
936
+ CREATE TABLE IF NOT EXISTS events (
937
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
938
+ run_id INTEGER REFERENCES runs(id),
939
+ kind TEXT NOT NULL,
940
+ text TEXT,
941
+ actor TEXT,
942
+ at TEXT NOT NULL DEFAULT (datetime('now'))
943
+ );
944
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
945
+ CREATE INDEX IF NOT EXISTS idx_runs_area ON runs(area);
946
+ CREATE INDEX IF NOT EXISTS idx_questions_run ON questions(run_id);
947
+ CREATE INDEX IF NOT EXISTS idx_questions_status ON questions(status);
948
+ CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id);
949
+ -- \u043C\u0430\u0440\u043A\u0435\u0440 \u0432\u044B\u043F\u043E\u043B\u043D\u0435\u043D\u043D\u044B\u0445 \u0438\u043C\u043F\u043E\u0440\u0442\u043E\u0432 \u0438\u0441\u0442\u043E\u0440\u0438\u0438 \u0438\u0437 \u0434\u0440\u0443\u0433\u0438\u0445 \u0411\u0414 (\u0438\u0434\u0435\u043C\u043F\u043E\u0442\u0435\u043D\u0442\u043D\u043E\u0441\u0442\u044C importDb)
950
+ CREATE TABLE IF NOT EXISTS imports (
951
+ source TEXT PRIMARY KEY,
952
+ run_count INTEGER NOT NULL DEFAULT 0,
953
+ imported_at TEXT NOT NULL DEFAULT (datetime('now'))
954
+ );
955
+ `;
956
+ SwarmDb = class {
957
+ db;
958
+ defaultRepo;
959
+ databaseCtor;
960
+ // для importDb (второй Database, source)
961
+ constructor(path = "swarm.db", defaultRepo = "") {
962
+ this.defaultRepo = normalizeRepo(defaultRepo);
963
+ this.databaseCtor = loadBetterSqlite3();
964
+ this.db = new this.databaseCtor(path);
965
+ this.db.exec("PRAGMA journal_mode = WAL;");
966
+ this.db.exec(SCHEMA);
967
+ for (const alter of [
968
+ "ALTER TABLE questions ADD COLUMN options TEXT",
969
+ "ALTER TABLE questions ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0",
970
+ "ALTER TABLE questions ADD COLUMN answered_by TEXT",
971
+ "ALTER TABLE runs ADD COLUMN superseded_by INTEGER",
972
+ "ALTER TABLE runs ADD COLUMN decision TEXT",
973
+ "ALTER TABLE runs ADD COLUMN stop_reason TEXT",
974
+ "ALTER TABLE runs ADD COLUMN repo TEXT",
975
+ "ALTER TABLE runs ADD COLUMN name TEXT",
976
+ "ALTER TABLE runs ADD COLUMN respawns INTEGER NOT NULL DEFAULT 0",
977
+ "ALTER TABLE runs ADD COLUMN resolves_run_id INTEGER",
978
+ "ALTER TABLE runs ADD COLUMN depends_on TEXT",
979
+ "ALTER TABLE runs ADD COLUMN task_title TEXT",
980
+ "ALTER TABLE runs ADD COLUMN task_desc TEXT",
981
+ "ALTER TABLE runs ADD COLUMN task_url TEXT",
982
+ // Иерархия роя (TASWM-78): роль прогона (drone|hero) и ссылка на прогон-героя.
983
+ "ALTER TABLE runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'drone'",
984
+ "ALTER TABLE runs ADD COLUMN hero_run_id INTEGER",
985
+ "ALTER TABLE events ADD COLUMN actor TEXT",
986
+ // Авто-ревью (TASWM-148): прогон-ревьюер (reviews_run_id → review-прогон) и
987
+ // вердикт (approve|changes), который ревьюер пишет на проверяемый прогон.
988
+ "ALTER TABLE runs ADD COLUMN reviews_run_id INTEGER",
989
+ "ALTER TABLE runs ADD COLUMN review_verdict TEXT",
990
+ // Авто-ревью v2 (TASWM-162): номер цикла ревью — пере-ревью после фикс-итерации.
991
+ "ALTER TABLE runs ADD COLUMN review_cycle INTEGER NOT NULL DEFAULT 0",
992
+ // Вычисляемая фаза ревью (TASWM-230): человеко-фраза «чего ждёт PR», её считает демон.
993
+ "ALTER TABLE runs ADD COLUMN review_phase TEXT",
994
+ // Приоритетная очередь (TASWM-269): явный приоритет прогона, выше — раньше из queued.
995
+ "ALTER TABLE runs ADD COLUMN priority INTEGER NOT NULL DEFAULT 0",
996
+ // Базовая ветка PR (TASWM-285): куда дрон открывает PR и от чего ответвляется.
997
+ "ALTER TABLE runs ADD COLUMN base_branch TEXT",
998
+ // Воркспейс задачи прогона (TASWM-288): им пинятся задачные MCP-вызовы прогона.
999
+ "ALTER TABLE runs ADD COLUMN workspace_id TEXT"
1000
+ ]) {
1001
+ try {
1002
+ this.db.exec(alter);
1003
+ } catch {
1004
+ }
1005
+ }
1006
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_repo ON runs(repo)");
1007
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_hero ON runs(hero_run_id)");
1008
+ if (this.defaultRepo) {
1009
+ this.db.prepare("UPDATE runs SET repo = ? WHERE repo IS NULL").run(this.defaultRepo);
1010
+ }
849
1011
  }
850
- }
851
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_repo ON runs(repo)");
852
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_hero ON runs(hero_run_id)");
853
- if (this.defaultRepo) {
854
- this.db.prepare("UPDATE runs SET repo = ? WHERE repo IS NULL").run(this.defaultRepo);
855
- }
856
- }
857
- close() {
858
- this.db.close();
859
- }
860
- // --- runs ---
861
- createRun(input) {
862
- const name = input.name ?? pickName(this.db.prepare("SELECT id, name, status FROM runs").all());
863
- const res = this.db.prepare(
864
- "INSERT INTO runs (task_slug, name, kind, hero_run_id, repo, area, depends_on, model, decision, resolves_run_id, reviews_run_id, review_cycle, priority, base_branch, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
865
- ).run(
866
- input.task_slug,
867
- name,
868
- input.kind ?? "drone",
869
- // роль прогона: дефолт — рабочий дрон
870
- input.hero_run_id ?? null,
871
- // отряд героя (задел иерархии, TASWM-78)
872
- normalizeRepo(input.repo || this.defaultRepo),
873
- // канон при записи → один area-lock на физрепо
874
- input.area ?? null,
875
- input.depends_on && input.depends_on.length ? JSON.stringify(input.depends_on) : null,
876
- input.model ?? null,
877
- input.decision ? JSON.stringify(input.decision) : null,
878
- input.resolves_run_id ?? null,
879
- input.reviews_run_id ?? null,
880
- input.review_cycle ?? 0,
881
- // цикл ревью для ревьюера (авто-ревью v2, TASWM-162); обычный прогон — 0
882
- input.priority ?? 0,
883
- // явный приоритет в очереди (TASWM-269); обычный прогон — 0
884
- input.base_branch ?? null,
885
- // базовая ветка PR (TASWM-285); null → проектная/дефолтная
886
- input.workspace_id ?? null
887
- // воркспейс задачи прогона (TASWM-288); null → глобальный пин
888
- );
889
- const id = Number(res.lastInsertRowid);
890
- const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.reviews_run_id ? `\u0440\u0435\u0432\u044C\u044E\u0435\u0440 \u0434\u043B\u044F #${input.reviews_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
891
- this.addEvent(id, "spawned", spawnNote);
892
- return this.getRun(id);
893
- }
894
- /**
895
- * Завести стаб-строку прогона #id, если её ещё нет (TASWM-263, «ревьюеры не могут сдать
896
- * работу»). Корень: демон в client-режиме (SWARM_BACKEND=client, опубликованный npm-пакет)
897
- * ведёт runs в in-memory зеркале SwarmClient + проде (swarm_run_upsert), а НЕ в этой sqlite —
898
- * но дрон-MCP-сервер (droneServer.ts:runDroneServer) ВСЕГДА открывает SwarmDb на локальный
899
- * DB_PATH, независимо от бэкенда демона. Спавненный из прода прогон (claim/hydrate) в этой
900
- * БД строки не получает → первый же write-путь (report_step/ask/done) падает на FK
901
- * (events.run_id/questions.run_id REFERENCES runs(id)), а ревьюер не может ни отчитаться,
902
- * ни донести вердикт. INSERT OR IGNORE — идемпотентно (конкурентный вызов/уже заведённая
903
- * строка не трогаются, id explicit-инсертом двигает автоинкремент дальше, коллизий с
904
- * будущими createRun не будет). status='running' — прогон уже фактически работает (иначе
905
- * не звал бы write-путь). kind/reviews_run_id/hero_run_id — ЛУЧШЕЕ известное из данных
906
- * спавна (runDroneServer читает их из SWARM_RUN_KIND/SWARM_REVIEWS_RUN_ID/SWARM_HERO_RUN_ID,
907
- * см. launcher.ts): дефолт "drone" без них исказил бы done() ревьюера — тот принял бы свой
908
- * вердикт за обычную сдачу PR (неверная ветка кода в droneServer.done). name — та же
909
- * история (TASWM-265): без явного имени колонка оставалась NULL, а rowToRun подставлял
910
- * `#id` — droneServer.done() подписывал отчёт номером прогона вместо имени дрона (номер
911
- * локальный и читателю задачи ничего не говорит). Честный fallback — заглушка
912
- * "(имя неизвестно)", а не номер, выданный за имя.
913
- */
914
- ensureRun(input) {
915
- this.db.prepare(
916
- "INSERT OR IGNORE INTO runs (id, task_slug, kind, status, name, reviews_run_id, hero_run_id) VALUES (?, ?, ?, 'running', ?, ?, ?)"
917
- ).run(
918
- input.id,
919
- input.task_slug,
920
- input.kind ?? "drone",
921
- input.name || "(\u0438\u043C\u044F \u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u043E)",
922
- input.reviews_run_id ?? null,
923
- input.hero_run_id ?? null
924
- );
925
- }
926
- getRun(id) {
927
- const row = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
928
- return row ? rowToRun(row) : null;
929
- }
930
- /**
931
- * Стабильный ключ прогона (TASWM-295). У sqlite он тривиален: id — настоящий PK, живущий
932
- * в файле, поэтому ключ это и есть номер с префиксом. Префикс не декоративный: он не даёт
933
- * перепутать пространства ключей, если токен канала, выписанный демоном на sqlite-бэкенде,
934
- * попадёт в демона на client-бэкенде (там ключи вида `p:<cuid>`) — резолв просто не найдёт
935
- * прогон, вместо того чтобы наткнуться на чужой с тем же номером.
936
- */
937
- runKeyOf(id) {
938
- return this.getRun(id) ? `local:${id}` : null;
939
- }
940
- runByKey(key) {
941
- const m = /^local:(\d+)$/.exec(key);
942
- return m ? this.getRun(Number(m[1])) : null;
943
- }
944
- listRuns(status) {
945
- const rows = status ? this.db.prepare("SELECT * FROM runs WHERE status = ? ORDER BY id DESC").all(status) : this.db.prepare("SELECT * FROM runs ORDER BY id DESC").all();
946
- return rows.map(rowToRun);
947
- }
948
- /** Прогоны отряда Героя-лейтенанта #heroId (его дроны: hero_run_id = heroId), новые сверху (TASWM-81). */
949
- squadRuns(heroId) {
950
- const rows = this.db.prepare("SELECT * FROM runs WHERE hero_run_id = ? ORDER BY id DESC").all(heroId);
951
- return rows.map(rowToRun);
952
- }
953
- updateRun(id, patch) {
954
- const fields = Object.keys(patch);
955
- if (fields.length === 0) return this.getRun(id);
956
- const set = fields.map((f) => `${f} = ?`).join(", ");
957
- const vals = fields.map((f) => {
958
- const v = patch[f];
959
- if (f === "depends_on") {
960
- const arr = v;
961
- return arr && arr.length ? JSON.stringify(arr) : null;
1012
+ close() {
1013
+ this.db.close();
962
1014
  }
963
- return v;
964
- });
965
- this.db.prepare(`UPDATE runs SET ${set}, updated_at = datetime('now') WHERE id = ?`).run(...vals, id);
966
- return this.getRun(id);
967
- }
968
- /**
969
- * Атомарно увеличить счётчик авто-оживлений прогона (после смерти дрона рой
970
- * сам его respawn'ит/--continue'ит). Возвращает новое значение — по нему
971
- * death-detect решает, не исчерпан ли лимит (2 оживления на прогон).
972
- */
973
- incRespawn(id) {
974
- this.db.prepare("UPDATE runs SET respawns = respawns + 1, updated_at = datetime('now') WHERE id = ?").run(id);
975
- const row = this.db.prepare("SELECT respawns FROM runs WHERE id = ?").get(id);
976
- return row?.respawns ?? 0;
977
- }
978
- /**
979
- * Остановить прогон оператором: статус "stopped" + причина + событие.
980
- * Прогон НЕ удаляется — остаётся в БД и в истории навсегда (в отличие от failed
981
- * с выброшенным worktree мы храним причину и запись). Kill/cleanup — снаружи (cancelRun).
982
- */
983
- cancel(id, reason) {
984
- const run = this.updateRun(id, { status: "stopped", stop_reason: reason });
985
- if (run) this.addEvent(id, "stopped", reason);
986
- return run;
987
- }
988
- /**
989
- * Живой прогон-резолвер конфликта для review-прогона #id (если есть). «Живой» =
990
- * не в терминальном статусе: пока такой в работе/на ревью — второго резолвера на тот
991
- * же PR не поднимаем (иначе на каждый тик плодили бы дронов). stalled/failed/done —
992
- * не живые: резолвер сорвался/добил → можно, если не исчёрпан лимит попыток.
993
- */
994
- activeResolverFor(reviewRunId) {
995
- const row = this.db.prepare(
996
- `SELECT * FROM runs WHERE resolves_run_id = ? AND status NOT IN (${RESOLVER_TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
997
- ).get(reviewRunId);
998
- return row ? rowToRun(row) : null;
999
- }
1000
- /** Сколько всего резолверов уже поднимали на этот review-PR — потолок авто-попыток. */
1001
- resolverCountFor(reviewRunId) {
1002
- const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE resolves_run_id = ?").get(reviewRunId);
1003
- return row.n;
1004
- }
1005
- /**
1006
- * Сколько ЖИВЫХ/УСПЕШНЫХ ревьюеров уже поднимали на этот review-PR В ТЕКУЩЕМ ЦИКЛЕ
1007
- * (авто-ревью, TASWM-148; скоуп по циклу — авто-ревью v2, TASWM-162) — one-shot
1008
- * идемпотентность БЕЗ лимита попыток (в отличие от резолвера): >0 → в этом цикле ревьюер
1009
- * уже есть, повторно не спавним. failed/stalled ревьюеры (worktree/launch сорвались ДО
1010
- * вердикта) в счёт НЕ идут — иначе один транзиентный сбой спавна (диск/git/gh) навсегда
1011
- * блокировал бы ревью цикла без шанса на повтор. stopped/superseded — тоже НЕ считаем
1012
- * (TASWM-253): stopped — оператор закрыл зомби-ревьюера руками (POST /swarm/runs/cleanup-
1013
- * dead), superseded — оператор явно сбросил попытки ревью (POST /swarm/review/retry,
1014
- * см. packages/api/src/swarm/routes.ts); оба варианта означают «эта попытка не в счёт»,
1015
- * а не «ревьюер ещё жив». РАНЬШЕ stopped
1016
- * тут не исключался — операторский kill зомби-ревьюера навсегда держал гейт закрытым
1017
- * (reviewerCountFor>0 бесконечно), хотя счётчик попыток (ниже) уже не рос. Скоуп «текущий
1018
- * цикл» = review_cycle ревьюера равен review_cycle проверяемого прогона: после фикс-
1019
- * итерации исполнитель бампит цикл (droneServer.done), ревьюеры прошлого цикла отваливаются
1020
- * из счёта → гейт снова открыт для ре-ревьюера (без него бейдж «changes» держал бы слот
1021
- * навсегда).
1022
- */
1023
- reviewerCountFor(reviewRunId) {
1024
- const row = this.db.prepare(
1025
- "SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status NOT IN ('failed','stalled','stopped','superseded') AND review_cycle = COALESCE((SELECT review_cycle FROM runs WHERE id = ?), 0)"
1026
- ).get(reviewRunId, reviewRunId);
1027
- return row.n;
1028
- }
1029
- /**
1030
- * Жёсткий кап ревью-спавнов (TASWM-204, карусель мёртвых ревьюеров): сколько ревьюеров
1031
- * ВСЕГО (любой статус, включая failed/stalled/stopped — специально в отличие от
1032
- * reviewerCountFor выше) поднималось на review-прогон #reviewRunId В ТЕКУЩЕМ ЦИКЛЕ (скоуп
1033
- * по циклу — авто-ревью v2, TASWM-162). Зеркалит resolverCountFor — плоский потолок
1034
- * попыток на цикл. Именно это ловит карусель: reviewerCountFor исключает failed/stalled
1035
- * (чтобы ОДИН транзиентный сбой не блокировал ревью), но тем самым допускает бесконечный
1036
- * ретрай, если ревьюер стабильно умирает без done(). Скоуп по циклу — чтобы кап считался
1037
- * заново на каждой фикс-итерации, а не суммировал попытки прошлых (успешно завершённых)
1038
- * циклов. superseded — ЕДИНСТВЕННОЕ исключение из капа (TASWM-253): это статус, которым
1039
- * POST /swarm/review/retry помечает попытки при операторском «попробовать ревью заново» —
1040
- * без исключения сброс не мог бы ничего сбросить (COUNT(*) слеп к статусу). failed/
1041
- * stalled/stopped по-прежнему считаются — иначе кап потерял бы смысл на органических
1042
- * смертях (ровно то, для чего его вводили в TASWM-204).
1043
- */
1044
- reviewerAttemptCountFor(reviewRunId) {
1045
- const row = this.db.prepare(
1046
- "SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status != 'superseded' AND review_cycle = COALESCE((SELECT review_cycle FROM runs WHERE id = ?), 0)"
1047
- ).get(reviewRunId, reviewRunId);
1048
- return row.n;
1049
- }
1050
- /** Последняя (любой статус) попытка ревьюера ТЕКУЩЕГО ЦИКЛА на review-прогон #reviewRunId
1051
- * (TASWM-196; скоуп по циклу — TASWM-162) — см. store.ts:lastReviewerAttemptFor. */
1052
- lastReviewerAttemptFor(reviewRunId) {
1053
- const row = this.db.prepare(
1054
- "SELECT * FROM runs WHERE reviews_run_id = ? AND review_cycle = COALESCE((SELECT review_cycle FROM runs WHERE id = ?), 0) ORDER BY id DESC LIMIT 1"
1055
- ).get(reviewRunId, reviewRunId);
1056
- return row ? rowToRun(row) : null;
1057
- }
1058
- /**
1059
- * Живые/ждущие ревьюер-прогоны review-прогона #reviewRunId (TASWM-204): для
1060
- * closeReviewersOnMerge — PR смержен раньше вердикта, зависшего ревьюера пора закрыть,
1061
- * не дожидаясь done(). Точечный WHERE, не полный скан listRuns() (code-review TASWM-204).
1062
- */
1063
- activeReviewersFor(reviewRunId) {
1064
- const rows = this.db.prepare(`SELECT * FROM runs WHERE reviews_run_id = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC`).all(reviewRunId);
1065
- return rows.map(rowToRun);
1066
- }
1067
- /**
1068
- * Последний живой (не терминальный) прогон по slug — для swarm cancel. Служебные
1069
- * сабраны (reviews_run_id/resolves_run_id — ревьюер/резолвер, TASWM-273) исключены:
1070
- * они заводятся с ТЕМ ЖЕ task_slug, что и родительский прогон, и всегда бо́льшим id
1071
- * (спавнятся позже) — без исключения `ORDER BY id DESC LIMIT 1` подсовывал бы
1072
- * cancelRun паузнутого ревьюера (paused НЕ терминален) вместо реального прогона,
1073
- * которого целился остановить оператор/`swarm cancel <slug>`: безусловный removeWorktree
1074
- * в cancelRun сносил бы worktree ревьюера, ждущего CI, и переводил его в stopped мимо
1075
- * обычной цепочки паузы/побудки — найдено при разборе TASWM-273 (PR #564 завис в stalled
1076
- * без вердикта: worktree ревьюера пропал так, что статус до этого не менялся). Сабран
1077
- * отменяют через свой id, не через slug — swarm cancel всегда целится в родительский прогон.
1078
- */
1079
- activeRunForSlug(slug) {
1080
- const row = this.db.prepare(
1081
- `SELECT * FROM runs WHERE task_slug = ? AND status NOT IN (${TERMINAL_SQL}) AND reviews_run_id IS NULL AND resolves_run_id IS NULL ORDER BY id DESC LIMIT 1`
1082
- ).get(slug);
1083
- return row ? rowToRun(row) : null;
1084
- }
1085
- /**
1086
- * Зоны, занятые сейчас работающими дронами — для конфликт-осознанной раздачи.
1087
- * С repo — только в этом репозитории (одна area в разных репо не конфликтует).
1088
- * Без repo — по всему рою (обратная совместимость).
1089
- */
1090
- runningAreas(repo) {
1091
- const base3 = "SELECT DISTINCT area FROM runs WHERE status IN ('running','blocked') AND area IS NOT NULL";
1092
- const rows = repo === void 0 ? this.db.prepare(base3).all() : this.db.prepare(`${base3} AND repo = ?`).all(repo);
1093
- return rows.map((r) => r.area);
1094
- }
1095
- /**
1096
- * Из depends_on прогона — слаги зависимостей, ещё НЕ закрытых (нет прогона со
1097
- * статусом done). Пусто → все выполнены, дрона можно спавнить. Явная зависимость
1098
- * (spawn --after) отдельна от area-lock: area освобождается на review (до мержа),
1099
- * а тут ждём именно done (смерж), чтобы следующий дрон стартовал от кода предыдущего.
1100
- * Несуществующий/зациклённый слаг просто никогда не станет done → прогон вечно ждёт,
1101
- * но очередь не виснет (гейт — пропуск в цикле, не блокировка).
1102
- */
1103
- unmetDeps(depends_on) {
1104
- if (!depends_on || depends_on.length === 0) return [];
1105
- const rows = this.db.prepare("SELECT DISTINCT task_slug FROM runs WHERE status = 'done' AND resolves_run_id IS NULL").all();
1106
- const done = new Set(rows.map((r) => r.task_slug));
1107
- return depends_on.filter((slug) => !done.has(slug));
1108
- }
1109
- /** Все репо, засветившиеся в прогонах — whitelist для валидации spawn из веба. */
1110
- distinctRepos() {
1111
- const rows = this.db.prepare("SELECT DISTINCT repo FROM runs WHERE repo IS NOT NULL AND repo <> ''").all();
1112
- return rows.map((r) => r.repo);
1113
- }
1114
- // --- импорт истории из другой БД (объединение роёв) ---
1115
- /**
1116
- * Идемпотентный перенос runs/events/questions из другой swarm-БД в эту.
1117
- * id прогонов ремапятся (старый→новый); run_id в events/questions и superseded_by
1118
- * в runs правятся по карте; всем импортируемым прогонам проставляется repo (норм.).
1119
- * pid НЕ переносим — чужой pid в объединённой БД бессмыслен и мог бы совпасть с
1120
- * живым процессом (ложный liveness); импортируемые прогоны исторические.
1121
- * Живые статусы нормализуем (running/blocked→stalled, queued→stopped) и открытые
1122
- * вопросы не тащим — иначе импорт даёт вечных зомби: без pid death-detect их
1123
- * пропускает, они навсегда держат (repo,area)-лок и «в работе» в метриках, а их
1124
- * открытые вопросы «воскрешают» архивный прогон через answer.
1125
- * Повторный вызов на том же source — no-op (маркер в таблице imports). Source
1126
- * открываем на ЗАПИСЬ (importDb только ЧИТАЕТ из него): все БД роя — WAL, а bun:sqlite
1127
- * с readonly НЕ открывает WAL-source без -shm (реальный перенос обычно = копия одного
1128
- * файла swarm.db без sidecar'ов → SQLITE_CANTOPEN). Несуществующий путь ловим existsSync
1129
- * ДО открытия — честная ошибка, а не пустой файл (rw создал бы его). Всё в одной
1130
- * транзакции: прервётся — маркер не встанет, повтор безопасен.
1131
- */
1132
- importDb(sourcePath, repo) {
1133
- const abs = resolve2(sourcePath);
1134
- const repoN = normalizeRepo(repo);
1135
- const already = this.db.prepare("SELECT run_count FROM imports WHERE source = ?").get(abs);
1136
- if (already) return { imported: false, runs: already.run_count };
1137
- if (!existsSync(abs)) throw new Error(`import-db: \u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442: ${abs}`);
1138
- const src = new this.databaseCtor(abs);
1139
- try {
1140
- const runs = src.prepare("SELECT * FROM runs ORDER BY id").all();
1141
- const events = src.prepare("SELECT * FROM events ORDER BY id").all();
1142
- const questions = src.prepare("SELECT * FROM questions ORDER BY id").all();
1143
- const txn = this.db.transaction(() => {
1144
- const idMap = /* @__PURE__ */ new Map();
1145
- const insRun = this.db.prepare(
1146
- "INSERT INTO runs (task_slug, repo, worktree_path, branch, status, area, model, cost_usd, pr_url, decision, stop_reason, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
1015
+ // --- runs ---
1016
+ createRun(input) {
1017
+ const name = input.name ?? pickName(this.db.prepare("SELECT id, name, status FROM runs").all());
1018
+ const res = this.db.prepare(
1019
+ "INSERT INTO runs (task_slug, name, kind, hero_run_id, repo, area, depends_on, model, decision, resolves_run_id, reviews_run_id, review_cycle, priority, base_branch, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
1020
+ ).run(
1021
+ input.task_slug,
1022
+ name,
1023
+ input.kind ?? "drone",
1024
+ // роль прогона: дефолт — рабочий дрон
1025
+ input.hero_run_id ?? null,
1026
+ // отряд героя (задел иерархии, TASWM-78)
1027
+ normalizeRepo(input.repo || this.defaultRepo),
1028
+ // канон при записи → один area-lock на физрепо
1029
+ input.area ?? null,
1030
+ input.depends_on && input.depends_on.length ? JSON.stringify(input.depends_on) : null,
1031
+ input.model ?? null,
1032
+ input.decision ? JSON.stringify(input.decision) : null,
1033
+ input.resolves_run_id ?? null,
1034
+ input.reviews_run_id ?? null,
1035
+ input.review_cycle ?? 0,
1036
+ // цикл ревью для ревьюера (авто-ревью v2, TASWM-162); обычный прогон — 0
1037
+ input.priority ?? 0,
1038
+ // явный приоритет в очереди (TASWM-269); обычный прогон — 0
1039
+ input.base_branch ?? null,
1040
+ // базовая ветка PR (TASWM-285); null → проектная/дефолтная
1041
+ input.workspace_id ?? null
1042
+ // воркспейс задачи прогона (TASWM-288); null → глобальный пин
1043
+ );
1044
+ const id = Number(res.lastInsertRowid);
1045
+ const spawnNote = input.resolves_run_id ? `\u0440\u0435\u0437\u043E\u043B\u0432\u0435\u0440 \u043A\u043E\u043D\u0444\u043B\u0438\u043A\u0442\u0430 \u0434\u043B\u044F #${input.resolves_run_id}` : input.reviews_run_id ? `\u0440\u0435\u0432\u044C\u044E\u0435\u0440 \u0434\u043B\u044F #${input.reviews_run_id}` : input.decision ? `\u043F\u0435\u0440\u0435\u0437\u0430\u043F\u0443\u0441\u043A \u0441 \u0440\u0435\u0448\u0435\u043D\u0438\u0435\u043C \xAB${input.decision.chosen}\xBB` : `run for ${input.task_slug}`;
1046
+ this.addEvent(id, "spawned", spawnNote);
1047
+ return this.getRun(id);
1048
+ }
1049
+ /**
1050
+ * Завести стаб-строку прогона #id, если её ещё нет (TASWM-263, «ревьюеры не могут сдать
1051
+ * работу»). Корень: демон в client-режиме (SWARM_BACKEND=client, опубликованный npm-пакет)
1052
+ * ведёт runs в in-memory зеркале SwarmClient + проде (swarm_run_upsert), а НЕ в этой sqlite —
1053
+ * но дрон-MCP-сервер (droneServer.ts:runDroneServer) ВСЕГДА открывает SwarmDb на локальный
1054
+ * DB_PATH, независимо от бэкенда демона. Спавненный из прода прогон (claim/hydrate) в этой
1055
+ * БД строки не получает → первый же write-путь (report_step/ask/done) падает на FK
1056
+ * (events.run_id/questions.run_id REFERENCES runs(id)), а ревьюер не может ни отчитаться,
1057
+ * ни донести вердикт. INSERT OR IGNORE — идемпотентно (конкурентный вызов/уже заведённая
1058
+ * строка не трогаются, id explicit-инсертом двигает автоинкремент дальше, коллизий с
1059
+ * будущими createRun не будет). status='running' — прогон уже фактически работает (иначе
1060
+ * не звал бы write-путь). kind/reviews_run_id/hero_run_id — ЛУЧШЕЕ известное из данных
1061
+ * спавна (runDroneServer читает их из SWARM_RUN_KIND/SWARM_REVIEWS_RUN_ID/SWARM_HERO_RUN_ID,
1062
+ * см. launcher.ts): дефолт "drone" без них исказил бы done() ревьюера — тот принял бы свой
1063
+ * вердикт за обычную сдачу PR (неверная ветка кода в droneServer.done). name — та же
1064
+ * история (TASWM-265): без явного имени колонка оставалась NULL, а rowToRun подставлял
1065
+ * `#id` — droneServer.done() подписывал отчёт номером прогона вместо имени дрона (номер
1066
+ * локальный и читателю задачи ничего не говорит). Честный fallback — заглушка
1067
+ * "(имя неизвестно)", а не номер, выданный за имя.
1068
+ */
1069
+ ensureRun(input) {
1070
+ this.db.prepare(
1071
+ "INSERT OR IGNORE INTO runs (id, task_slug, kind, status, name, reviews_run_id, hero_run_id) VALUES (?, ?, ?, 'running', ?, ?, ?)"
1072
+ ).run(
1073
+ input.id,
1074
+ input.task_slug,
1075
+ input.kind ?? "drone",
1076
+ input.name || "(\u0438\u043C\u044F \u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u043E)",
1077
+ input.reviews_run_id ?? null,
1078
+ input.hero_run_id ?? null
1147
1079
  );
1148
- for (const r of runs) {
1149
- const raw = r.status ?? "done";
1150
- let status = raw;
1151
- let stopReason = r.stop_reason ?? null;
1152
- let normEvent = null;
1153
- if (raw === "running" || raw === "blocked") {
1154
- status = "stalled";
1155
- normEvent = { kind: "stalled", text: "\u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D \u0438\u0437 \u0430\u0440\u0445\u0438\u0432\u0430: \u0441\u0442\u0430\u0442\u0443\u0441 \u043D\u043E\u0440\u043C\u0430\u043B\u0438\u0437\u043E\u0432\u0430\u043D" };
1156
- } else if (raw === "queued") {
1157
- status = "stopped";
1158
- stopReason = "\u0430\u0440\u0445\u0438\u0432\u043D\u044B\u0439 \u0438\u043C\u043F\u043E\u0440\u0442";
1159
- normEvent = { kind: "stopped", text: "\u0430\u0440\u0445\u0438\u0432\u043D\u044B\u0439 \u0438\u043C\u043F\u043E\u0440\u0442" };
1080
+ }
1081
+ getRun(id) {
1082
+ const row = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
1083
+ return row ? rowToRun(row) : null;
1084
+ }
1085
+ /**
1086
+ * Стабильный ключ прогона (TASWM-295). У sqlite он тривиален: id — настоящий PK, живущий
1087
+ * в файле, поэтому ключ это и есть номер с префиксом. Префикс не декоративный: он не даёт
1088
+ * перепутать пространства ключей, если токен канала, выписанный демоном на sqlite-бэкенде,
1089
+ * попадёт в демона на client-бэкенде (там ключи вида `p:<cuid>`) — резолв просто не найдёт
1090
+ * прогон, вместо того чтобы наткнуться на чужой с тем же номером.
1091
+ */
1092
+ runKeyOf(id) {
1093
+ return this.getRun(id) ? `local:${id}` : null;
1094
+ }
1095
+ runByKey(key) {
1096
+ const m = /^local:(\d+)$/.exec(key);
1097
+ return m ? this.getRun(Number(m[1])) : null;
1098
+ }
1099
+ listRuns(status) {
1100
+ const rows = status ? this.db.prepare("SELECT * FROM runs WHERE status = ? ORDER BY id DESC").all(status) : this.db.prepare("SELECT * FROM runs ORDER BY id DESC").all();
1101
+ return rows.map(rowToRun);
1102
+ }
1103
+ /** Прогоны отряда Героя-лейтенанта #heroId (его дроны: hero_run_id = heroId), новые сверху (TASWM-81). */
1104
+ squadRuns(heroId) {
1105
+ const rows = this.db.prepare("SELECT * FROM runs WHERE hero_run_id = ? ORDER BY id DESC").all(heroId);
1106
+ return rows.map(rowToRun);
1107
+ }
1108
+ updateRun(id, patch) {
1109
+ const fields = Object.keys(patch);
1110
+ if (fields.length === 0) return this.getRun(id);
1111
+ const set = fields.map((f) => `${f} = ?`).join(", ");
1112
+ const vals = fields.map((f) => {
1113
+ const v = patch[f];
1114
+ if (f === "depends_on") {
1115
+ const arr = v;
1116
+ return arr && arr.length ? JSON.stringify(arr) : null;
1160
1117
  }
1161
- const res = insRun.run(
1162
- r.task_slug,
1163
- repoN,
1164
- r.worktree_path ?? null,
1165
- r.branch ?? null,
1166
- status,
1167
- r.area ?? null,
1168
- r.model ?? null,
1169
- r.cost_usd ?? 0,
1170
- r.pr_url ?? null,
1171
- r.decision ?? null,
1172
- stopReason,
1173
- r.created_at ?? null,
1174
- r.updated_at ?? null
1175
- );
1176
- const newId = Number(res.lastInsertRowid);
1177
- idMap.set(r.id, newId);
1178
- if (normEvent) this.addEvent(newId, normEvent.kind, normEvent.text);
1179
- }
1180
- const setSup = this.db.prepare("UPDATE runs SET superseded_by = ? WHERE id = ?");
1181
- for (const r of runs) {
1182
- const sup = r.superseded_by;
1183
- if (sup != null && idMap.has(sup)) setSup.run(idMap.get(sup), idMap.get(r.id));
1118
+ return v;
1119
+ });
1120
+ this.db.prepare(`UPDATE runs SET ${set}, updated_at = datetime('now') WHERE id = ?`).run(...vals, id);
1121
+ return this.getRun(id);
1122
+ }
1123
+ /**
1124
+ * Атомарно увеличить счётчик авто-оживлений прогона (после смерти дрона рой
1125
+ * сам его respawn'ит/--continue'ит). Возвращает новое значение — по нему
1126
+ * death-detect решает, не исчерпан ли лимит (2 оживления на прогон).
1127
+ */
1128
+ incRespawn(id) {
1129
+ this.db.prepare("UPDATE runs SET respawns = respawns + 1, updated_at = datetime('now') WHERE id = ?").run(id);
1130
+ const row = this.db.prepare("SELECT respawns FROM runs WHERE id = ?").get(id);
1131
+ return row?.respawns ?? 0;
1132
+ }
1133
+ /**
1134
+ * Остановить прогон оператором: статус "stopped" + причина + событие.
1135
+ * Прогон НЕ удаляется — остаётся в БД и в истории навсегда (в отличие от failed
1136
+ * с выброшенным worktree мы храним причину и запись). Kill/cleanup — снаружи (cancelRun).
1137
+ */
1138
+ cancel(id, reason) {
1139
+ const run = this.updateRun(id, { status: "stopped", stop_reason: reason });
1140
+ if (run) this.addEvent(id, "stopped", reason);
1141
+ return run;
1142
+ }
1143
+ /**
1144
+ * Живой прогон-резолвер конфликта для review-прогона #id (если есть). «Живой» =
1145
+ * не в терминальном статусе: пока такой в работе/на ревью — второго резолвера на тот
1146
+ * же PR не поднимаем (иначе на каждый тик плодили бы дронов). stalled/failed/done —
1147
+ * не живые: резолвер сорвался/добил → можно, если не исчёрпан лимит попыток.
1148
+ */
1149
+ activeResolverFor(reviewRunId) {
1150
+ const row = this.db.prepare(
1151
+ `SELECT * FROM runs WHERE resolves_run_id = ? AND status NOT IN (${RESOLVER_TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
1152
+ ).get(reviewRunId);
1153
+ return row ? rowToRun(row) : null;
1154
+ }
1155
+ /** Сколько всего резолверов уже поднимали на этот review-PR — потолок авто-попыток. */
1156
+ resolverCountFor(reviewRunId) {
1157
+ const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE resolves_run_id = ?").get(reviewRunId);
1158
+ return row.n;
1159
+ }
1160
+ /**
1161
+ * Сколько ЖИВЫХ/УСПЕШНЫХ ревьюеров уже поднимали на этот review-PR В ТЕКУЩЕМ ЦИКЛЕ
1162
+ * (авто-ревью, TASWM-148; скоуп по циклу — авто-ревью v2, TASWM-162) — one-shot
1163
+ * идемпотентность БЕЗ лимита попыток (в отличие от резолвера): >0 → в этом цикле ревьюер
1164
+ * уже есть, повторно не спавним. failed/stalled ревьюеры (worktree/launch сорвались ДО
1165
+ * вердикта) в счёт НЕ идут — иначе один транзиентный сбой спавна (диск/git/gh) навсегда
1166
+ * блокировал бы ревью цикла без шанса на повтор. stopped/superseded — тоже НЕ считаем
1167
+ * (TASWM-253): stopped — оператор закрыл зомби-ревьюера руками (POST /swarm/runs/cleanup-
1168
+ * dead), superseded — оператор явно сбросил попытки ревью (POST /swarm/review/retry,
1169
+ * см. packages/api/src/swarm/routes.ts); оба варианта означают «эта попытка не в счёт»,
1170
+ * а не «ревьюер ещё жив». РАНЬШЕ stopped
1171
+ * тут не исключался — операторский kill зомби-ревьюера навсегда держал гейт закрытым
1172
+ * (reviewerCountFor>0 бесконечно), хотя счётчик попыток (ниже) уже не рос. Скоуп «текущий
1173
+ * цикл» = review_cycle ревьюера равен review_cycle проверяемого прогона: после фикс-
1174
+ * итерации исполнитель бампит цикл (droneServer.done), ревьюеры прошлого цикла отваливаются
1175
+ * из счёта → гейт снова открыт для ре-ревьюера (без него бейдж «changes» держал бы слот
1176
+ * навсегда).
1177
+ */
1178
+ reviewerCountFor(reviewRunId) {
1179
+ const row = this.db.prepare(
1180
+ "SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status NOT IN ('failed','stalled','stopped','superseded') AND review_cycle = COALESCE((SELECT review_cycle FROM runs WHERE id = ?), 0)"
1181
+ ).get(reviewRunId, reviewRunId);
1182
+ return row.n;
1183
+ }
1184
+ /**
1185
+ * Жёсткий кап ревью-спавнов (TASWM-204, карусель мёртвых ревьюеров): сколько ревьюеров
1186
+ * ВСЕГО (любой статус, включая failed/stalled/stopped — специально в отличие от
1187
+ * reviewerCountFor выше) поднималось на review-прогон #reviewRunId В ТЕКУЩЕМ ЦИКЛЕ (скоуп
1188
+ * по циклу — авто-ревью v2, TASWM-162). Зеркалит resolverCountFor — плоский потолок
1189
+ * попыток на цикл. Именно это ловит карусель: reviewerCountFor исключает failed/stalled
1190
+ * (чтобы ОДИН транзиентный сбой не блокировал ревью), но тем самым допускает бесконечный
1191
+ * ретрай, если ревьюер стабильно умирает без done(). Скоуп по циклу — чтобы кап считался
1192
+ * заново на каждой фикс-итерации, а не суммировал попытки прошлых (успешно завершённых)
1193
+ * циклов. superseded — ЕДИНСТВЕННОЕ исключение из капа (TASWM-253): это статус, которым
1194
+ * POST /swarm/review/retry помечает попытки при операторском «попробовать ревью заново» —
1195
+ * без исключения сброс не мог бы ничего сбросить (COUNT(*) слеп к статусу). failed/
1196
+ * stalled/stopped по-прежнему считаются — иначе кап потерял бы смысл на органических
1197
+ * смертях (ровно то, для чего его вводили в TASWM-204).
1198
+ */
1199
+ reviewerAttemptCountFor(reviewRunId) {
1200
+ const row = this.db.prepare(
1201
+ "SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status != 'superseded' AND review_cycle = COALESCE((SELECT review_cycle FROM runs WHERE id = ?), 0)"
1202
+ ).get(reviewRunId, reviewRunId);
1203
+ return row.n;
1204
+ }
1205
+ /** Последняя (любой статус) попытка ревьюера ТЕКУЩЕГО ЦИКЛА на review-прогон #reviewRunId
1206
+ * (TASWM-196; скоуп по циклу — TASWM-162) — см. store.ts:lastReviewerAttemptFor. */
1207
+ lastReviewerAttemptFor(reviewRunId) {
1208
+ const row = this.db.prepare(
1209
+ "SELECT * FROM runs WHERE reviews_run_id = ? AND review_cycle = COALESCE((SELECT review_cycle FROM runs WHERE id = ?), 0) ORDER BY id DESC LIMIT 1"
1210
+ ).get(reviewRunId, reviewRunId);
1211
+ return row ? rowToRun(row) : null;
1212
+ }
1213
+ /**
1214
+ * Живые/ждущие ревьюер-прогоны review-прогона #reviewRunId (TASWM-204): для
1215
+ * closeReviewersOnMerge — PR смержен раньше вердикта, зависшего ревьюера пора закрыть,
1216
+ * не дожидаясь done(). Точечный WHERE, не полный скан listRuns() (code-review TASWM-204).
1217
+ */
1218
+ activeReviewersFor(reviewRunId) {
1219
+ const rows = this.db.prepare(`SELECT * FROM runs WHERE reviews_run_id = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC`).all(reviewRunId);
1220
+ return rows.map(rowToRun);
1221
+ }
1222
+ /**
1223
+ * Последний живой (не терминальный) прогон по slug — для swarm cancel. Служебные
1224
+ * сабраны (reviews_run_id/resolves_run_id — ревьюер/резолвер, TASWM-273) исключены:
1225
+ * они заводятся с ТЕМ ЖЕ task_slug, что и родительский прогон, и всегда бо́льшим id
1226
+ * (спавнятся позже) — без исключения `ORDER BY id DESC LIMIT 1` подсовывал бы
1227
+ * cancelRun паузнутого ревьюера (paused НЕ терминален) вместо реального прогона,
1228
+ * которого целился остановить оператор/`swarm cancel <slug>`: безусловный removeWorktree
1229
+ * в cancelRun сносил бы worktree ревьюера, ждущего CI, и переводил его в stopped мимо
1230
+ * обычной цепочки паузы/побудки — найдено при разборе TASWM-273 (PR #564 завис в stalled
1231
+ * без вердикта: worktree ревьюера пропал так, что статус до этого не менялся). Сабран
1232
+ * отменяют через свой id, не через slug — swarm cancel всегда целится в родительский прогон.
1233
+ */
1234
+ activeRunForSlug(slug) {
1235
+ const row = this.db.prepare(
1236
+ `SELECT * FROM runs WHERE task_slug = ? AND status NOT IN (${TERMINAL_SQL}) AND reviews_run_id IS NULL AND resolves_run_id IS NULL ORDER BY id DESC LIMIT 1`
1237
+ ).get(slug);
1238
+ return row ? rowToRun(row) : null;
1239
+ }
1240
+ /**
1241
+ * Зоны, занятые сейчас работающими дронами — для конфликт-осознанной раздачи.
1242
+ * С repo — только в этом репозитории (одна area в разных репо не конфликтует).
1243
+ * Без repo — по всему рою (обратная совместимость).
1244
+ */
1245
+ runningAreas(repo) {
1246
+ const base3 = "SELECT DISTINCT area FROM runs WHERE status IN ('running','blocked') AND area IS NOT NULL";
1247
+ const rows = repo === void 0 ? this.db.prepare(base3).all() : this.db.prepare(`${base3} AND repo = ?`).all(repo);
1248
+ return rows.map((r) => r.area);
1249
+ }
1250
+ /**
1251
+ * Из depends_on прогона — слаги зависимостей, ещё НЕ закрытых (нет прогона со
1252
+ * статусом done). Пусто → все выполнены, дрона можно спавнить. Явная зависимость
1253
+ * (spawn --after) отдельна от area-lock: area освобождается на review (до мержа),
1254
+ * а тут ждём именно done (смерж), чтобы следующий дрон стартовал от кода предыдущего.
1255
+ * Несуществующий/зациклённый слаг просто никогда не станет done → прогон вечно ждёт,
1256
+ * но очередь не виснет (гейт — пропуск в цикле, не блокировка).
1257
+ */
1258
+ unmetDeps(depends_on) {
1259
+ if (!depends_on || depends_on.length === 0) return [];
1260
+ const rows = this.db.prepare("SELECT DISTINCT task_slug FROM runs WHERE status = 'done' AND resolves_run_id IS NULL").all();
1261
+ const done = new Set(rows.map((r) => r.task_slug));
1262
+ return depends_on.filter((slug) => !done.has(slug));
1263
+ }
1264
+ /** Все репо, засветившиеся в прогонах — whitelist для валидации spawn из веба. */
1265
+ distinctRepos() {
1266
+ const rows = this.db.prepare("SELECT DISTINCT repo FROM runs WHERE repo IS NOT NULL AND repo <> ''").all();
1267
+ return rows.map((r) => r.repo);
1268
+ }
1269
+ // --- импорт истории из другой БД (объединение роёв) ---
1270
+ /**
1271
+ * Идемпотентный перенос runs/events/questions из другой swarm-БД в эту.
1272
+ * id прогонов ремапятся (старый→новый); run_id в events/questions и superseded_by
1273
+ * в runs правятся по карте; всем импортируемым прогонам проставляется repo (норм.).
1274
+ * pid НЕ переносим — чужой pid в объединённой БД бессмыслен и мог бы совпасть с
1275
+ * живым процессом (ложный liveness); импортируемые прогоны исторические.
1276
+ * Живые статусы нормализуем (running/blocked→stalled, queued→stopped) и открытые
1277
+ * вопросы не тащим — иначе импорт даёт вечных зомби: без pid death-detect их
1278
+ * пропускает, они навсегда держат (repo,area)-лок и «в работе» в метриках, а их
1279
+ * открытые вопросы «воскрешают» архивный прогон через answer.
1280
+ * Повторный вызов на том же source — no-op (маркер в таблице imports). Source
1281
+ * открываем на ЗАПИСЬ (importDb только ЧИТАЕТ из него): все БД роя — WAL, а bun:sqlite
1282
+ * с readonly НЕ открывает WAL-source без -shm (реальный перенос обычно = копия одного
1283
+ * файла swarm.db без sidecar'ов → SQLITE_CANTOPEN). Несуществующий путь ловим existsSync
1284
+ * ДО открытия — честная ошибка, а не пустой файл (rw создал бы его). Всё в одной
1285
+ * транзакции: прервётся — маркер не встанет, повтор безопасен.
1286
+ */
1287
+ importDb(sourcePath, repo) {
1288
+ const abs = resolve2(sourcePath);
1289
+ const repoN = normalizeRepo(repo);
1290
+ const already = this.db.prepare("SELECT run_count FROM imports WHERE source = ?").get(abs);
1291
+ if (already) return { imported: false, runs: already.run_count };
1292
+ if (!existsSync8(abs)) throw new Error(`import-db: \u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442: ${abs}`);
1293
+ const src = new this.databaseCtor(abs);
1294
+ try {
1295
+ const runs = src.prepare("SELECT * FROM runs ORDER BY id").all();
1296
+ const events = src.prepare("SELECT * FROM events ORDER BY id").all();
1297
+ const questions = src.prepare("SELECT * FROM questions ORDER BY id").all();
1298
+ const txn = this.db.transaction(() => {
1299
+ const idMap = /* @__PURE__ */ new Map();
1300
+ const insRun = this.db.prepare(
1301
+ "INSERT INTO runs (task_slug, repo, worktree_path, branch, status, area, model, cost_usd, pr_url, decision, stop_reason, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
1302
+ );
1303
+ for (const r of runs) {
1304
+ const raw = r.status ?? "done";
1305
+ let status = raw;
1306
+ let stopReason = r.stop_reason ?? null;
1307
+ let normEvent = null;
1308
+ if (raw === "running" || raw === "blocked") {
1309
+ status = "stalled";
1310
+ normEvent = { kind: "stalled", text: "\u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D \u0438\u0437 \u0430\u0440\u0445\u0438\u0432\u0430: \u0441\u0442\u0430\u0442\u0443\u0441 \u043D\u043E\u0440\u043C\u0430\u043B\u0438\u0437\u043E\u0432\u0430\u043D" };
1311
+ } else if (raw === "queued") {
1312
+ status = "stopped";
1313
+ stopReason = "\u0430\u0440\u0445\u0438\u0432\u043D\u044B\u0439 \u0438\u043C\u043F\u043E\u0440\u0442";
1314
+ normEvent = { kind: "stopped", text: "\u0430\u0440\u0445\u0438\u0432\u043D\u044B\u0439 \u0438\u043C\u043F\u043E\u0440\u0442" };
1315
+ }
1316
+ const res = insRun.run(
1317
+ r.task_slug,
1318
+ repoN,
1319
+ r.worktree_path ?? null,
1320
+ r.branch ?? null,
1321
+ status,
1322
+ r.area ?? null,
1323
+ r.model ?? null,
1324
+ r.cost_usd ?? 0,
1325
+ r.pr_url ?? null,
1326
+ r.decision ?? null,
1327
+ stopReason,
1328
+ r.created_at ?? null,
1329
+ r.updated_at ?? null
1330
+ );
1331
+ const newId = Number(res.lastInsertRowid);
1332
+ idMap.set(r.id, newId);
1333
+ if (normEvent) this.addEvent(newId, normEvent.kind, normEvent.text);
1334
+ }
1335
+ const setSup = this.db.prepare("UPDATE runs SET superseded_by = ? WHERE id = ?");
1336
+ for (const r of runs) {
1337
+ const sup = r.superseded_by;
1338
+ if (sup != null && idMap.has(sup)) setSup.run(idMap.get(sup), idMap.get(r.id));
1339
+ }
1340
+ const insEv = this.db.prepare("INSERT INTO events (run_id, kind, text, actor, at) VALUES (?,?,?,?,?)");
1341
+ for (const e of events) {
1342
+ const oldRun = e.run_id;
1343
+ const newRun = oldRun != null ? idMap.get(oldRun) ?? null : null;
1344
+ insEv.run(newRun, e.kind, e.text ?? null, e.actor ?? null, e.at ?? null);
1345
+ }
1346
+ const insQ = this.db.prepare(
1347
+ "INSERT INTO questions (run_id, text, options, status, answer, answered_by, escalated, asked_at, answered_at) VALUES (?,?,?,?,?,?,?,?,?)"
1348
+ );
1349
+ for (const q of questions) {
1350
+ if (q.status === "open") continue;
1351
+ const newRun = idMap.get(q.run_id);
1352
+ if (newRun == null) continue;
1353
+ insQ.run(
1354
+ newRun,
1355
+ q.text,
1356
+ q.options ?? null,
1357
+ q.status ?? "answered",
1358
+ q.answer ?? null,
1359
+ q.answered_by ?? null,
1360
+ q.escalated ?? 0,
1361
+ q.asked_at ?? null,
1362
+ q.answered_at ?? null
1363
+ );
1364
+ }
1365
+ this.db.prepare("INSERT INTO imports (source, run_count) VALUES (?, ?)").run(abs, runs.length);
1366
+ });
1367
+ txn();
1368
+ return { imported: true, runs: runs.length };
1369
+ } finally {
1370
+ src.close();
1184
1371
  }
1185
- const insEv = this.db.prepare("INSERT INTO events (run_id, kind, text, actor, at) VALUES (?,?,?,?,?)");
1186
- for (const e of events) {
1187
- const oldRun = e.run_id;
1188
- const newRun = oldRun != null ? idMap.get(oldRun) ?? null : null;
1189
- insEv.run(newRun, e.kind, e.text ?? null, e.actor ?? null, e.at ?? null);
1372
+ }
1373
+ // --- questions (петля ask/answer) ---
1374
+ ask(run_id, text, options) {
1375
+ const insert = () => this.db.prepare("INSERT INTO questions (run_id, text, options) VALUES (?, ?, ?)").run(run_id, text, options && options.length ? JSON.stringify(options) : null);
1376
+ let res;
1377
+ try {
1378
+ res = insert();
1379
+ } catch (err) {
1380
+ if (isForeignKeyError(err)) {
1381
+ console.error(
1382
+ `[swarm-db] FOREIGN KEY constraint failed: \u0432\u043E\u043F\u0440\u043E\u0441 \u0434\u043B\u044F \u043F\u0440\u043E\u0433\u043E\u043D\u0430 #${run_id} \u2014 \u0441\u0442\u0440\u043E\u043A\u0438 \u043D\u0435\u0442 \u0432 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u043C \u0441\u0442\u043E\u0440\u0435 (\u0441\u043C. TASWM-263: \u0434\u0435\u043C\u043E\u043D \u0432 client-\u0440\u0435\u0436\u0438\u043C\u0435 \u043D\u0435 \u0437\u0430\u0432\u043E\u0434\u0438\u0442 \u0435\u0451 \u0437\u0434\u0435\u0441\u044C). \u0417\u0430\u0432\u043E\u0436\u0443 \u0441\u0442\u0430\u0431 \u0438 \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u044E.`
1383
+ );
1384
+ this.ensureRun({ id: run_id, task_slug: "?" });
1385
+ res = insert();
1386
+ } else {
1387
+ throw err;
1388
+ }
1190
1389
  }
1191
- const insQ = this.db.prepare(
1192
- "INSERT INTO questions (run_id, text, options, status, answer, answered_by, escalated, asked_at, answered_at) VALUES (?,?,?,?,?,?,?,?,?)"
1193
- );
1194
- for (const q of questions) {
1195
- if (q.status === "open") continue;
1196
- const newRun = idMap.get(q.run_id);
1197
- if (newRun == null) continue;
1198
- insQ.run(
1199
- newRun,
1200
- q.text,
1201
- q.options ?? null,
1202
- q.status ?? "answered",
1203
- q.answer ?? null,
1204
- q.answered_by ?? null,
1205
- q.escalated ?? 0,
1206
- q.asked_at ?? null,
1207
- q.answered_at ?? null
1208
- );
1390
+ const id = Number(res.lastInsertRowid);
1391
+ this.updateRun(run_id, { status: "blocked" });
1392
+ this.addEvent(run_id, "blocked", text);
1393
+ return this.getQuestion(id);
1394
+ }
1395
+ /** Оркестратор поднял вопрос человеку (техническое решил бы сам). */
1396
+ escalateQuestion(id) {
1397
+ this.db.prepare("UPDATE questions SET escalated=1 WHERE id=?").run(id);
1398
+ }
1399
+ /**
1400
+ * Снять открытый вопрос без реального ответа (дрон замер: overmind недоступен,
1401
+ * ответа не будет — TASWM-41). Отдельный статус 'withdrawn' (не 'answered'): вопрос
1402
+ * уходит из openQuestions() (WHERE status='open'), защищён от answer() (тот правит
1403
+ * только open И воскрешает прогон лишь на статусе 'answered') — так снятый вопрос
1404
+ * не оживит замерший прогон. Остаётся в истории как снятый с пояснением.
1405
+ */
1406
+ withdrawQuestion(id, reason) {
1407
+ this.db.prepare("UPDATE questions SET status='withdrawn', answer=?, answered_at=datetime('now') WHERE id=? AND status='open'").run(reason, id);
1408
+ }
1409
+ answer(question_id, answer, by = "human") {
1410
+ this.db.prepare(
1411
+ "UPDATE questions SET status='answered', answer=?, answered_by=?, answered_at=datetime('now') WHERE id=? AND status='open'"
1412
+ ).run(answer, by, question_id);
1413
+ const q = this.getQuestion(question_id);
1414
+ if (q && q.status === "answered") {
1415
+ this.updateRun(q.run_id, { status: "running" });
1416
+ this.addEvent(q.run_id, "answered", answer, by);
1209
1417
  }
1210
- this.db.prepare("INSERT INTO imports (source, run_count) VALUES (?, ?)").run(abs, runs.length);
1211
- });
1212
- txn();
1213
- return { imported: true, runs: runs.length };
1214
- } finally {
1215
- src.close();
1216
- }
1217
- }
1218
- // --- questions (петля ask/answer) ---
1219
- ask(run_id, text, options) {
1220
- const insert = () => this.db.prepare("INSERT INTO questions (run_id, text, options) VALUES (?, ?, ?)").run(run_id, text, options && options.length ? JSON.stringify(options) : null);
1221
- let res;
1222
- try {
1223
- res = insert();
1224
- } catch (err) {
1225
- if (isForeignKeyError(err)) {
1226
- console.error(
1227
- `[swarm-db] FOREIGN KEY constraint failed: \u0432\u043E\u043F\u0440\u043E\u0441 \u0434\u043B\u044F \u043F\u0440\u043E\u0433\u043E\u043D\u0430 #${run_id} \u2014 \u0441\u0442\u0440\u043E\u043A\u0438 \u043D\u0435\u0442 \u0432 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u043C \u0441\u0442\u043E\u0440\u0435 (\u0441\u043C. TASWM-263: \u0434\u0435\u043C\u043E\u043D \u0432 client-\u0440\u0435\u0436\u0438\u043C\u0435 \u043D\u0435 \u0437\u0430\u0432\u043E\u0434\u0438\u0442 \u0435\u0451 \u0437\u0434\u0435\u0441\u044C). \u0417\u0430\u0432\u043E\u0436\u0443 \u0441\u0442\u0430\u0431 \u0438 \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u044E.`
1418
+ return q;
1419
+ }
1420
+ /** Пометить старый прогон переигранным на новый (откат на другой вариант). */
1421
+ supersede(oldId, newId, by = "human") {
1422
+ this.updateRun(oldId, { superseded_by: newId });
1423
+ this.addEvent(oldId, "superseded", `\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u2192 #${newId}`, by);
1424
+ }
1425
+ getQuestion(id) {
1426
+ const row = this.db.prepare("SELECT * FROM questions WHERE id = ?").get(id);
1427
+ return row ? rowToQuestion(row) : null;
1428
+ }
1429
+ openQuestions() {
1430
+ return this.db.prepare("SELECT * FROM questions WHERE status='open' ORDER BY id").all().map(
1431
+ rowToQuestion
1228
1432
  );
1229
- this.ensureRun({ id: run_id, task_slug: "?" });
1230
- res = insert();
1231
- } else {
1232
- throw err;
1233
1433
  }
1234
- }
1235
- const id = Number(res.lastInsertRowid);
1236
- this.updateRun(run_id, { status: "blocked" });
1237
- this.addEvent(run_id, "blocked", text);
1238
- return this.getQuestion(id);
1239
- }
1240
- /** Оркестратор поднял вопрос человеку (техническое решил бы сам). */
1241
- escalateQuestion(id) {
1242
- this.db.prepare("UPDATE questions SET escalated=1 WHERE id=?").run(id);
1243
- }
1244
- /**
1245
- * Снять открытый вопрос без реального ответа (дрон замер: overmind недоступен,
1246
- * ответа не будет — TASWM-41). Отдельный статус 'withdrawn' (не 'answered'): вопрос
1247
- * уходит из openQuestions() (WHERE status='open'), защищён от answer() (тот правит
1248
- * только open И воскрешает прогон лишь на статусе 'answered') — так снятый вопрос
1249
- * не оживит замерший прогон. Остаётся в истории как снятый с пояснением.
1250
- */
1251
- withdrawQuestion(id, reason) {
1252
- this.db.prepare("UPDATE questions SET status='withdrawn', answer=?, answered_at=datetime('now') WHERE id=? AND status='open'").run(reason, id);
1253
- }
1254
- answer(question_id, answer, by = "human") {
1255
- this.db.prepare(
1256
- "UPDATE questions SET status='answered', answer=?, answered_by=?, answered_at=datetime('now') WHERE id=? AND status='open'"
1257
- ).run(answer, by, question_id);
1258
- const q = this.getQuestion(question_id);
1259
- if (q && q.status === "answered") {
1260
- this.updateRun(q.run_id, { status: "running" });
1261
- this.addEvent(q.run_id, "answered", answer, by);
1262
- }
1263
- return q;
1264
- }
1265
- /** Пометить старый прогон переигранным на новый (откат на другой вариант). */
1266
- supersede(oldId, newId, by = "human") {
1267
- this.updateRun(oldId, { superseded_by: newId });
1268
- this.addEvent(oldId, "superseded", `\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u2192 #${newId}`, by);
1269
- }
1270
- getQuestion(id) {
1271
- const row = this.db.prepare("SELECT * FROM questions WHERE id = ?").get(id);
1272
- return row ? rowToQuestion(row) : null;
1273
- }
1274
- openQuestions() {
1275
- return this.db.prepare("SELECT * FROM questions WHERE status='open' ORDER BY id").all().map(
1276
- rowToQuestion
1277
- );
1278
- }
1279
- /** ВСЕ вопросы прогона (любого статуса), по возрастанию id — см. SwarmStore.questionsFor. */
1280
- questionsFor(run_id) {
1281
- return this.db.prepare("SELECT * FROM questions WHERE run_id = ? ORDER BY id").all(run_id).map(
1282
- rowToQuestion
1283
- );
1284
- }
1285
- /** id прогонов, у которых есть хотя бы один вопрос — доступность «Переиграть» в модалке. */
1286
- runIdsWithQuestions() {
1287
- return this.db.prepare("SELECT DISTINCT run_id FROM questions").all().map((r) => r.run_id);
1288
- }
1289
- /**
1290
- * Открытые вопросы дронов отряда Героя #heroId (join questions×runs по hero_run_id).
1291
- * Герой видит их в squad_status и отвечает через answer_drone; вопрос не из отряда сюда
1292
- * не попадёт — это и есть гейт «отвечать только своим» (TASWM-81).
1293
- */
1294
- openQuestionsFor(heroId) {
1295
- return this.db.prepare(
1296
- "SELECT q.* FROM questions q JOIN runs r ON r.id = q.run_id WHERE r.hero_run_id = ? AND q.status = 'open' ORDER BY q.id"
1297
- ).all(heroId).map(rowToQuestion);
1298
- }
1299
- // --- events (лог/хроника) ---
1300
- addEvent(run_id, kind, text, actor = null) {
1301
- const clean = sanitizeEventText(text);
1302
- const insert = () => this.db.prepare("INSERT INTO events (run_id, kind, text, actor) VALUES (?, ?, ?, ?)").run(run_id, kind, clean, actor);
1303
- try {
1304
- insert();
1305
- } catch (err) {
1306
- if (run_id != null && isForeignKeyError(err)) {
1307
- console.error(
1308
- `[swarm-db] FOREIGN KEY constraint failed: \u0441\u043E\u0431\u044B\u0442\u0438\u0435 "${kind}" \u0434\u043B\u044F \u043F\u0440\u043E\u0433\u043E\u043D\u0430 #${run_id} \u2014 \u0441\u0442\u0440\u043E\u043A\u0438 \u043D\u0435\u0442 \u0432 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u043C \u0441\u0442\u043E\u0440\u0435 (\u0441\u043C. TASWM-263: \u0434\u0435\u043C\u043E\u043D \u0432 client-\u0440\u0435\u0436\u0438\u043C\u0435 \u043D\u0435 \u0437\u0430\u0432\u043E\u0434\u0438\u0442 \u0435\u0451 \u0437\u0434\u0435\u0441\u044C). \u0417\u0430\u0432\u043E\u0436\u0443 \u0441\u0442\u0430\u0431 \u0438 \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u044E.`
1434
+ /** ВСЕ вопросы прогона (любого статуса), по возрастанию id — см. SwarmStore.questionsFor. */
1435
+ questionsFor(run_id) {
1436
+ return this.db.prepare("SELECT * FROM questions WHERE run_id = ? ORDER BY id").all(run_id).map(
1437
+ rowToQuestion
1309
1438
  );
1310
- this.ensureRun({ id: run_id, task_slug: "?" });
1311
- insert();
1312
- return;
1313
1439
  }
1314
- throw err;
1315
- }
1316
- }
1317
- events(run_id) {
1318
- return this.db.prepare("SELECT * FROM events WHERE run_id = ? ORDER BY id").all(run_id);
1319
- }
1320
- /** Хроника роя (вкладка История): Q&A (вопрос+варианты+ответ+автор) + вехи прогонов, новые сверху. */
1321
- history(limit = 50) {
1322
- const items = [];
1323
- const name = (row) => row.name ?? `#${row.run_id}`;
1324
- for (const row of this.db.prepare(
1325
- "SELECT q.*, r.task_slug slug, r.area area, r.name name, r.repo repo, r.task_title title, r.pr_url pr, r.task_url taskUrl FROM questions q LEFT JOIN runs r ON r.id = q.run_id"
1326
- ).all()) {
1327
- const q = rowToQuestion(row);
1328
- items.push({
1329
- kind: "qa",
1330
- at: row.answered_at ?? q.asked_at,
1331
- slug: row.slug ?? "?",
1332
- name: name(row),
1333
- repo: row.repo ?? "",
1334
- area: row.area ?? null,
1335
- qid: q.id,
1336
- question: q.text,
1337
- options: q.options,
1338
- answer: q.answer,
1339
- answered_by: q.answered_by,
1340
- escalated: q.escalated,
1341
- open: q.status === "open",
1342
- title: row.title ?? null,
1343
- pr: row.pr ?? null,
1344
- taskUrl: row.taskUrl ?? null
1345
- });
1346
- }
1347
- for (const row of this.db.prepare(
1348
- "SELECT e.*, r.task_slug slug, r.name name, r.repo repo, r.task_title title, r.pr_url pr, r.task_url taskUrl FROM events e LEFT JOIN runs r ON r.id = e.run_id"
1349
- ).all()) {
1350
- if (!isMilestoneKind(row.kind)) continue;
1351
- const global = row.run_id == null;
1352
- items.push({
1353
- kind: "milestone",
1354
- at: row.at,
1355
- slug: row.slug ?? (global ? "\u0440\u043E\u0439" : "?"),
1356
- name: global ? "\u0440\u043E\u0439" : name(row),
1357
- repo: row.repo ?? "",
1358
- event: row.kind,
1359
- text: row.text ?? null,
1360
- actor: row.actor ?? null,
1361
- title: row.title ?? null,
1362
- pr: row.pr ?? null,
1363
- taskUrl: row.taskUrl ?? null
1364
- });
1365
- }
1366
- items.sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0);
1367
- return items.slice(0, limit);
1368
- }
1369
- /** Сколько PR смержено сегодня (UTC) — для метрики «Смержено сегодня» на Главной. */
1370
- mergedToday() {
1371
- const row = this.db.prepare("SELECT COUNT(*) n FROM events WHERE kind = 'merged' AND date(at) = date('now')").get();
1372
- return row.n;
1373
- }
1374
- // --- штормовой режим (TASWM-23): состояние роя в событиях (source of truth) ---
1375
- /**
1376
- * В шторме ли рой сейчас: смотрим последнее ГЛОБАЛЬНОЕ (run_id NULL) событие
1377
- * storm_start/storm_end. storm_start без последующего storm_end → шторм активен.
1378
- * Выводится из событий → переживает рестарт overmind.
1379
- */
1380
- stormActive() {
1381
- const row = this.db.prepare("SELECT kind FROM events WHERE run_id IS NULL AND kind IN ('storm_start','storm_end') ORDER BY id DESC LIMIT 1").get();
1382
- return row?.kind === "storm_start";
1383
- }
1384
- /** Время начала ТЕКУЩЕГО шторма (at события storm_start) или null, если шторма нет. */
1385
- currentStormStartedAt() {
1386
- const row = this.db.prepare("SELECT kind, at FROM events WHERE run_id IS NULL AND kind IN ('storm_start','storm_end') ORDER BY id DESC LIMIT 1").get();
1387
- return row?.kind === "storm_start" ? row.at : null;
1388
- }
1389
- /**
1390
- * Время ПОСЛЕДНЕГО отбоя (at события storm_end) или null, если шторм сейчас идёт /
1391
- * отбоя ещё не было. Для гистерезиса TASWM-208: свежий отбой держит кулдаун на
1392
- * эвристический перезапуск. Смотрим то же последнее storm_start/storm_end, что и
1393
- * stormActive/currentStormStartedAt — probe-события не участвуют.
1394
- */
1395
- lastStormEndedAt() {
1396
- const row = this.db.prepare("SELECT kind, at FROM events WHERE run_id IS NULL AND kind IN ('storm_start','storm_end') ORDER BY id DESC LIMIT 1").get();
1397
- return row?.kind === "storm_end" ? row.at : null;
1398
- }
1399
- /** Пометить вход в шторм (глобальное событие). Идемпотентность — на стороне вызывающего. */
1400
- startStorm(reason) {
1401
- this.addEvent(null, "storm_start", reason);
1402
- }
1403
- /** Пометить отбой шторма (глобальное событие). */
1404
- endStorm(reason) {
1405
- this.addEvent(null, "storm_end", reason);
1406
- }
1407
- /** Записать смерть работающего дрона — семпл для скользящего окна детекции шторма. */
1408
- recordDeath(runId, note) {
1409
- this.addEvent(runId, "died", note ?? "\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440 (dead pid)");
1410
- }
1411
- /**
1412
- * Смертей дронов (событий died) с момента cutoff. cutoffSeconds — epoch-СЕКУНДЫ;
1413
- * datetime(?, 'unixepoch') форматирует его тем же «YYYY-MM-DD HH:MM:SS» UTC, что и
1414
- * datetime('now') в at — строковое сравнение корректно, без парса на стороне JS.
1415
- */
1416
- deathCountSince(cutoffSeconds) {
1417
- const row = this.db.prepare("SELECT COUNT(*) n FROM events WHERE kind='died' AND at >= datetime(?, 'unixepoch')").get(cutoffSeconds);
1418
- return row.n;
1419
- }
1420
- /**
1421
- * Спавн-фейлов (событий failed на НЕ-резолверных/НЕ-ревьюерных прогонах) с момента
1422
- * cutoff. Резолверные (merge-main-in) и ревьюерные (авто-ревью, TASWM-148) failed не
1423
- * считаем штормовым сигналом.
1424
- */
1425
- spawnFailCountSince(cutoffSeconds) {
1426
- const row = this.db.prepare(
1427
- "SELECT COUNT(*) n FROM events e JOIN runs r ON r.id = e.run_id WHERE e.kind='failed' AND r.resolves_run_id IS NULL AND r.reviews_run_id IS NULL AND e.at >= datetime(?, 'unixepoch')"
1428
- ).get(cutoffSeconds);
1429
- return row.n;
1430
- }
1431
- /** Записать результат периодической проверки шторма (health-probe + внешний сигнал). */
1432
- recordStormProbe(probe, ext, reason, resetHint) {
1433
- this.addEvent(null, "storm_probe", encodeProbe({ probe, ext, reason: reason ?? null, resetHint: resetHint ?? null }));
1434
- }
1435
- /** Последняя запись storm_probe (для троттлинга по времени и последнего внешнего сигнала). */
1436
- latestStormProbe() {
1437
- const row = this.db.prepare("SELECT text, at FROM events WHERE run_id IS NULL AND kind='storm_probe' ORDER BY id DESC LIMIT 1").get();
1438
- return row ? { rec: decodeProbe(row.text), at: row.at } : null;
1439
- }
1440
- /** Записи storm_probe текущего шторма (at >= sinceIso), по возрастанию — для счётчика успехов подряд. */
1441
- stormProbeRecordsSince(sinceIso) {
1442
- const rows = this.db.prepare("SELECT text FROM events WHERE run_id IS NULL AND kind='storm_probe' AND at >= ? ORDER BY id").all(sinceIso);
1443
- return rows.map((r) => decodeProbe(r.text));
1444
- }
1445
- /**
1446
- * Последняя РЕАЛЬНАЯ health-probe (probe !== null; ext-only записи пропускаем) с её
1447
- * временем. TASWM-137 п.д: подтверждающая проба перед входом в шторм по эвристике —
1448
- * зелёная проба вето́ит ложный шторм; свежую (в пределах probeIntervalMs) переиспользуем,
1449
- * не гоняя новую каждый тик. Смотрим только хвост (LIMIT) — старее нам всё равно не нужно.
1450
- */
1451
- lastHealthProbe() {
1452
- const rows = this.db.prepare("SELECT text, at FROM events WHERE run_id IS NULL AND kind='storm_probe' ORDER BY id DESC LIMIT 20").all();
1453
- for (const r of rows) {
1454
- const rec = decodeProbe(r.text);
1455
- if (rec.probe !== null) return { ok: rec.probe, at: r.at };
1456
- }
1457
- return null;
1440
+ /** id прогонов, у которых есть хотя бы один вопрос — доступность «Переиграть» в модалке. */
1441
+ runIdsWithQuestions() {
1442
+ return this.db.prepare("SELECT DISTINCT run_id FROM questions").all().map((r) => r.run_id);
1443
+ }
1444
+ /**
1445
+ * Открытые вопросы дронов отряда Героя #heroId (join questions×runs по hero_run_id).
1446
+ * Герой видит их в squad_status и отвечает через answer_drone; вопрос не из отряда сюда
1447
+ * не попадёт — это и есть гейт «отвечать только своим» (TASWM-81).
1448
+ */
1449
+ openQuestionsFor(heroId) {
1450
+ return this.db.prepare(
1451
+ "SELECT q.* FROM questions q JOIN runs r ON r.id = q.run_id WHERE r.hero_run_id = ? AND q.status = 'open' ORDER BY q.id"
1452
+ ).all(heroId).map(rowToQuestion);
1453
+ }
1454
+ // --- events (лог/хроника) ---
1455
+ addEvent(run_id, kind, text, actor = null) {
1456
+ const clean = sanitizeEventText(text);
1457
+ const insert = () => this.db.prepare("INSERT INTO events (run_id, kind, text, actor) VALUES (?, ?, ?, ?)").run(run_id, kind, clean, actor);
1458
+ try {
1459
+ insert();
1460
+ } catch (err) {
1461
+ if (run_id != null && isForeignKeyError(err)) {
1462
+ console.error(
1463
+ `[swarm-db] FOREIGN KEY constraint failed: \u0441\u043E\u0431\u044B\u0442\u0438\u0435 "${kind}" \u0434\u043B\u044F \u043F\u0440\u043E\u0433\u043E\u043D\u0430 #${run_id} \u2014 \u0441\u0442\u0440\u043E\u043A\u0438 \u043D\u0435\u0442 \u0432 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u043E\u043C \u0441\u0442\u043E\u0440\u0435 (\u0441\u043C. TASWM-263: \u0434\u0435\u043C\u043E\u043D \u0432 client-\u0440\u0435\u0436\u0438\u043C\u0435 \u043D\u0435 \u0437\u0430\u0432\u043E\u0434\u0438\u0442 \u0435\u0451 \u0437\u0434\u0435\u0441\u044C). \u0417\u0430\u0432\u043E\u0436\u0443 \u0441\u0442\u0430\u0431 \u0438 \u043F\u043E\u0432\u0442\u043E\u0440\u044F\u044E.`
1464
+ );
1465
+ this.ensureRun({ id: run_id, task_slug: "?" });
1466
+ insert();
1467
+ return;
1468
+ }
1469
+ throw err;
1470
+ }
1471
+ }
1472
+ events(run_id) {
1473
+ return this.db.prepare("SELECT * FROM events WHERE run_id = ? ORDER BY id").all(run_id);
1474
+ }
1475
+ /** Хроника роя (вкладка История): Q&A (вопрос+варианты+ответ+автор) + вехи прогонов, новые сверху. */
1476
+ history(limit = 50) {
1477
+ const items = [];
1478
+ const name = (row) => row.name ?? `#${row.run_id}`;
1479
+ for (const row of this.db.prepare(
1480
+ "SELECT q.*, r.task_slug slug, r.area area, r.name name, r.repo repo, r.task_title title, r.pr_url pr, r.task_url taskUrl FROM questions q LEFT JOIN runs r ON r.id = q.run_id"
1481
+ ).all()) {
1482
+ const q = rowToQuestion(row);
1483
+ items.push({
1484
+ kind: "qa",
1485
+ at: row.answered_at ?? q.asked_at,
1486
+ slug: row.slug ?? "?",
1487
+ name: name(row),
1488
+ repo: row.repo ?? "",
1489
+ area: row.area ?? null,
1490
+ qid: q.id,
1491
+ question: q.text,
1492
+ options: q.options,
1493
+ answer: q.answer,
1494
+ answered_by: q.answered_by,
1495
+ escalated: q.escalated,
1496
+ open: q.status === "open",
1497
+ title: row.title ?? null,
1498
+ pr: row.pr ?? null,
1499
+ taskUrl: row.taskUrl ?? null
1500
+ });
1501
+ }
1502
+ for (const row of this.db.prepare(
1503
+ "SELECT e.*, r.task_slug slug, r.name name, r.repo repo, r.task_title title, r.pr_url pr, r.task_url taskUrl FROM events e LEFT JOIN runs r ON r.id = e.run_id"
1504
+ ).all()) {
1505
+ if (!isMilestoneKind(row.kind)) continue;
1506
+ const global = row.run_id == null;
1507
+ items.push({
1508
+ kind: "milestone",
1509
+ at: row.at,
1510
+ slug: row.slug ?? (global ? "\u0440\u043E\u0439" : "?"),
1511
+ name: global ? "\u0440\u043E\u0439" : name(row),
1512
+ repo: row.repo ?? "",
1513
+ event: row.kind,
1514
+ text: row.text ?? null,
1515
+ actor: row.actor ?? null,
1516
+ title: row.title ?? null,
1517
+ pr: row.pr ?? null,
1518
+ taskUrl: row.taskUrl ?? null
1519
+ });
1520
+ }
1521
+ items.sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0);
1522
+ return items.slice(0, limit);
1523
+ }
1524
+ /** Сколько PR смержено сегодня (UTC) — для метрики «Смержено сегодня» на Главной. */
1525
+ mergedToday() {
1526
+ const row = this.db.prepare("SELECT COUNT(*) n FROM events WHERE kind = 'merged' AND date(at) = date('now')").get();
1527
+ return row.n;
1528
+ }
1529
+ // --- штормовой режим (TASWM-23): состояние роя в событиях (source of truth) ---
1530
+ /**
1531
+ * В шторме ли рой сейчас: смотрим последнее ГЛОБАЛЬНОЕ (run_id NULL) событие
1532
+ * storm_start/storm_end. storm_start без последующего storm_end → шторм активен.
1533
+ * Выводится из событий → переживает рестарт overmind.
1534
+ */
1535
+ stormActive() {
1536
+ const row = this.db.prepare("SELECT kind FROM events WHERE run_id IS NULL AND kind IN ('storm_start','storm_end') ORDER BY id DESC LIMIT 1").get();
1537
+ return row?.kind === "storm_start";
1538
+ }
1539
+ /** Время начала ТЕКУЩЕГО шторма (at события storm_start) или null, если шторма нет. */
1540
+ currentStormStartedAt() {
1541
+ const row = this.db.prepare("SELECT kind, at FROM events WHERE run_id IS NULL AND kind IN ('storm_start','storm_end') ORDER BY id DESC LIMIT 1").get();
1542
+ return row?.kind === "storm_start" ? row.at : null;
1543
+ }
1544
+ /**
1545
+ * Время ПОСЛЕДНЕГО отбоя (at события storm_end) или null, если шторм сейчас идёт /
1546
+ * отбоя ещё не было. Для гистерезиса TASWM-208: свежий отбой держит кулдаун на
1547
+ * эвристический перезапуск. Смотрим то же последнее storm_start/storm_end, что и
1548
+ * stormActive/currentStormStartedAt — probe-события не участвуют.
1549
+ */
1550
+ lastStormEndedAt() {
1551
+ const row = this.db.prepare("SELECT kind, at FROM events WHERE run_id IS NULL AND kind IN ('storm_start','storm_end') ORDER BY id DESC LIMIT 1").get();
1552
+ return row?.kind === "storm_end" ? row.at : null;
1553
+ }
1554
+ /** Пометить вход в шторм (глобальное событие). Идемпотентность — на стороне вызывающего. */
1555
+ startStorm(reason) {
1556
+ this.addEvent(null, "storm_start", reason);
1557
+ }
1558
+ /** Пометить отбой шторма (глобальное событие). */
1559
+ endStorm(reason) {
1560
+ this.addEvent(null, "storm_end", reason);
1561
+ }
1562
+ /** Записать смерть работающего дрона — семпл для скользящего окна детекции шторма. */
1563
+ recordDeath(runId, note) {
1564
+ this.addEvent(runId, "died", note ?? "\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440 (dead pid)");
1565
+ }
1566
+ /**
1567
+ * Смертей дронов (событий died) с момента cutoff. cutoffSeconds — epoch-СЕКУНДЫ;
1568
+ * datetime(?, 'unixepoch') форматирует его тем же «YYYY-MM-DD HH:MM:SS» UTC, что и
1569
+ * datetime('now') в at — строковое сравнение корректно, без парса на стороне JS.
1570
+ */
1571
+ deathCountSince(cutoffSeconds) {
1572
+ const row = this.db.prepare("SELECT COUNT(*) n FROM events WHERE kind='died' AND at >= datetime(?, 'unixepoch')").get(cutoffSeconds);
1573
+ return row.n;
1574
+ }
1575
+ /**
1576
+ * Спавн-фейлов (событий failed на НЕ-резолверных/НЕ-ревьюерных прогонах) с момента
1577
+ * cutoff. Резолверные (merge-main-in) и ревьюерные (авто-ревью, TASWM-148) failed не
1578
+ * считаем штормовым сигналом.
1579
+ */
1580
+ spawnFailCountSince(cutoffSeconds) {
1581
+ const row = this.db.prepare(
1582
+ "SELECT COUNT(*) n FROM events e JOIN runs r ON r.id = e.run_id WHERE e.kind='failed' AND r.resolves_run_id IS NULL AND r.reviews_run_id IS NULL AND e.at >= datetime(?, 'unixepoch')"
1583
+ ).get(cutoffSeconds);
1584
+ return row.n;
1585
+ }
1586
+ /** Записать результат периодической проверки шторма (health-probe + внешний сигнал). */
1587
+ recordStormProbe(probe, ext, reason, resetHint) {
1588
+ this.addEvent(null, "storm_probe", encodeProbe({ probe, ext, reason: reason ?? null, resetHint: resetHint ?? null }));
1589
+ }
1590
+ /** Последняя запись storm_probe (для троттлинга по времени и последнего внешнего сигнала). */
1591
+ latestStormProbe() {
1592
+ const row = this.db.prepare("SELECT text, at FROM events WHERE run_id IS NULL AND kind='storm_probe' ORDER BY id DESC LIMIT 1").get();
1593
+ return row ? { rec: decodeProbe(row.text), at: row.at } : null;
1594
+ }
1595
+ /** Записи storm_probe текущего шторма (at >= sinceIso), по возрастанию — для счётчика успехов подряд. */
1596
+ stormProbeRecordsSince(sinceIso) {
1597
+ const rows = this.db.prepare("SELECT text FROM events WHERE run_id IS NULL AND kind='storm_probe' AND at >= ? ORDER BY id").all(sinceIso);
1598
+ return rows.map((r) => decodeProbe(r.text));
1599
+ }
1600
+ /**
1601
+ * Последняя РЕАЛЬНАЯ health-probe (probe !== null; ext-only записи пропускаем) с её
1602
+ * временем. TASWM-137 п.д: подтверждающая проба перед входом в шторм по эвристике —
1603
+ * зелёная проба вето́ит ложный шторм; свежую (в пределах probeIntervalMs) переиспользуем,
1604
+ * не гоняя новую каждый тик. Смотрим только хвост (LIMIT) — старее нам всё равно не нужно.
1605
+ */
1606
+ lastHealthProbe() {
1607
+ const rows = this.db.prepare("SELECT text, at FROM events WHERE run_id IS NULL AND kind='storm_probe' ORDER BY id DESC LIMIT 20").all();
1608
+ for (const r of rows) {
1609
+ const rec = decodeProbe(r.text);
1610
+ if (rec.probe !== null) return { ok: rec.probe, at: r.at };
1611
+ }
1612
+ return null;
1613
+ }
1614
+ };
1458
1615
  }
1459
- };
1616
+ });
1617
+
1618
+ // src/cli.ts
1619
+ import { basename as basename4 } from "path";
1620
+ import { existsSync as existsSync9, readdirSync as readdirSync6, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
1621
+ import { createInterface } from "readline/promises";
1622
+ import { setTimeout as sleep2 } from "timers/promises";
1460
1623
 
1461
1624
  // src/swarmClient.ts
1462
- import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1625
+ init_config();
1626
+ import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1463
1627
  import { dirname } from "path";
1464
1628
  import { randomUUID } from "crypto";
1465
1629
 
@@ -1478,6 +1642,7 @@ function execQuiet(file, args = [], opts = {}) {
1478
1642
  var PROC_SNAPSHOT_MAX_BUFFER = 16 * 1024 * 1024;
1479
1643
 
1480
1644
  // src/resources.ts
1645
+ init_config();
1481
1646
  import { readFileSync as readFileSync2, statfsSync } from "fs";
1482
1647
  function isTransientResourceError(e) {
1483
1648
  const code = e?.code;
@@ -1619,13 +1784,18 @@ function hostResourceSnapshot() {
1619
1784
  };
1620
1785
  }
1621
1786
 
1787
+ // src/swarmClient.ts
1788
+ init_names2();
1789
+ init_storm();
1790
+
1622
1791
  // src/logTail.ts
1623
- import { closeSync, existsSync as existsSync2, openSync, readSync, readdirSync, statSync } from "fs";
1792
+ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from "fs";
1624
1793
  import { join as join2 } from "path";
1625
1794
  var TAIL_LINES_FALLBACK = 200;
1626
1795
  var TAIL_MAX_BYTES_FALLBACK = 64 * 1024;
1627
1796
 
1628
1797
  // src/swarmClient.ts
1798
+ init_src();
1629
1799
  var RESOLVER_TERMINAL = /* @__PURE__ */ new Set([...TERMINAL_STATUSES, "stalled"]);
1630
1800
  var SLUG_TERMINAL = new Set(TERMINAL_STATUSES);
1631
1801
  function runRef(runKey) {
@@ -1634,6 +1804,11 @@ function runRef(runKey) {
1634
1804
  function isRunRef(v) {
1635
1805
  return typeof v === "object" && v !== null && "__runRef" in v;
1636
1806
  }
1807
+ function isHostCommandShape(v) {
1808
+ const c = v;
1809
+ return !!c && typeof c === "object" && typeof c.id === "string" && !!c.id && typeof c.kind === "string";
1810
+ }
1811
+ var EMPTY_HEARTBEAT_REPLY = { commands: [], logRequests: [] };
1637
1812
  function jsonFromTextContent(res) {
1638
1813
  const text = res?.content?.find((c) => c.type === "text")?.text;
1639
1814
  if (text) {
@@ -1721,6 +1896,9 @@ var SwarmClient = class _SwarmClient {
1721
1896
  flushing = false;
1722
1897
  flushTimer = null;
1723
1898
  backoffMs = 500;
1899
+ /** Сколько раз пробуем отвергнутую запись, прежде чем выбросить её (TASWM-338). */
1900
+ rejected = /* @__PURE__ */ new Map();
1901
+ static REJECT_LIMIT = 5;
1724
1902
  static BACKOFF_BASE = 500;
1725
1903
  static BACKOFF_MAX = 3e4;
1726
1904
  constructor(opts) {
@@ -1732,7 +1910,7 @@ var SwarmClient = class _SwarmClient {
1732
1910
  this.repoFullNames = registry.map((e) => e.repoFullName);
1733
1911
  this.repoPathByFullName = new Map(registry.map((e) => [e.repoFullName, normalizeRepo(e.path)]));
1734
1912
  this.host = opts.host ?? null;
1735
- this.repoExists = opts.repoExists ?? existsSync3;
1913
+ this.repoExists = opts.repoExists ?? existsSync2;
1736
1914
  this.questionSyncMs = opts.questionSyncMs ?? 3e3;
1737
1915
  this.outboxPath = opts.outboxPath;
1738
1916
  this.mapPath = `${opts.outboxPath}.prodmap.json`;
@@ -1742,14 +1920,14 @@ var SwarmClient = class _SwarmClient {
1742
1920
  }
1743
1921
  // ── рестарт: поднять outbox (незаписанные операции) и карту runKey→prodId ─────
1744
1922
  recover() {
1745
- if (existsSync3(this.mapPath)) {
1923
+ if (existsSync2(this.mapPath)) {
1746
1924
  try {
1747
1925
  const obj = JSON.parse(readFileSync3(this.mapPath, "utf8"));
1748
1926
  for (const [k, v] of Object.entries(obj)) this.prodIdByKey.set(k, v);
1749
1927
  } catch {
1750
1928
  }
1751
1929
  }
1752
- if (existsSync3(this.outboxPath)) {
1930
+ if (existsSync2(this.outboxPath)) {
1753
1931
  const txt = readFileSync3(this.outboxPath, "utf8");
1754
1932
  for (const line of txt.split("\n")) {
1755
1933
  const s = line.trim();
@@ -1770,7 +1948,7 @@ var SwarmClient = class _SwarmClient {
1770
1948
  // eventsLog ПЕРВЫМИ (до hydrate), в порядке файла (= хронологический); id
1771
1949
  // переприсваиваем локально (id событий — только порядок, не корреляция с продом).
1772
1950
  replayGlobalEvents() {
1773
- if (!existsSync3(this.stormPath)) return;
1951
+ if (!existsSync2(this.stormPath)) return;
1774
1952
  let txt;
1775
1953
  try {
1776
1954
  txt = readFileSync3(this.stormPath, "utf8");
@@ -2441,7 +2619,7 @@ var SwarmClient = class _SwarmClient {
2441
2619
  * транзиентным сбоем ОС, который этот PR должен переживать, а не превращать в мор.
2442
2620
  */
2443
2621
  reconcileRuntime(opts) {
2444
- const wtExists = opts.worktreeExists ?? existsSync3;
2622
+ const wtExists = opts.worktreeExists ?? existsSync2;
2445
2623
  const pidByWorktree = /* @__PURE__ */ new Map();
2446
2624
  try {
2447
2625
  for (const p of opts.listProcesses()) if (p.worktree) pidByWorktree.set(p.worktree, p.pid);
@@ -2802,17 +2980,24 @@ var SwarmClient = class _SwarmClient {
2802
2980
  * прислал предыдущий (более свежий по коду) процесс демона.
2803
2981
  */
2804
2982
  /**
2805
- * TASWM-322: ответ на пульс — ЕДИНСТВЕННЫЙ канал «прод → демон» (демон за домашним
2806
- * роутером, прод к нему не постучится). Оттуда приезжают команды машине — сейчас одна:
2807
- * «пришли хвост лога» (logRequests). Возвращаем их вызывающему; пульс недоступен или
2808
- * прод старый (поля нет) → пустой список, всё как раньше.
2809
- */
2810
- /**
2983
+ * TASWM-321: ВОЗВРАЩАЕТ команды этой машине (пауза/рестарт/обновление/…), приехавшие
2984
+ * в ответе на пульс. Это единственный момент, когда прод может что-то сказать демону:
2985
+ * тот живёт за домашним роутером без белого адреса, и связь всегда инициирует он сам.
2986
+ * Прод, отдав команды, метит их доставленными — повторный пульс их не вернёт, поэтому
2987
+ * потерять ответ здесь значит потерять команду; парсим бережно и не бросаем.
2988
+ *
2989
+ * TASWM-322: тем же ответом приезжают запросы «пришли хвост лога» (logRequests) —
2990
+ * канал «прод → демон» один на оба вида поручений, поэтому и разбираем их вместе.
2991
+ * Возвращаем ОБА списка объектом: два разных потребителя в overmind, и слепить их в
2992
+ * один список значило бы заставить каждого фильтровать чужое.
2993
+ *
2811
2994
  * TASWM-326: тем же пульсом уходит ПАУЗА этой машины (`pause` — durable-запись из
2812
2995
  * pause.json либо null). В парке машин «на паузе» обязано отличаться от «нет работы»
2813
2996
  * и от «молчит», а единственный канал «демон → прод» с нужной кадансностью — пульс.
2814
2997
  * Шлём ВСЕГДА (в т.ч. `paused:false`): это состояние, а не команда, и молчание тут
2815
2998
  * означало бы «прод помнит вчерашнюю паузу», когда рой давно работает.
2999
+ *
3000
+ * Прод недоступен / ответ без поручений → пустые списки (демон работает дальше).
2816
3001
  */
2817
3002
  async heartbeat(daemonVersion, pause) {
2818
3003
  try {
@@ -2827,9 +3012,33 @@ var SwarmClient = class _SwarmClient {
2827
3012
  args.hostName = this.host.hostName;
2828
3013
  }
2829
3014
  const res = await this.call("swarm_heartbeat", args);
2830
- return parseLogRequests(res?.structuredContent?.logRequests);
3015
+ if (res?.isError) return EMPTY_HEARTBEAT_REPLY;
3016
+ const data = res?.structuredContent ?? {};
3017
+ return {
3018
+ commands: Array.isArray(data.commands) ? data.commands.filter(isHostCommandShape) : [],
3019
+ logRequests: parseLogRequests(data.logRequests)
3020
+ };
3021
+ } catch {
3022
+ return EMPTY_HEARTBEAT_REPLY;
3023
+ }
3024
+ }
3025
+ /**
3026
+ * TASWM-321: подтвердить исполнение команды (`swarm_command_ack`). ПРЯМОЙ вызов, не
3027
+ * outbox: подтверждение — «скажи СЕЙЧАС», ради него команду и заводили («без
3028
+ * подтверждения непонятно, обновился он или команда потерялась по дороге»), а
3029
+ * промежуточные шаги обновления, доехавшие после самого обновления, бессмысленны.
3030
+ * Не бросает: сорвавшийся ack не должен рушить тик демона — команда останется в
3031
+ * прошлом состоянии, следующий шаг/финал её всё равно подвинут.
3032
+ */
3033
+ async ackCommand(commandId, status, patch = {}) {
3034
+ try {
3035
+ await this.call("swarm_command_ack", {
3036
+ commandId,
3037
+ status,
3038
+ ...patch.step ? { step: patch.step } : {},
3039
+ ...patch.result ? { result: patch.result.slice(0, 2e3) } : {}
3040
+ });
2831
3041
  } catch {
2832
- return [];
2833
3042
  }
2834
3043
  }
2835
3044
  /**
@@ -3238,7 +3447,23 @@ var SwarmClient = class _SwarmClient {
3238
3447
  } catch {
3239
3448
  break;
3240
3449
  }
3241
- if (res?.isError) break;
3450
+ if (res?.isError) {
3451
+ const reason = (res.content ?? []).map((c) => c.text).filter(Boolean).join(" ").trim() || "\u0431\u0435\u0437 \u043E\u0431\u044A\u044F\u0441\u043D\u0435\u043D\u0438\u044F";
3452
+ const tries = (this.rejected.get(e.seq) ?? 0) + 1;
3453
+ this.rejected.set(e.seq, tries);
3454
+ if (tries >= _SwarmClient.REJECT_LIMIT) {
3455
+ this.pending.shift();
3456
+ this.rejected.delete(e.seq);
3457
+ console.error(
3458
+ `[swarm-client] \u043F\u0440\u043E\u0434 \u043E\u0442\u0432\u0435\u0440\u0433 ${call.tool} ${tries} \u0440\u0430\u0437(\u0430) \u2014 \u0412\u042B\u0411\u0420\u0410\u0421\u042B\u0412\u0410\u042E \u044D\u0442\u0443 \u0437\u0430\u043F\u0438\u0441\u044C, \u0438\u043D\u0430\u0447\u0435 \u043E\u043D\u0430 \u0434\u0435\u0440\u0436\u0438\u0442 \u0432\u0441\u044E \u043E\u0447\u0435\u0440\u0435\u0434\u044C. \u041F\u043E\u0442\u0435\u0440\u044F\u043D\u043E: ${e.t}${e.t === "event" ? ` (${e.kind})` : ""}. \u041F\u0440\u0438\u0447\u0438\u043D\u0430 \u043E\u0442\u043A\u0430\u0437\u0430: ${reason}`
3459
+ );
3460
+ continue;
3461
+ }
3462
+ console.error(
3463
+ `[swarm-client] \u043F\u0440\u043E\u0434 \u043E\u0442\u0432\u0435\u0440\u0433 ${call.tool}: ${reason} \u2014 \u043F\u043E\u043F\u044B\u0442\u043A\u0430 ${tries}/${_SwarmClient.REJECT_LIMIT}, \u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438 ${this.pending.length} \u0437\u0430\u043F\u0438\u0441(\u0435\u0439). \u041F\u043E\u043A\u0430 \u043E\u043D\u0430 \u043D\u0435 \u043F\u0440\u043E\u0439\u0434\u0451\u0442, \u043F\u0440\u043E\u0434 \u041D\u0415 \u0443\u0432\u0438\u0434\u0438\u0442 \u043D\u0438\u0447\u0435\u0433\u043E \u043D\u043E\u0432\u043E\u0433\u043E.`
3464
+ );
3465
+ break;
3466
+ }
3242
3467
  if (e.t === "run") {
3243
3468
  const run = res.structuredContent?.run;
3244
3469
  if (run?.id && this.prodIdByKey.get(e.runKey) !== run.id) {
@@ -3298,6 +3523,9 @@ function parseDecision(v) {
3298
3523
  }
3299
3524
  }
3300
3525
 
3526
+ // src/cli.ts
3527
+ init_config();
3528
+
3301
3529
  // src/daemonLaunch.ts
3302
3530
  import { openSync as openSync2, closeSync as closeSync2, renameSync as renameSync2, statSync as statSync2, writeFileSync as writeFileSync3, readFileSync as readFileSync4 } from "fs";
3303
3531
  import { spawn } from "child_process";
@@ -3609,6 +3837,7 @@ async function cleanupWorktreeProcesses(worktree, opts = {}) {
3609
3837
  }
3610
3838
 
3611
3839
  // src/repoRegistry.ts
3840
+ init_config();
3612
3841
  import { $ } from "execa";
3613
3842
  function parseRepoFullName(remoteUrl) {
3614
3843
  const m = remoteUrl.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(\.git)?\/?$/i);
@@ -3634,14 +3863,14 @@ async function buildRepoRegistry(paths) {
3634
3863
 
3635
3864
  // src/hostIdentity.ts
3636
3865
  import { hostname } from "os";
3637
- import { existsSync as existsSync4, readFileSync as readFileSync5, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs";
3866
+ import { existsSync as existsSync3, readFileSync as readFileSync5, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs";
3638
3867
  import { join as join3 } from "path";
3639
3868
  import { randomUUID as randomUUID2 } from "crypto";
3640
3869
  function hostIdentityPath(home) {
3641
3870
  return join3(home, "host.json");
3642
3871
  }
3643
3872
  function loadHostIdentity(deps) {
3644
- const exists = deps.exists ?? existsSync4;
3873
+ const exists = deps.exists ?? existsSync3;
3645
3874
  const readFile2 = deps.readFile ?? ((p) => readFileSync5(p, "utf8"));
3646
3875
  const writeFile2 = deps.writeFile ?? ((p, data) => {
3647
3876
  writeFileSync4(`${p}.tmp`, data);
@@ -3671,6 +3900,7 @@ function loadHostIdentity(deps) {
3671
3900
  }
3672
3901
 
3673
3902
  // src/runList.ts
3903
+ init_src();
3674
3904
  function hostGroupHeader(g, thisHostId) {
3675
3905
  const running = g.runs.filter((r) => r.status === "running").length;
3676
3906
  const mine = !g.unknown && g.key === thisHostId ? " (\u044D\u0442\u0430 \u043C\u0430\u0448\u0438\u043D\u0430)" : "";
@@ -3702,6 +3932,7 @@ function renderRunList(runs, opts) {
3702
3932
  }
3703
3933
 
3704
3934
  // src/hostPick.ts
3935
+ init_src();
3705
3936
  function chooseSpawnHost(flag) {
3706
3937
  if (flag == null) return { kind: "self" };
3707
3938
  const ref = normalizeHostRef(flag);
@@ -3748,6 +3979,7 @@ function hostLines(hosts, opts = {}) {
3748
3979
  }
3749
3980
 
3750
3981
  // src/web.ts
3982
+ init_src();
3751
3983
  import { randomBytes as randomBytes3 } from "crypto";
3752
3984
  import { basename as basename2 } from "path";
3753
3985
  import { serve as serve2 } from "@hono/node-server";
@@ -3941,7 +4173,7 @@ async function collectRunDiff(worktree, opts = {}) {
3941
4173
  // package.json
3942
4174
  var package_default = {
3943
4175
  name: "@taskless-app/swarm",
3944
- version: "0.4.7",
4176
+ version: "0.4.10",
3945
4177
  type: "module",
3946
4178
  description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
3947
4179
  keywords: [
@@ -3990,13 +4222,14 @@ var package_default = {
3990
4222
  "@modelcontextprotocol/sdk": "^1.29.0",
3991
4223
  execa: "^10.0.0",
3992
4224
  zod: "3.25.76"
3993
- },
3994
- optionalDependencies: {
3995
- "better-sqlite3": "^12.11.1"
3996
4225
  }
3997
4226
  };
3998
4227
 
4228
+ // src/web.ts
4229
+ init_storm();
4230
+
3999
4231
  // src/gh.ts
4232
+ init_src();
4000
4233
  import { $ as $3 } from "execa";
4001
4234
  var NO_CHECKS_STDERR = /no checks reported/i;
4002
4235
  var realChecksRunner = async (pr) => {
@@ -4042,7 +4275,11 @@ async function ghChecks(pr, run = realChecksRunner) {
4042
4275
  return { bucket, text, tone, total, pass, fail, pending: pending2 };
4043
4276
  }
4044
4277
 
4278
+ // src/web.ts
4279
+ init_config();
4280
+
4045
4281
  // src/droneChannel.ts
4282
+ init_src();
4046
4283
  import { createHmac, randomBytes, timingSafeEqual } from "crypto";
4047
4284
  import { chmodSync, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
4048
4285
  import { dirname as dirname2 } from "path";
@@ -4088,6 +4325,7 @@ function verifyLocalCliToken(token, secret) {
4088
4325
  var EVENT_KIND_SET = new Set(EVENT_KINDS);
4089
4326
 
4090
4327
  // src/oauth.ts
4328
+ init_config();
4091
4329
  import { createHash, randomBytes as randomBytes2 } from "crypto";
4092
4330
  import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, unlinkSync } from "fs";
4093
4331
  import { join as join5 } from "path";
@@ -5786,14 +6024,14 @@ import { cancelRun, respawnRun, runDaemon, wakeRun, OVERMIND_ENTRY, listDronePro
5786
6024
 
5787
6025
  // src/launcher.ts
5788
6026
  import { spawn as spawn2 } from "child_process";
5789
- import { closeSync as closeSync3, existsSync as existsSync6, mkdirSync as mkdirSync5, openSync as openSync3, readdirSync as readdirSync4, statSync as statSync5, writeFileSync as writeFileSync7 } from "fs";
5790
- import { createRequire as createRequire2 } from "module";
6027
+ import { closeSync as closeSync3, existsSync as existsSync5, mkdirSync as mkdirSync5, openSync as openSync3, readdirSync as readdirSync4, statSync as statSync5, writeFileSync as writeFileSync7 } from "fs";
6028
+ import { createRequire } from "module";
5791
6029
  import { homedir as homedir2 } from "os";
5792
6030
  import { dirname as dirname3, join as join7 } from "path";
5793
6031
  import { fileURLToPath } from "url";
5794
6032
 
5795
6033
  // src/claudeBin.ts
5796
- import { existsSync as existsSync5, readdirSync as readdirSync2 } from "fs";
6034
+ import { existsSync as existsSync4, readdirSync as readdirSync2 } from "fs";
5797
6035
  import { win32 as winPath } from "path";
5798
6036
  var CLAUDE_FALLBACK = "claude";
5799
6037
  function compareVersionDirs(a, b) {
@@ -5830,7 +6068,7 @@ function winVersionRoots(env, listDir) {
5830
6068
  function findWindowsClaude(opts = {}) {
5831
6069
  const env = opts.env ?? process.env;
5832
6070
  const listDir = opts.listDir ?? realListDir;
5833
- const fileExists = opts.fileExists ?? existsSync5;
6071
+ const fileExists = opts.fileExists ?? existsSync4;
5834
6072
  let best = null;
5835
6073
  for (const root of winVersionRoots(env, listDir)) {
5836
6074
  for (const version of listDir(root)) {
@@ -5984,7 +6222,7 @@ function droneTestEnv(droneCap, base3 = process.env, host = hostForkSpec()) {
5984
6222
  }
5985
6223
 
5986
6224
  // src/launcher.ts
5987
- var requireFromHere = createRequire2(import.meta.url);
6225
+ var requireFromHere = createRequire(import.meta.url);
5988
6226
  function resolveTsxCli() {
5989
6227
  try {
5990
6228
  return requireFromHere.resolve("tsx/cli");
@@ -6050,7 +6288,7 @@ function roleDir(worktree, kind) {
6050
6288
  return join7(worktree, ".claude", "roles", kind);
6051
6289
  }
6052
6290
  function roleDirs(worktree, kind) {
6053
- return [bundledRoleDir(kind), roleDir(worktree, kind)].filter((dir) => existsSync6(dir));
6291
+ return [bundledRoleDir(kind), roleDir(worktree, kind)].filter((dir) => existsSync5(dir));
6054
6292
  }
6055
6293
  function nextRunLogPath(runLogsDir, runId) {
6056
6294
  const dir = join7(runLogsDir, String(runId));
@@ -6146,7 +6384,7 @@ function makeLauncher(cfg) {
6146
6384
  const realHome = cfg.droneIsolation?.realHome ?? homedir2();
6147
6385
  const home = cfg.droneIsolation ? buildDroneHome({ realHome, droneHome: cfg.droneIsolation.home }) : void 0;
6148
6386
  const settings = cfg.droneIsolation ? droneSettingsJson(realHome, cfg.droneIsolation.swarmHome) : void 0;
6149
- if (cfg.droneIsolation && process.env.TASKLESS_TOKEN && !existsSync6(join7(cfg.droneIsolation.swarmHome, "token"))) {
6387
+ if (cfg.droneIsolation && process.env.TASKLESS_TOKEN && !existsSync5(join7(cfg.droneIsolation.swarmHome, "token"))) {
6150
6388
  console.error(
6151
6389
  `[launcher] TASKLESS_TOKEN \u0437\u0430\u0434\u0430\u043D \u0442\u043E\u043B\u044C\u043A\u043E \u0432 env \u0434\u0435\u043C\u043E\u043D\u0430 \u2014 \u0434\u0440\u043E\u043D\u0443 \u043E\u043D \u043D\u0435 \u043D\u0430\u0441\u043B\u0435\u0434\u0443\u0435\u0442\u0441\u044F (TASWM-289), \u0430 \u0444\u0430\u0439\u043B\u0430 ${join7(cfg.droneIsolation.swarmHome, "token")} \u043D\u0435\u0442. read_task \u0434\u0440\u043E\u043D\u0430 \u0431\u0443\u0434\u0435\u0442 slug-only. \u041F\u043E\u0447\u0438\u043D\u0438: \`swarm login\` (oauth.json) \u0438\u043B\u0438 \u043F\u043E\u043B\u043E\u0436\u0438 \u0442\u043E\u043A\u0435\u043D \u0432 \u044D\u0442\u043E\u0442 \u0444\u0430\u0439\u043B.`
6152
6390
  );
@@ -6179,7 +6417,7 @@ function makeLauncher(cfg) {
6179
6417
  }
6180
6418
 
6181
6419
  // src/git.ts
6182
- import { existsSync as existsSync7, readdirSync as readdirSync5, statSync as statSync6 } from "fs";
6420
+ import { existsSync as existsSync6, readdirSync as readdirSync5, statSync as statSync6 } from "fs";
6183
6421
  import { appendFile, mkdir, readFile, rm } from "fs/promises";
6184
6422
  import { isAbsolute, join as join8 } from "path";
6185
6423
  import { $ as $4 } from "execa";
@@ -6197,7 +6435,7 @@ async function detachWorktree(repo, worktree) {
6197
6435
  }
6198
6436
  }
6199
6437
  async function ensureWorktreeDirGone(worktree) {
6200
- if (!existsSync7(worktree)) return false;
6438
+ if (!existsSync6(worktree)) return false;
6201
6439
  if (!isLinkedWorktree(worktree)) {
6202
6440
  console.error(
6203
6441
  `[git] ${worktree} \u2014 \u043D\u0435 \u043F\u0440\u0438\u0441\u0442\u0451\u0433\u043D\u0443\u0442\u044B\u0439 worktree (.git \u043D\u0435 \u0444\u0430\u0439\u043B): \u043A\u0430\u0442\u0430\u043B\u043E\u0433 \u041D\u0415 \u0442\u0440\u043E\u0433\u0430\u044E. \u0415\u0441\u043B\u0438 \u044D\u0442\u043E \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043B\u044C\u043D\u043E worktree \u0434\u0440\u043E\u043D\u0430 \u2014 \u0443\u0431\u0435\u0440\u0438\u0442\u0435 \u0435\u0433\u043E \u0440\u0443\u043A\u0430\u043C\u0438; \u0435\u0441\u043B\u0438 \u044D\u0442\u043E \u043E\u0441\u043D\u043E\u0432\u043D\u043E\u0439 \u0447\u0435\u043A\u0430\u0443\u0442, \u0437\u043D\u0430\u0447\u0438\u0442 \u0432\u0435\u0442\u043A\u0430 \u0441\u043B\u0430\u0433\u0430 \u0447\u0435\u043A\u043D\u0443\u0442\u0430 \u0432 \u043D\u0451\u043C (git checkout <slug>) \u2014 \u043F\u0435\u0440\u0435\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0435\u0433\u043E \u043D\u0430 \u0434\u0440\u0443\u0433\u0443\u044E \u0432\u0435\u0442\u043A\u0443.`
@@ -6212,7 +6450,7 @@ async function ensureWorktreeDirGone(worktree) {
6212
6450
  );
6213
6451
  return true;
6214
6452
  }
6215
- if (existsSync7(worktree)) {
6453
+ if (existsSync6(worktree)) {
6216
6454
  console.error(`[git] \u043A\u0430\u0442\u0430\u043B\u043E\u0433 worktree ${worktree} \u043F\u0435\u0440\u0435\u0436\u0438\u043B rm \u2014 \u0443\u0431\u0435\u0440\u0438\u0442\u0435 \u0440\u0443\u043A\u0430\u043C\u0438, \u0438\u043D\u0430\u0447\u0435 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u0439 \u0441\u043F\u0430\u0432\u043D \u0441\u043B\u0430\u0433\u0430 \u0432\u0441\u0442\u0430\u043D\u0435\u0442.`);
6217
6455
  }
6218
6456
  return true;
@@ -6225,6 +6463,7 @@ async function removeWorktree(repo, path) {
6225
6463
  }
6226
6464
 
6227
6465
  // src/agentToken.ts
6466
+ init_config();
6228
6467
  import { readFileSync as readFileSync8, writeFileSync as writeFileSync8, unlinkSync as unlinkSync2 } from "fs";
6229
6468
  import { join as join9 } from "path";
6230
6469
  var STORE_PATH2 = join9(SWARM_HOME, "agent.json");
@@ -6271,6 +6510,7 @@ async function probeAgentToken(args, fetchFn = fetch) {
6271
6510
  }
6272
6511
 
6273
6512
  // src/taskless.ts
6513
+ init_src();
6274
6514
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6275
6515
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6276
6516
 
@@ -6289,7 +6529,11 @@ import { readFile as fsReadFile } from "fs/promises";
6289
6529
  import { basename as basename3, isAbsolute as isAbsolute2, join as join11 } from "path";
6290
6530
  var SHOT_MAX_FILE_BYTES = 10 * 1024 * 1024;
6291
6531
 
6532
+ // src/taskless.ts
6533
+ init_config();
6534
+
6292
6535
  // src/workspace.ts
6536
+ init_config();
6293
6537
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync9, unlinkSync as unlinkSync3 } from "fs";
6294
6538
  import { join as join12 } from "path";
6295
6539
  var WORKSPACE_PATH = join12(SWARM_HOME, "workspace.json");
@@ -6644,6 +6888,7 @@ async function launchChromium(opts = {}) {
6644
6888
  }
6645
6889
 
6646
6890
  // src/shotCli.ts
6891
+ init_config();
6647
6892
  function isFlagValue(v) {
6648
6893
  return v !== void 0 && !v.startsWith("--");
6649
6894
  }
@@ -6718,6 +6963,7 @@ async function runShotCli(argv, deps = {}) {
6718
6963
  }
6719
6964
 
6720
6965
  // src/daemonLink.ts
6966
+ init_config();
6721
6967
  var TIMEOUT_MS = 5e3;
6722
6968
  var ACTION_TIMEOUT_MS = 2e4;
6723
6969
  var BASE = `http://127.0.0.1:${WEB_PORT}`;
@@ -6783,14 +7029,14 @@ async function daemonStatus2() {
6783
7029
  var NO_DAEMON_NOTE = `\u0434\u0435\u043C\u043E\u043D \u043D\u0435 \u043E\u0442\u0432\u0435\u0447\u0430\u0435\u0442 (\u043C\u043E\u0440\u0434\u0430 127.0.0.1:${WEB_PORT}) \u2014 \u043F\u043E\u043A\u0430\u0437\u044B\u0432\u0430\u044E \u041B\u041E\u041A\u0410\u041B\u042C\u041D\u042B\u0419 \u0441\u0442\u043E\u0440 \u044D\u0442\u043E\u0439 \u043C\u0430\u0448\u0438\u043D\u044B, \u043E\u043D \u043C\u043E\u0436\u0435\u0442 \u0440\u0430\u0441\u0445\u043E\u0434\u0438\u0442\u044C\u0441\u044F \u0441 \u0434\u0430\u0448\u0431\u043E\u0440\u0434\u043E\u043C. \u041F\u043E\u0434\u043D\u0438\u043C\u0438 \u0434\u0435\u043C\u043E\u043D\u0430: swarm start`;
6784
7030
 
6785
7031
  // src/pauseState.ts
6786
- import { existsSync as existsSync8, readFileSync as readFileSync10, renameSync as renameSync4, unlinkSync as unlinkSync4, writeFileSync as writeFileSync10 } from "fs";
7032
+ import { existsSync as existsSync7, readFileSync as readFileSync10, renameSync as renameSync4, unlinkSync as unlinkSync4, writeFileSync as writeFileSync10 } from "fs";
6787
7033
  import { join as join13 } from "path";
6788
7034
  function pauseStatePath(home) {
6789
7035
  return join13(home, "pause.json");
6790
7036
  }
6791
7037
  var ACTIVE = { paused: false, at: "", source: "cli" };
6792
7038
  function loadPauseState(home, io = {}) {
6793
- const exists = io.exists ?? existsSync8;
7039
+ const exists = io.exists ?? existsSync7;
6794
7040
  const readFile2 = io.readFile ?? ((p) => readFileSync10(p, "utf8"));
6795
7041
  const path = pauseStatePath(home);
6796
7042
  if (!exists(path)) return ACTIVE;
@@ -6810,7 +7056,7 @@ function loadPauseState(home, io = {}) {
6810
7056
  function savePauseState(home, state, io = {}) {
6811
7057
  const path = pauseStatePath(home);
6812
7058
  if (!state.paused) {
6813
- const remove = io.remove ?? ((p) => existsSync8(p) && unlinkSync4(p));
7059
+ const remove = io.remove ?? ((p) => existsSync7(p) && unlinkSync4(p));
6814
7060
  remove(path);
6815
7061
  return;
6816
7062
  }
@@ -6856,6 +7102,7 @@ function readHeartbeat(path) {
6856
7102
  }
6857
7103
 
6858
7104
  // src/cli.ts
7105
+ init_src();
6859
7106
  var PACKAGE_VERSION2 = package_default.version;
6860
7107
  function takeRepo(argv) {
6861
7108
  const i = argv.indexOf("--repo");
@@ -7353,7 +7600,10 @@ function effectiveWorktreesDir() {
7353
7600
  return effectiveWorktreesDirMemo;
7354
7601
  }
7355
7602
  async function makeCliStore(repo) {
7356
- if (SWARM_BACKEND !== "client") return new SwarmDb(DB_PATH, repo);
7603
+ if (SWARM_BACKEND !== "client") {
7604
+ const { SwarmDb: SwarmDb2 } = await Promise.resolve().then(() => (init_db(), db_exports));
7605
+ return new SwarmDb2(DB_PATH, repo);
7606
+ }
7357
7607
  const call = resilientSwarmMcp({
7358
7608
  onDown: (ms) => console.warn(`Swarm MCP \u043C\u043E\u043B\u0447\u0438\u0442 ${Math.round(ms / 1e3)}\u0441 \u2014 \u0440\u0430\u0431\u043E\u0442\u0430\u0435\u043C \u0432\u0441\u043B\u0435\u043F\u0443\u044E, \u043F\u0440\u043E\u0432\u0435\u0440\u044C \u0441\u0432\u044F\u0437\u044C \u0441 \u043F\u0440\u043E\u0434\u043E\u043C.`),
7359
7609
  onUp: () => console.warn("Swarm MCP \u0441\u043D\u043E\u0432\u0430 \u043D\u0430 \u0441\u0432\u044F\u0437\u0438.")
@@ -7411,7 +7661,8 @@ if (cmd === "import-db") {
7411
7661
  console.error("\u043D\u0443\u0436\u043D\u043E: swarm import-db <path> --repo <path>");
7412
7662
  process.exit(1);
7413
7663
  }
7414
- const sdb = new SwarmDb(DB_PATH, repo);
7664
+ const { SwarmDb: SwarmDb2 } = await Promise.resolve().then(() => (init_db(), db_exports));
7665
+ const sdb = new SwarmDb2(DB_PATH, repo);
7415
7666
  const res = sdb.importDb(path, repo);
7416
7667
  if (res.imported) console.log(`\u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E ${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432 \u0438\u0437 ${path} (repo=${repo})`);
7417
7668
  else console.log(`${path} \u0443\u0436\u0435 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D \u0440\u0430\u043D\u0435\u0435 (${res.runs} \u043F\u0440\u043E\u0433\u043E\u043D\u043E\u0432) \u2014 no-op`);