@taskless-app/swarm 0.4.5 → 0.4.9

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 (4) hide show
  1. package/README.md +8 -2
  2. package/dist/cli.js +1181 -986
  3. package/dist/overmind.js +2311 -2127
  4. 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,44 @@ 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
374
  return cleaned.length ? cleaned : null;
340
375
  }
376
+ var ANSI_CSI_RE, CONTROL_CHARS_RE;
377
+ var init_text = __esm({
378
+ "../swarm-core/src/text.ts"() {
379
+ "use strict";
380
+ ANSI_CSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
381
+ CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/g;
382
+ }
383
+ });
384
+
385
+ // ../swarm-core/src/comment.ts
386
+ var init_comment = __esm({
387
+ "../swarm-core/src/comment.ts"() {
388
+ "use strict";
389
+ }
390
+ });
341
391
 
342
392
  // ../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
393
  function toRoman(n) {
373
394
  if (n < 1 || n > 3999) return String(n);
374
395
  const table = [
@@ -396,10 +417,43 @@ function genName(base3, g) {
396
417
  function baseName(name) {
397
418
  return name.split(" ")[0];
398
419
  }
420
+ var DRONE_POOL, ACTIVE_NAME_STATUSES;
421
+ var init_names = __esm({
422
+ "../swarm-core/src/names.ts"() {
423
+ "use strict";
424
+ DRONE_POOL = [
425
+ // Латинский бестиарий (15)
426
+ "SCARAB",
427
+ "VESPA",
428
+ "MANTIS",
429
+ "HORNET",
430
+ "CICADA",
431
+ "LOCUST",
432
+ "ATTA",
433
+ "FORMICA",
434
+ "MYRMEX",
435
+ "APIS",
436
+ "TERMES",
437
+ "GRYLLUS",
438
+ "LAMPYRIS",
439
+ "ARACHNE",
440
+ "SPHINX",
441
+ // Мифо-стаи (9)
442
+ "TIAMAT",
443
+ "JORMUNGAND",
444
+ "GRENDEL",
445
+ "TYPHON",
446
+ "FENRIS",
447
+ "GARM",
448
+ "KUKULKAN",
449
+ "LEVIATHAN",
450
+ "SURTUR"
451
+ ];
452
+ ACTIVE_NAME_STATUSES = ["queued", "running", "blocked", "review"];
453
+ }
454
+ });
399
455
 
400
456
  // ../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
457
  function normalizeBaseBranch(raw) {
404
458
  if (raw == null) return void 0;
405
459
  if (typeof raw !== "string") return null;
@@ -409,19 +463,22 @@ function normalizeBaseBranch(raw) {
409
463
  if (v.includes("..") || v.endsWith("/") || v.endsWith(".lock")) return null;
410
464
  return BASE_BRANCH_RE.test(v) ? v : null;
411
465
  }
466
+ var BASE_BRANCH_MAX, BASE_BRANCH_RE;
467
+ var init_baseBranch = __esm({
468
+ "../swarm-core/src/baseBranch.ts"() {
469
+ "use strict";
470
+ BASE_BRANCH_MAX = 255;
471
+ BASE_BRANCH_RE = /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
472
+ }
473
+ });
412
474
 
413
475
  // ../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
476
  function normalizeHostRef(value) {
416
477
  if (value == null) return null;
417
478
  const trimmed = value.trim();
418
479
  if (!trimmed) return null;
419
480
  return ANY_HOST_TOKENS.has(trimmed.toLowerCase()) ? null : trimmed;
420
481
  }
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
482
  function splitQueue(items, ref) {
426
483
  const queue = [];
427
484
  const hosted = [];
@@ -432,7 +489,6 @@ function splitQueue(items, ref) {
432
489
  }
433
490
  return { queue, hosted };
434
491
  }
435
- var HOST_HEARTBEAT_STALE_MS = 45e3;
436
492
  function groupByHost(items, ref, opts = {}) {
437
493
  const { hosts = [], nowMs = Date.now(), staleMs = HOST_HEARTBEAT_STALE_MS } = opts;
438
494
  const infoById = new Map(hosts.map((h) => [h.hostId, h]));
@@ -488,10 +544,51 @@ function hostStateText(host, nowMs = Date.now(), staleMs = HOST_HEARTBEAT_STALE_
488
544
  function shouldGroupByHost(groups) {
489
545
  return groups.length > 1;
490
546
  }
547
+ var ANY_HOST_TOKENS, UNKNOWN_HOST_KEY, UNKNOWN_HOST_LABEL, QUEUE_LABEL, NOT_STARTED_STATUSES, HOST_HEARTBEAT_STALE_MS;
548
+ var init_hosts = __esm({
549
+ "../swarm-core/src/hosts.ts"() {
550
+ "use strict";
551
+ ANY_HOST_TOKENS = /* @__PURE__ */ new Set(["any", "none", "null", "free", "-", "\u043B\u044E\u0431\u0430\u044F", "\u043B\u044E\u0431\u043E\u0439"]);
552
+ UNKNOWN_HOST_KEY = "__no-host__";
553
+ UNKNOWN_HOST_LABEL = "\u043C\u0430\u0448\u0438\u043D\u0430 \u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u0430";
554
+ QUEUE_LABEL = "\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438";
555
+ NOT_STARTED_STATUSES = /* @__PURE__ */ new Set(["queued"]);
556
+ HOST_HEARTBEAT_STALE_MS = 45e3;
557
+ }
558
+ });
559
+
560
+ // ../swarm-core/src/relief.ts
561
+ var init_relief = __esm({
562
+ "../swarm-core/src/relief.ts"() {
563
+ "use strict";
564
+ init_time();
565
+ }
566
+ });
567
+
568
+ // ../swarm-core/src/index.ts
569
+ var init_src = __esm({
570
+ "../swarm-core/src/index.ts"() {
571
+ "use strict";
572
+ init_types();
573
+ init_timeline();
574
+ init_reviewPhase();
575
+ init_status();
576
+ init_time();
577
+ init_windows();
578
+ init_slug();
579
+ init_text();
580
+ init_comment();
581
+ init_names();
582
+ init_baseBranch();
583
+ init_hosts();
584
+ init_relief();
585
+ }
586
+ });
491
587
 
492
588
  // src/config.ts
493
- var SWARM_HOME = process.env.SWARM_HOME || join(homedir(), ".taskless-swarm");
494
- mkdirSync(SWARM_HOME, { recursive: true });
589
+ import { homedir } from "os";
590
+ import { basename, join, resolve } from "path";
591
+ import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs";
495
592
  function normalizeRepo(repo) {
496
593
  const r = (repo ?? "").trim();
497
594
  if (!r) return "";
@@ -501,15 +598,6 @@ function normalizeRepo(repo) {
501
598
  return resolve(r);
502
599
  }
503
600
  }
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
601
  function droneWorktreePath(worktreesDir, repo, slug) {
514
602
  return join(worktreesDir, basename(normalizeRepo(repo)), slug);
515
603
  }
@@ -519,48 +607,12 @@ function resolveEffectiveWorktreesDir(deps) {
519
607
  if (deps.liveDronesUnder(deps.legacyDir)) return deps.legacyDir;
520
608
  return deps.targetDir;
521
609
  }
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
610
  function envNum(name, def) {
526
611
  const raw = process.env[name];
527
612
  if (raw === void 0 || raw === "") return def;
528
613
  const n = Number(raw);
529
614
  return Number.isFinite(n) && n >= 0 ? n : def;
530
615
  }
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_REPO_PATHS = (process.env.SWARM_REPOS || SWARM_REPO).split(",").map((s) => s.trim()).filter(Boolean);
561
- var TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
562
- var SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
563
- var TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
564
616
  function mcpEndpoint(api, path = TASKLESS_MCP_PATH) {
565
617
  const base3 = api.replace(/\/$/, "");
566
618
  const p = path.startsWith("/") ? path : `/${path}`;
@@ -573,32 +625,86 @@ function tokenFromFile() {
573
625
  return "";
574
626
  }
575
627
  }
576
- var TASKLESS_TOKEN = process.env.TASKLESS_TOKEN || tokenFromFile();
577
- var TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
578
- var TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
579
- var TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
580
- var SWARM_VERIFY = process.env.SWARM_VERIFY === "ci" ? "ci" : "local";
581
- var SWARM_AUTO_REVIEW = process.env.SWARM_AUTO_REVIEW !== "0";
582
- var REVIEW_CUTOFF_PATH = process.env.SWARM_REVIEW_CUTOFF || join(SWARM_HOME, "review-cutoff.json");
583
- var SWARM_REVIEW_CAP = Math.trunc(envNum("SWARM_REVIEW_CAP", 1));
584
- var SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
585
- var WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
586
- var WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
587
- var SWARM_ANOMALY_RENOTIFY_MS = envNum("SWARM_ANOMALY_RENOTIFY_MS", 60 * 6e4);
588
- var SWARM_ANOMALY_MAX_REPEATS = Math.trunc(envNum("SWARM_ANOMALY_MAX_REPEATS", 3));
589
- var SWARM_ANOMALY_RUN_DOWN_WINDOW_MS = envNum("SWARM_ANOMALY_RUN_DOWN_WINDOW_MS", 24 * 60 * 6e4);
590
- var SWARM_ANOMALY_FAILED_SPIKE_COUNT = Math.trunc(envNum("SWARM_ANOMALY_FAILED_SPIKE_COUNT", 3));
591
- var SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS = envNum("SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS", 30 * 6e4);
592
- var SWARM_ANOMALY_QUEUE_STALLED_MS = envNum("SWARM_ANOMALY_QUEUE_STALLED_MS", 10 * 6e4);
593
- var SWARM_ANOMALY_PR_STUCK_MERGE_MS = envNum("SWARM_ANOMALY_PR_STUCK_MERGE_MS", 30 * 6e4);
594
- var SWARM_ANOMALY_PR_NO_REVIEWER_MS = envNum("SWARM_ANOMALY_PR_NO_REVIEWER_MS", 20 * 6e4);
595
- var SWARM_ANOMALY_QUESTION_UNANSWERED_MS = envNum("SWARM_ANOMALY_QUESTION_UNANSWERED_MS", 20 * 6e4);
596
- var SWARM_ANOMALY_OUTBOX_STUCK_MS = envNum("SWARM_ANOMALY_OUTBOX_STUCK_MS", 10 * 6e4);
597
- var SWARM_ANOMALY_RESOURCE_GATE_STALL_MS = envNum("SWARM_ANOMALY_RESOURCE_GATE_STALL_MS", 5 * 6e4);
598
- var SWARM_WATCHDOG_STALL_MS = envNum("SWARM_WATCHDOG_STALL_MS", 15 * 6e4);
628
+ 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;
629
+ var init_config = __esm({
630
+ "src/config.ts"() {
631
+ "use strict";
632
+ init_src();
633
+ SWARM_HOME = process.env.SWARM_HOME || join(homedir(), ".taskless-swarm");
634
+ mkdirSync(SWARM_HOME, { recursive: true });
635
+ DB_PATH = process.env.SWARM_DB || join(SWARM_HOME, "swarm.db");
636
+ DRONE_HOME = process.env.SWARM_DRONE_HOME || join(SWARM_HOME, "drone-home");
637
+ DRONE_HOME_ISOLATION = process.env.SWARM_DRONE_ISOLATE_HOME !== "0" && process.platform !== "win32";
638
+ DRONE_ISOLATION = DRONE_HOME_ISOLATION ? { home: DRONE_HOME, swarmHome: SWARM_HOME } : void 0;
639
+ WORKTREES_NOINDEX_DIR = join(SWARM_HOME, "worktrees.noindex");
640
+ WORKTREES_LEGACY_DIR = join(SWARM_HOME, "worktrees");
641
+ WORKTREES_DIR = process.env.SWARM_WORKTREES || WORKTREES_NOINDEX_DIR;
642
+ RUN_LOGS_DIR = process.env.SWARM_RUN_LOGS || join(SWARM_HOME, "run-logs");
643
+ DAEMON_LOG_PATH = process.env.SWARM_DAEMON_LOG || join(SWARM_HOME, "overmind.log");
644
+ DIST_BUILD = true;
645
+ SWARM_BACKEND = process.env.SWARM_BACKEND === "client" ? "client" : process.env.SWARM_BACKEND === "sqlite" ? "sqlite" : DIST_BUILD ? "client" : "sqlite";
646
+ SWARM_OUTBOX = process.env.SWARM_OUTBOX || join(SWARM_HOME, "outbox.jsonl");
647
+ SWARM_MAX_IN_FLIGHT = Math.trunc(envNum("SWARM_MAX_IN_FLIGHT", 3));
648
+ SWARM_CLAIM_INTERVAL_MS = envNum("SWARM_CLAIM_INTERVAL_MS", 6e4);
649
+ SWARM_MAX_LOAD_FACTOR = envNum("SWARM_MAX_LOAD_FACTOR", 0.9);
650
+ SWARM_MIN_FREE_GB = envNum("SWARM_MIN_FREE_GB", 1.5);
651
+ SWARM_MAX_FD_LOAD_FACTOR = envNum("SWARM_MAX_FD_LOAD_FACTOR", 0.9);
652
+ SWARM_MAX_CPU_BUSY = envNum("SWARM_MAX_CPU_BUSY", 0.85);
653
+ SWARM_RELIEF_LOAD_FACTOR = envNum("SWARM_RELIEF_LOAD_FACTOR", 1.3);
654
+ SWARM_RELIEF_MIN_FREE_GB = envNum("SWARM_RELIEF_MIN_FREE_GB", 0.8);
655
+ SWARM_RELIEF_MAX_FD_LOAD_FACTOR = envNum("SWARM_RELIEF_MAX_FD_LOAD_FACTOR", 0.95);
656
+ SWARM_RELIEF_CPU_BUSY = envNum("SWARM_RELIEF_CPU_BUSY", 0.95);
657
+ SWARM_RELIEF_COST_WINDOW_MS = envNum("SWARM_RELIEF_COST_WINDOW_MS", 60 * 6e4);
658
+ SWARM_RELIEF_ALERT_PAUSES = Math.trunc(envNum("SWARM_RELIEF_ALERT_PAUSES", 6));
659
+ SWARM_RELIEF_ALERT_LOST_MS = envNum("SWARM_RELIEF_ALERT_LOST_MS", 10 * 6e4);
660
+ SWARM_RELIEF_ALERT_MIN_STEPS = envNum("SWARM_RELIEF_ALERT_MIN_STEPS", 1);
661
+ SWARM_RELIEF_ALERT_SUSTAIN_MS = envNum("SWARM_RELIEF_ALERT_SUSTAIN_MS", 10 * 6e4);
662
+ SWARM_LOW_DISK_GB = envNum("SWARM_LOW_DISK_GB", 10);
663
+ SWARM_REHYDRATE_MS = envNum("SWARM_REHYDRATE_MS", 15e3);
664
+ SWARM_DRONE_BRIDGE_MS = envNum("SWARM_DRONE_BRIDGE_MS", 5e3);
665
+ SWARM_PROD_HEARTBEAT_MS = envNum("SWARM_PROD_HEARTBEAT_MS", 15e3);
666
+ SWARM_VERSION_CHECK_MS = envNum("SWARM_VERSION_CHECK_MS", 5 * 6e4);
667
+ SWARM_SWEEP_INTERVAL_MS = envNum("SWARM_SWEEP_INTERVAL_MS", 10 * 6e4);
668
+ DRONE_CHANNEL_ENABLED = process.env.SWARM_DRONE_CHANNEL !== "0";
669
+ DRONE_CHANNEL_PORT = Number(process.env.SWARM_DRONE_CHANNEL_PORT) || 7421;
670
+ DRONE_CHANNEL_URL = process.env.SWARM_DRONE_CHANNEL_URL || `http://127.0.0.1:${DRONE_CHANNEL_PORT}`;
671
+ DRONE_CHANNEL_SECRET_PATH = process.env.SWARM_DRONE_CHANNEL_SECRET || join(SWARM_HOME, "drone-channel.key");
672
+ DRONE_CHANNEL_GRACE_MS = envNum("SWARM_DRONE_CHANNEL_GRACE_MS", 12e4);
673
+ HEARTBEAT_PATH = process.env.SWARM_HEARTBEAT || join(SWARM_HOME, "overmind.heartbeat");
674
+ HEARTBEAT_STALE_MS = Number(process.env.SWARM_HEARTBEAT_STALE_MS) || 3e4;
675
+ SWARM_REPO = process.env.SWARM_REPO || "";
676
+ SWARM_SCAN_DEPTH = Number(process.env.SWARM_SCAN_DEPTH) || 3;
677
+ SWARM_REPO_PATHS = (process.env.SWARM_REPOS || SWARM_REPO).split(",").map((s) => s.trim()).filter(Boolean);
678
+ TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
679
+ SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
680
+ TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
681
+ TASKLESS_TOKEN = process.env.TASKLESS_TOKEN || tokenFromFile();
682
+ TASKLESS_WORKSPACE = process.env.TASKLESS_WORKSPACE || "";
683
+ TASKLESS_WORKSPACE_SLUG = process.env.TASKLESS_WORKSPACE_SLUG || "";
684
+ TASKLESS_TASK_URL_BASE = process.env.TASKLESS_TASK_URL_BASE || (TASKLESS_WORKSPACE_SLUG ? `${TASKLESS_API.replace(/\/$/, "")}/app/${TASKLESS_WORKSPACE_SLUG}` : "");
685
+ SWARM_VERIFY = process.env.SWARM_VERIFY === "ci" ? "ci" : "local";
686
+ SWARM_AUTO_REVIEW = process.env.SWARM_AUTO_REVIEW !== "0";
687
+ REVIEW_CUTOFF_PATH = process.env.SWARM_REVIEW_CUTOFF || join(SWARM_HOME, "review-cutoff.json");
688
+ SWARM_REVIEW_CAP = Math.trunc(envNum("SWARM_REVIEW_CAP", 1));
689
+ SWARM_STORM_URL = process.env.SWARM_STORM_URL || "";
690
+ WEB_PORT = Number(process.env.SWARM_WEB_PORT) || 7420;
691
+ WEB_REDIRECT_URI = process.env.SWARM_REDIRECT_URI || `http://127.0.0.1:${WEB_PORT}/callback`;
692
+ SWARM_ANOMALY_RENOTIFY_MS = envNum("SWARM_ANOMALY_RENOTIFY_MS", 60 * 6e4);
693
+ SWARM_ANOMALY_MAX_REPEATS = Math.trunc(envNum("SWARM_ANOMALY_MAX_REPEATS", 3));
694
+ SWARM_ANOMALY_RUN_DOWN_WINDOW_MS = envNum("SWARM_ANOMALY_RUN_DOWN_WINDOW_MS", 24 * 60 * 6e4);
695
+ SWARM_ANOMALY_FAILED_SPIKE_COUNT = Math.trunc(envNum("SWARM_ANOMALY_FAILED_SPIKE_COUNT", 3));
696
+ SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS = envNum("SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS", 30 * 6e4);
697
+ SWARM_ANOMALY_QUEUE_STALLED_MS = envNum("SWARM_ANOMALY_QUEUE_STALLED_MS", 10 * 6e4);
698
+ SWARM_ANOMALY_PR_STUCK_MERGE_MS = envNum("SWARM_ANOMALY_PR_STUCK_MERGE_MS", 30 * 6e4);
699
+ SWARM_ANOMALY_PR_NO_REVIEWER_MS = envNum("SWARM_ANOMALY_PR_NO_REVIEWER_MS", 20 * 6e4);
700
+ SWARM_ANOMALY_QUESTION_UNANSWERED_MS = envNum("SWARM_ANOMALY_QUESTION_UNANSWERED_MS", 20 * 6e4);
701
+ SWARM_ANOMALY_OUTBOX_STUCK_MS = envNum("SWARM_ANOMALY_OUTBOX_STUCK_MS", 10 * 6e4);
702
+ SWARM_ANOMALY_RESOURCE_GATE_STALL_MS = envNum("SWARM_ANOMALY_RESOURCE_GATE_STALL_MS", 5 * 6e4);
703
+ SWARM_WATCHDOG_STALL_MS = envNum("SWARM_WATCHDOG_STALL_MS", 15 * 6e4);
704
+ }
705
+ });
599
706
 
600
707
  // src/names.ts
601
- var NAME_LIVE = new Set(ACTIVE_NAME_STATUSES);
602
708
  function pickName(rows) {
603
709
  const live = /* @__PURE__ */ new Set();
604
710
  const lastUse = /* @__PURE__ */ new Map();
@@ -618,21 +724,16 @@ function pickName(rows) {
618
724
  }
619
725
  }
620
726
  }
727
+ var NAME_LIVE;
728
+ var init_names2 = __esm({
729
+ "src/names.ts"() {
730
+ "use strict";
731
+ init_src();
732
+ NAME_LIVE = new Set(ACTIVE_NAME_STATUSES);
733
+ }
734
+ });
621
735
 
622
736
  // src/storm.ts
623
- var numEnv = (name, def) => {
624
- const v = Number(process.env[name]);
625
- return Number.isFinite(v) && v > 0 ? v : def;
626
- };
627
- var DEFAULT_STORM_CONFIG = {
628
- deathThreshold: numEnv("SWARM_STORM_DEATHS", 2),
629
- windowMs: numEnv("SWARM_STORM_WINDOW_MS", 5 * 60 * 1e3),
630
- spawnFailThreshold: numEnv("SWARM_STORM_SPAWN_FAILS", 2),
631
- probeIntervalMs: numEnv("SWARM_STORM_PROBE_INTERVAL_MS", 60 * 1e3),
632
- probeSuccessesToClear: numEnv("SWARM_STORM_PROBE_CLEAR", 2),
633
- probeTimeoutMs: numEnv("SWARM_STORM_PROBE_TIMEOUT_MS", 20 * 1e3),
634
- restartCooldownMs: numEnv("SWARM_STORM_RESTART_COOLDOWN_MS", 3 * 60 * 1e3)
635
- };
636
737
  function encodeProbe(rec) {
637
738
  return JSON.stringify(rec);
638
739
  }
@@ -656,76 +757,35 @@ function lastProbeReason(recs) {
656
757
  }
657
758
  return null;
658
759
  }
760
+ var numEnv, DEFAULT_STORM_CONFIG;
761
+ var init_storm = __esm({
762
+ "src/storm.ts"() {
763
+ "use strict";
764
+ init_src();
765
+ numEnv = (name, def) => {
766
+ const v = Number(process.env[name]);
767
+ return Number.isFinite(v) && v > 0 ? v : def;
768
+ };
769
+ DEFAULT_STORM_CONFIG = {
770
+ deathThreshold: numEnv("SWARM_STORM_DEATHS", 2),
771
+ windowMs: numEnv("SWARM_STORM_WINDOW_MS", 5 * 60 * 1e3),
772
+ spawnFailThreshold: numEnv("SWARM_STORM_SPAWN_FAILS", 2),
773
+ probeIntervalMs: numEnv("SWARM_STORM_PROBE_INTERVAL_MS", 60 * 1e3),
774
+ probeSuccessesToClear: numEnv("SWARM_STORM_PROBE_CLEAR", 2),
775
+ probeTimeoutMs: numEnv("SWARM_STORM_PROBE_TIMEOUT_MS", 20 * 1e3),
776
+ restartCooldownMs: numEnv("SWARM_STORM_RESTART_COOLDOWN_MS", 3 * 60 * 1e3)
777
+ };
778
+ }
779
+ });
659
780
 
660
781
  // src/db.ts
661
- var TERMINAL_SQL = TERMINAL_STATUSES.map((s) => `'${s}'`).join(",");
662
- var RESOLVER_TERMINAL_SQL = [...TERMINAL_STATUSES, "stalled"].map((s) => `'${s}'`).join(",");
663
- var SCHEMA = `
664
- CREATE TABLE IF NOT EXISTS runs (
665
- id INTEGER PRIMARY KEY AUTOINCREMENT,
666
- task_slug TEXT NOT NULL,
667
- name TEXT,
668
- kind TEXT NOT NULL DEFAULT 'drone',
669
- hero_run_id INTEGER,
670
- repo TEXT,
671
- worktree_path TEXT,
672
- branch TEXT,
673
- status TEXT NOT NULL DEFAULT 'queued',
674
- pid INTEGER,
675
- area TEXT,
676
- depends_on TEXT,
677
- model TEXT,
678
- cost_usd REAL NOT NULL DEFAULT 0,
679
- pr_url TEXT,
680
- superseded_by INTEGER,
681
- decision TEXT,
682
- stop_reason TEXT,
683
- respawns INTEGER NOT NULL DEFAULT 0,
684
- resolves_run_id INTEGER,
685
- reviews_run_id INTEGER,
686
- review_verdict TEXT,
687
- review_cycle INTEGER NOT NULL DEFAULT 0,
688
- task_title TEXT,
689
- task_desc TEXT,
690
- task_url TEXT,
691
- priority INTEGER NOT NULL DEFAULT 0,
692
- base_branch TEXT,
693
- workspace_id TEXT,
694
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
695
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
696
- );
697
- CREATE TABLE IF NOT EXISTS questions (
698
- id INTEGER PRIMARY KEY AUTOINCREMENT,
699
- run_id INTEGER NOT NULL REFERENCES runs(id),
700
- text TEXT NOT NULL,
701
- options TEXT,
702
- status TEXT NOT NULL DEFAULT 'open',
703
- answer TEXT,
704
- answered_by TEXT,
705
- escalated INTEGER NOT NULL DEFAULT 0,
706
- asked_at TEXT NOT NULL DEFAULT (datetime('now')),
707
- answered_at TEXT
708
- );
709
- CREATE TABLE IF NOT EXISTS events (
710
- id INTEGER PRIMARY KEY AUTOINCREMENT,
711
- run_id INTEGER REFERENCES runs(id),
712
- kind TEXT NOT NULL,
713
- text TEXT,
714
- actor TEXT,
715
- at TEXT NOT NULL DEFAULT (datetime('now'))
716
- );
717
- CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
718
- CREATE INDEX IF NOT EXISTS idx_runs_area ON runs(area);
719
- CREATE INDEX IF NOT EXISTS idx_questions_run ON questions(run_id);
720
- CREATE INDEX IF NOT EXISTS idx_questions_status ON questions(status);
721
- CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id);
722
- -- \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)
723
- CREATE TABLE IF NOT EXISTS imports (
724
- source TEXT PRIMARY KEY,
725
- run_count INTEGER NOT NULL DEFAULT 0,
726
- imported_at TEXT NOT NULL DEFAULT (datetime('now'))
727
- );
728
- `;
782
+ var db_exports = {};
783
+ __export(db_exports, {
784
+ SwarmDb: () => SwarmDb
785
+ });
786
+ import { createRequire as createRequire2 } from "module";
787
+ import { existsSync as existsSync8 } from "fs";
788
+ import { resolve as resolve2 } from "path";
729
789
  function rowToQuestion(row) {
730
790
  return {
731
791
  id: row.id,
@@ -788,7 +848,7 @@ function isForeignKeyError(err) {
788
848
  return err instanceof Error && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY";
789
849
  }
790
850
  function loadBetterSqlite3() {
791
- const require2 = createRequire(import.meta.url);
851
+ const require2 = createRequire2(import.meta.url);
792
852
  try {
793
853
  return require2("better-sqlite3");
794
854
  } catch (err) {
@@ -797,668 +857,753 @@ function loadBetterSqlite3() {
797
857
  );
798
858
  }
799
859
  }
800
- var SwarmDb = class {
801
- db;
802
- defaultRepo;
803
- databaseCtor;
804
- // для importDb (второй Database, source)
805
- constructor(path = "swarm.db", defaultRepo = "") {
806
- this.defaultRepo = normalizeRepo(defaultRepo);
807
- this.databaseCtor = loadBetterSqlite3();
808
- this.db = new this.databaseCtor(path);
809
- this.db.exec("PRAGMA journal_mode = WAL;");
810
- this.db.exec(SCHEMA);
811
- for (const alter of [
812
- "ALTER TABLE questions ADD COLUMN options TEXT",
813
- "ALTER TABLE questions ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0",
814
- "ALTER TABLE questions ADD COLUMN answered_by TEXT",
815
- "ALTER TABLE runs ADD COLUMN superseded_by INTEGER",
816
- "ALTER TABLE runs ADD COLUMN decision TEXT",
817
- "ALTER TABLE runs ADD COLUMN stop_reason TEXT",
818
- "ALTER TABLE runs ADD COLUMN repo TEXT",
819
- "ALTER TABLE runs ADD COLUMN name TEXT",
820
- "ALTER TABLE runs ADD COLUMN respawns INTEGER NOT NULL DEFAULT 0",
821
- "ALTER TABLE runs ADD COLUMN resolves_run_id INTEGER",
822
- "ALTER TABLE runs ADD COLUMN depends_on TEXT",
823
- "ALTER TABLE runs ADD COLUMN task_title TEXT",
824
- "ALTER TABLE runs ADD COLUMN task_desc TEXT",
825
- "ALTER TABLE runs ADD COLUMN task_url TEXT",
826
- // Иерархия роя (TASWM-78): роль прогона (drone|hero) и ссылка на прогон-героя.
827
- "ALTER TABLE runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'drone'",
828
- "ALTER TABLE runs ADD COLUMN hero_run_id INTEGER",
829
- "ALTER TABLE events ADD COLUMN actor TEXT",
830
- // Авто-ревью (TASWM-148): прогон-ревьюер (reviews_run_id → review-прогон) и
831
- // вердикт (approve|changes), который ревьюер пишет на проверяемый прогон.
832
- "ALTER TABLE runs ADD COLUMN reviews_run_id INTEGER",
833
- "ALTER TABLE runs ADD COLUMN review_verdict TEXT",
834
- // Авто-ревью v2 (TASWM-162): номер цикла ревью — пере-ревью после фикс-итерации.
835
- "ALTER TABLE runs ADD COLUMN review_cycle INTEGER NOT NULL DEFAULT 0",
836
- // Вычисляемая фаза ревью (TASWM-230): человеко-фраза «чего ждёт PR», её считает демон.
837
- "ALTER TABLE runs ADD COLUMN review_phase TEXT",
838
- // Приоритетная очередь (TASWM-269): явный приоритет прогона, выше — раньше из queued.
839
- "ALTER TABLE runs ADD COLUMN priority INTEGER NOT NULL DEFAULT 0",
840
- // Базовая ветка PR (TASWM-285): куда дрон открывает PR и от чего ответвляется.
841
- "ALTER TABLE runs ADD COLUMN base_branch TEXT",
842
- // Воркспейс задачи прогона (TASWM-288): им пинятся задачные MCP-вызовы прогона.
843
- "ALTER TABLE runs ADD COLUMN workspace_id TEXT"
844
- ]) {
845
- try {
846
- this.db.exec(alter);
847
- } catch {
860
+ var TERMINAL_SQL, RESOLVER_TERMINAL_SQL, SCHEMA, SwarmDb;
861
+ var init_db = __esm({
862
+ "src/db.ts"() {
863
+ "use strict";
864
+ init_config();
865
+ init_names2();
866
+ init_storm();
867
+ init_src();
868
+ TERMINAL_SQL = TERMINAL_STATUSES.map((s) => `'${s}'`).join(",");
869
+ RESOLVER_TERMINAL_SQL = [...TERMINAL_STATUSES, "stalled"].map((s) => `'${s}'`).join(",");
870
+ SCHEMA = `
871
+ CREATE TABLE IF NOT EXISTS runs (
872
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
873
+ task_slug TEXT NOT NULL,
874
+ name TEXT,
875
+ kind TEXT NOT NULL DEFAULT 'drone',
876
+ hero_run_id INTEGER,
877
+ repo TEXT,
878
+ worktree_path TEXT,
879
+ branch TEXT,
880
+ status TEXT NOT NULL DEFAULT 'queued',
881
+ pid INTEGER,
882
+ area TEXT,
883
+ depends_on TEXT,
884
+ model TEXT,
885
+ cost_usd REAL NOT NULL DEFAULT 0,
886
+ pr_url TEXT,
887
+ superseded_by INTEGER,
888
+ decision TEXT,
889
+ stop_reason TEXT,
890
+ respawns INTEGER NOT NULL DEFAULT 0,
891
+ resolves_run_id INTEGER,
892
+ reviews_run_id INTEGER,
893
+ review_verdict TEXT,
894
+ review_cycle INTEGER NOT NULL DEFAULT 0,
895
+ task_title TEXT,
896
+ task_desc TEXT,
897
+ task_url TEXT,
898
+ priority INTEGER NOT NULL DEFAULT 0,
899
+ base_branch TEXT,
900
+ workspace_id TEXT,
901
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
902
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
903
+ );
904
+ CREATE TABLE IF NOT EXISTS questions (
905
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
906
+ run_id INTEGER NOT NULL REFERENCES runs(id),
907
+ text TEXT NOT NULL,
908
+ options TEXT,
909
+ status TEXT NOT NULL DEFAULT 'open',
910
+ answer TEXT,
911
+ answered_by TEXT,
912
+ escalated INTEGER NOT NULL DEFAULT 0,
913
+ asked_at TEXT NOT NULL DEFAULT (datetime('now')),
914
+ answered_at TEXT
915
+ );
916
+ CREATE TABLE IF NOT EXISTS events (
917
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
918
+ run_id INTEGER REFERENCES runs(id),
919
+ kind TEXT NOT NULL,
920
+ text TEXT,
921
+ actor TEXT,
922
+ at TEXT NOT NULL DEFAULT (datetime('now'))
923
+ );
924
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
925
+ CREATE INDEX IF NOT EXISTS idx_runs_area ON runs(area);
926
+ CREATE INDEX IF NOT EXISTS idx_questions_run ON questions(run_id);
927
+ CREATE INDEX IF NOT EXISTS idx_questions_status ON questions(status);
928
+ CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id);
929
+ -- \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)
930
+ CREATE TABLE IF NOT EXISTS imports (
931
+ source TEXT PRIMARY KEY,
932
+ run_count INTEGER NOT NULL DEFAULT 0,
933
+ imported_at TEXT NOT NULL DEFAULT (datetime('now'))
934
+ );
935
+ `;
936
+ SwarmDb = class {
937
+ db;
938
+ defaultRepo;
939
+ databaseCtor;
940
+ // для importDb (второй Database, source)
941
+ constructor(path = "swarm.db", defaultRepo = "") {
942
+ this.defaultRepo = normalizeRepo(defaultRepo);
943
+ this.databaseCtor = loadBetterSqlite3();
944
+ this.db = new this.databaseCtor(path);
945
+ this.db.exec("PRAGMA journal_mode = WAL;");
946
+ this.db.exec(SCHEMA);
947
+ for (const alter of [
948
+ "ALTER TABLE questions ADD COLUMN options TEXT",
949
+ "ALTER TABLE questions ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0",
950
+ "ALTER TABLE questions ADD COLUMN answered_by TEXT",
951
+ "ALTER TABLE runs ADD COLUMN superseded_by INTEGER",
952
+ "ALTER TABLE runs ADD COLUMN decision TEXT",
953
+ "ALTER TABLE runs ADD COLUMN stop_reason TEXT",
954
+ "ALTER TABLE runs ADD COLUMN repo TEXT",
955
+ "ALTER TABLE runs ADD COLUMN name TEXT",
956
+ "ALTER TABLE runs ADD COLUMN respawns INTEGER NOT NULL DEFAULT 0",
957
+ "ALTER TABLE runs ADD COLUMN resolves_run_id INTEGER",
958
+ "ALTER TABLE runs ADD COLUMN depends_on TEXT",
959
+ "ALTER TABLE runs ADD COLUMN task_title TEXT",
960
+ "ALTER TABLE runs ADD COLUMN task_desc TEXT",
961
+ "ALTER TABLE runs ADD COLUMN task_url TEXT",
962
+ // Иерархия роя (TASWM-78): роль прогона (drone|hero) и ссылка на прогон-героя.
963
+ "ALTER TABLE runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'drone'",
964
+ "ALTER TABLE runs ADD COLUMN hero_run_id INTEGER",
965
+ "ALTER TABLE events ADD COLUMN actor TEXT",
966
+ // Авто-ревью (TASWM-148): прогон-ревьюер (reviews_run_id → review-прогон) и
967
+ // вердикт (approve|changes), который ревьюер пишет на проверяемый прогон.
968
+ "ALTER TABLE runs ADD COLUMN reviews_run_id INTEGER",
969
+ "ALTER TABLE runs ADD COLUMN review_verdict TEXT",
970
+ // Авто-ревью v2 (TASWM-162): номер цикла ревью — пере-ревью после фикс-итерации.
971
+ "ALTER TABLE runs ADD COLUMN review_cycle INTEGER NOT NULL DEFAULT 0",
972
+ // Вычисляемая фаза ревью (TASWM-230): человеко-фраза «чего ждёт PR», её считает демон.
973
+ "ALTER TABLE runs ADD COLUMN review_phase TEXT",
974
+ // Приоритетная очередь (TASWM-269): явный приоритет прогона, выше — раньше из queued.
975
+ "ALTER TABLE runs ADD COLUMN priority INTEGER NOT NULL DEFAULT 0",
976
+ // Базовая ветка PR (TASWM-285): куда дрон открывает PR и от чего ответвляется.
977
+ "ALTER TABLE runs ADD COLUMN base_branch TEXT",
978
+ // Воркспейс задачи прогона (TASWM-288): им пинятся задачные MCP-вызовы прогона.
979
+ "ALTER TABLE runs ADD COLUMN workspace_id TEXT"
980
+ ]) {
981
+ try {
982
+ this.db.exec(alter);
983
+ } catch {
984
+ }
985
+ }
986
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_repo ON runs(repo)");
987
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_hero ON runs(hero_run_id)");
988
+ if (this.defaultRepo) {
989
+ this.db.prepare("UPDATE runs SET repo = ? WHERE repo IS NULL").run(this.defaultRepo);
990
+ }
848
991
  }
849
- }
850
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_repo ON runs(repo)");
851
- this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_hero ON runs(hero_run_id)");
852
- if (this.defaultRepo) {
853
- this.db.prepare("UPDATE runs SET repo = ? WHERE repo IS NULL").run(this.defaultRepo);
854
- }
855
- }
856
- close() {
857
- this.db.close();
858
- }
859
- // --- runs ---
860
- createRun(input) {
861
- const name = input.name ?? pickName(this.db.prepare("SELECT id, name, status FROM runs").all());
862
- const res = this.db.prepare(
863
- "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
864
- ).run(
865
- input.task_slug,
866
- name,
867
- input.kind ?? "drone",
868
- // роль прогона: дефолт — рабочий дрон
869
- input.hero_run_id ?? null,
870
- // отряд героя (задел иерархии, TASWM-78)
871
- normalizeRepo(input.repo || this.defaultRepo),
872
- // канон при записи → один area-lock на физрепо
873
- input.area ?? null,
874
- input.depends_on && input.depends_on.length ? JSON.stringify(input.depends_on) : null,
875
- input.model ?? null,
876
- input.decision ? JSON.stringify(input.decision) : null,
877
- input.resolves_run_id ?? null,
878
- input.reviews_run_id ?? null,
879
- input.review_cycle ?? 0,
880
- // цикл ревью для ревьюера (авто-ревью v2, TASWM-162); обычный прогон — 0
881
- input.priority ?? 0,
882
- // явный приоритет в очереди (TASWM-269); обычный прогон — 0
883
- input.base_branch ?? null,
884
- // базовая ветка PR (TASWM-285); null → проектная/дефолтная
885
- input.workspace_id ?? null
886
- // воркспейс задачи прогона (TASWM-288); null → глобальный пин
887
- );
888
- const id = Number(res.lastInsertRowid);
889
- 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}`;
890
- this.addEvent(id, "spawned", spawnNote);
891
- return this.getRun(id);
892
- }
893
- /**
894
- * Завести стаб-строку прогона #id, если её ещё нет (TASWM-263, «ревьюеры не могут сдать
895
- * работу»). Корень: демон в client-режиме (SWARM_BACKEND=client, опубликованный npm-пакет)
896
- * ведёт runs в in-memory зеркале SwarmClient + проде (swarm_run_upsert), а НЕ в этой sqlite —
897
- * но дрон-MCP-сервер (droneServer.ts:runDroneServer) ВСЕГДА открывает SwarmDb на локальный
898
- * DB_PATH, независимо от бэкенда демона. Спавненный из прода прогон (claim/hydrate) в этой
899
- * БД строки не получает → первый же write-путь (report_step/ask/done) падает на FK
900
- * (events.run_id/questions.run_id REFERENCES runs(id)), а ревьюер не может ни отчитаться,
901
- * ни донести вердикт. INSERT OR IGNORE — идемпотентно (конкурентный вызов/уже заведённая
902
- * строка не трогаются, id explicit-инсертом двигает автоинкремент дальше, коллизий с
903
- * будущими createRun не будет). status='running' — прогон уже фактически работает (иначе
904
- * не звал бы write-путь). kind/reviews_run_id/hero_run_id — ЛУЧШЕЕ известное из данных
905
- * спавна (runDroneServer читает их из SWARM_RUN_KIND/SWARM_REVIEWS_RUN_ID/SWARM_HERO_RUN_ID,
906
- * см. launcher.ts): дефолт "drone" без них исказил бы done() ревьюера — тот принял бы свой
907
- * вердикт за обычную сдачу PR (неверная ветка кода в droneServer.done). name — та же
908
- * история (TASWM-265): без явного имени колонка оставалась NULL, а rowToRun подставлял
909
- * `#id` — droneServer.done() подписывал отчёт номером прогона вместо имени дрона (номер
910
- * локальный и читателю задачи ничего не говорит). Честный fallback — заглушка
911
- * "(имя неизвестно)", а не номер, выданный за имя.
912
- */
913
- ensureRun(input) {
914
- this.db.prepare(
915
- "INSERT OR IGNORE INTO runs (id, task_slug, kind, status, name, reviews_run_id, hero_run_id) VALUES (?, ?, ?, 'running', ?, ?, ?)"
916
- ).run(
917
- input.id,
918
- input.task_slug,
919
- input.kind ?? "drone",
920
- input.name || "(\u0438\u043C\u044F \u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u043E)",
921
- input.reviews_run_id ?? null,
922
- input.hero_run_id ?? null
923
- );
924
- }
925
- getRun(id) {
926
- const row = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
927
- return row ? rowToRun(row) : null;
928
- }
929
- /**
930
- * Стабильный ключ прогона (TASWM-295). У sqlite он тривиален: id — настоящий PK, живущий
931
- * в файле, поэтому ключ это и есть номер с префиксом. Префикс не декоративный: он не даёт
932
- * перепутать пространства ключей, если токен канала, выписанный демоном на sqlite-бэкенде,
933
- * попадёт в демона на client-бэкенде (там ключи вида `p:<cuid>`) — резолв просто не найдёт
934
- * прогон, вместо того чтобы наткнуться на чужой с тем же номером.
935
- */
936
- runKeyOf(id) {
937
- return this.getRun(id) ? `local:${id}` : null;
938
- }
939
- runByKey(key) {
940
- const m = /^local:(\d+)$/.exec(key);
941
- return m ? this.getRun(Number(m[1])) : null;
942
- }
943
- listRuns(status) {
944
- 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();
945
- return rows.map(rowToRun);
946
- }
947
- /** Прогоны отряда Героя-лейтенанта #heroId (его дроны: hero_run_id = heroId), новые сверху (TASWM-81). */
948
- squadRuns(heroId) {
949
- const rows = this.db.prepare("SELECT * FROM runs WHERE hero_run_id = ? ORDER BY id DESC").all(heroId);
950
- return rows.map(rowToRun);
951
- }
952
- updateRun(id, patch) {
953
- const fields = Object.keys(patch);
954
- if (fields.length === 0) return this.getRun(id);
955
- const set = fields.map((f) => `${f} = ?`).join(", ");
956
- const vals = fields.map((f) => {
957
- const v = patch[f];
958
- if (f === "depends_on") {
959
- const arr = v;
960
- return arr && arr.length ? JSON.stringify(arr) : null;
992
+ close() {
993
+ this.db.close();
961
994
  }
962
- return v;
963
- });
964
- this.db.prepare(`UPDATE runs SET ${set}, updated_at = datetime('now') WHERE id = ?`).run(...vals, id);
965
- return this.getRun(id);
966
- }
967
- /**
968
- * Атомарно увеличить счётчик авто-оживлений прогона (после смерти дрона рой
969
- * сам его respawn'ит/--continue'ит). Возвращает новое значение — по нему
970
- * death-detect решает, не исчерпан ли лимит (2 оживления на прогон).
971
- */
972
- incRespawn(id) {
973
- this.db.prepare("UPDATE runs SET respawns = respawns + 1, updated_at = datetime('now') WHERE id = ?").run(id);
974
- const row = this.db.prepare("SELECT respawns FROM runs WHERE id = ?").get(id);
975
- return row?.respawns ?? 0;
976
- }
977
- /**
978
- * Остановить прогон оператором: статус "stopped" + причина + событие.
979
- * Прогон НЕ удаляется — остаётся в БД и в истории навсегда (в отличие от failed
980
- * с выброшенным worktree мы храним причину и запись). Kill/cleanup — снаружи (cancelRun).
981
- */
982
- cancel(id, reason) {
983
- const run = this.updateRun(id, { status: "stopped", stop_reason: reason });
984
- if (run) this.addEvent(id, "stopped", reason);
985
- return run;
986
- }
987
- /**
988
- * Живой прогон-резолвер конфликта для review-прогона #id (если есть). «Живой» =
989
- * не в терминальном статусе: пока такой в работе/на ревью — второго резолвера на тот
990
- * же PR не поднимаем (иначе на каждый тик плодили бы дронов). stalled/failed/done —
991
- * не живые: резолвер сорвался/добил → можно, если не исчёрпан лимит попыток.
992
- */
993
- activeResolverFor(reviewRunId) {
994
- const row = this.db.prepare(
995
- `SELECT * FROM runs WHERE resolves_run_id = ? AND status NOT IN (${RESOLVER_TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
996
- ).get(reviewRunId);
997
- return row ? rowToRun(row) : null;
998
- }
999
- /** Сколько всего резолверов уже поднимали на этот review-PR — потолок авто-попыток. */
1000
- resolverCountFor(reviewRunId) {
1001
- const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE resolves_run_id = ?").get(reviewRunId);
1002
- return row.n;
1003
- }
1004
- /**
1005
- * Сколько ЖИВЫХ/УСПЕШНЫХ ревьюеров уже поднимали на этот review-PR В ТЕКУЩЕМ ЦИКЛЕ
1006
- * (авто-ревью, TASWM-148; скоуп по циклу — авто-ревью v2, TASWM-162) — one-shot
1007
- * идемпотентность БЕЗ лимита попыток (в отличие от резолвера): >0 → в этом цикле ревьюер
1008
- * уже есть, повторно не спавним. failed/stalled ревьюеры (worktree/launch сорвались ДО
1009
- * вердикта) в счёт НЕ идут — иначе один транзиентный сбой спавна (диск/git/gh) навсегда
1010
- * блокировал бы ревью цикла без шанса на повтор. stopped/superseded — тоже НЕ считаем
1011
- * (TASWM-253): stopped — оператор закрыл зомби-ревьюера руками (POST /swarm/runs/cleanup-
1012
- * dead), superseded — оператор явно сбросил попытки ревью (POST /swarm/review/retry,
1013
- * см. packages/api/src/swarm/routes.ts); оба варианта означают «эта попытка не в счёт»,
1014
- * а не «ревьюер ещё жив». РАНЬШЕ stopped
1015
- * тут не исключался — операторский kill зомби-ревьюера навсегда держал гейт закрытым
1016
- * (reviewerCountFor>0 бесконечно), хотя счётчик попыток (ниже) уже не рос. Скоуп «текущий
1017
- * цикл» = review_cycle ревьюера равен review_cycle проверяемого прогона: после фикс-
1018
- * итерации исполнитель бампит цикл (droneServer.done), ревьюеры прошлого цикла отваливаются
1019
- * из счёта → гейт снова открыт для ре-ревьюера (без него бейдж «changes» держал бы слот
1020
- * навсегда).
1021
- */
1022
- reviewerCountFor(reviewRunId) {
1023
- const row = this.db.prepare(
1024
- "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)"
1025
- ).get(reviewRunId, reviewRunId);
1026
- return row.n;
1027
- }
1028
- /**
1029
- * Жёсткий кап ревью-спавнов (TASWM-204, карусель мёртвых ревьюеров): сколько ревьюеров
1030
- * ВСЕГО (любой статус, включая failed/stalled/stopped — специально в отличие от
1031
- * reviewerCountFor выше) поднималось на review-прогон #reviewRunId В ТЕКУЩЕМ ЦИКЛЕ (скоуп
1032
- * по циклу — авто-ревью v2, TASWM-162). Зеркалит resolverCountFor — плоский потолок
1033
- * попыток на цикл. Именно это ловит карусель: reviewerCountFor исключает failed/stalled
1034
- * (чтобы ОДИН транзиентный сбой не блокировал ревью), но тем самым допускает бесконечный
1035
- * ретрай, если ревьюер стабильно умирает без done(). Скоуп по циклу — чтобы кап считался
1036
- * заново на каждой фикс-итерации, а не суммировал попытки прошлых (успешно завершённых)
1037
- * циклов. superseded — ЕДИНСТВЕННОЕ исключение из капа (TASWM-253): это статус, которым
1038
- * POST /swarm/review/retry помечает попытки при операторском «попробовать ревью заново» —
1039
- * без исключения сброс не мог бы ничего сбросить (COUNT(*) слеп к статусу). failed/
1040
- * stalled/stopped по-прежнему считаются — иначе кап потерял бы смысл на органических
1041
- * смертях (ровно то, для чего его вводили в TASWM-204).
1042
- */
1043
- reviewerAttemptCountFor(reviewRunId) {
1044
- const row = this.db.prepare(
1045
- "SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status != 'superseded' AND review_cycle = COALESCE((SELECT review_cycle FROM runs WHERE id = ?), 0)"
1046
- ).get(reviewRunId, reviewRunId);
1047
- return row.n;
1048
- }
1049
- /** Последняя (любой статус) попытка ревьюера ТЕКУЩЕГО ЦИКЛА на review-прогон #reviewRunId
1050
- * (TASWM-196; скоуп по циклу — TASWM-162) — см. store.ts:lastReviewerAttemptFor. */
1051
- lastReviewerAttemptFor(reviewRunId) {
1052
- const row = this.db.prepare(
1053
- "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"
1054
- ).get(reviewRunId, reviewRunId);
1055
- return row ? rowToRun(row) : null;
1056
- }
1057
- /**
1058
- * Живые/ждущие ревьюер-прогоны review-прогона #reviewRunId (TASWM-204): для
1059
- * closeReviewersOnMerge — PR смержен раньше вердикта, зависшего ревьюера пора закрыть,
1060
- * не дожидаясь done(). Точечный WHERE, не полный скан listRuns() (code-review TASWM-204).
1061
- */
1062
- activeReviewersFor(reviewRunId) {
1063
- const rows = this.db.prepare(`SELECT * FROM runs WHERE reviews_run_id = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC`).all(reviewRunId);
1064
- return rows.map(rowToRun);
1065
- }
1066
- /**
1067
- * Последний живой (не терминальный) прогон по slug — для swarm cancel. Служебные
1068
- * сабраны (reviews_run_id/resolves_run_id — ревьюер/резолвер, TASWM-273) исключены:
1069
- * они заводятся с ТЕМ ЖЕ task_slug, что и родительский прогон, и всегда бо́льшим id
1070
- * (спавнятся позже) — без исключения `ORDER BY id DESC LIMIT 1` подсовывал бы
1071
- * cancelRun паузнутого ревьюера (paused НЕ терминален) вместо реального прогона,
1072
- * которого целился остановить оператор/`swarm cancel <slug>`: безусловный removeWorktree
1073
- * в cancelRun сносил бы worktree ревьюера, ждущего CI, и переводил его в stopped мимо
1074
- * обычной цепочки паузы/побудки — найдено при разборе TASWM-273 (PR #564 завис в stalled
1075
- * без вердикта: worktree ревьюера пропал так, что статус до этого не менялся). Сабран
1076
- * отменяют через свой id, не через slug — swarm cancel всегда целится в родительский прогон.
1077
- */
1078
- activeRunForSlug(slug) {
1079
- const row = this.db.prepare(
1080
- `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`
1081
- ).get(slug);
1082
- return row ? rowToRun(row) : null;
1083
- }
1084
- /**
1085
- * Зоны, занятые сейчас работающими дронами — для конфликт-осознанной раздачи.
1086
- * С repo — только в этом репозитории (одна area в разных репо не конфликтует).
1087
- * Без repo — по всему рою (обратная совместимость).
1088
- */
1089
- runningAreas(repo) {
1090
- const base3 = "SELECT DISTINCT area FROM runs WHERE status IN ('running','blocked') AND area IS NOT NULL";
1091
- const rows = repo === void 0 ? this.db.prepare(base3).all() : this.db.prepare(`${base3} AND repo = ?`).all(repo);
1092
- return rows.map((r) => r.area);
1093
- }
1094
- /**
1095
- * Из depends_on прогона — слаги зависимостей, ещё НЕ закрытых (нет прогона со
1096
- * статусом done). Пусто → все выполнены, дрона можно спавнить. Явная зависимость
1097
- * (spawn --after) отдельна от area-lock: area освобождается на review (до мержа),
1098
- * а тут ждём именно done (смерж), чтобы следующий дрон стартовал от кода предыдущего.
1099
- * Несуществующий/зациклённый слаг просто никогда не станет done → прогон вечно ждёт,
1100
- * но очередь не виснет (гейт — пропуск в цикле, не блокировка).
1101
- */
1102
- unmetDeps(depends_on) {
1103
- if (!depends_on || depends_on.length === 0) return [];
1104
- const rows = this.db.prepare("SELECT DISTINCT task_slug FROM runs WHERE status = 'done' AND resolves_run_id IS NULL").all();
1105
- const done = new Set(rows.map((r) => r.task_slug));
1106
- return depends_on.filter((slug) => !done.has(slug));
1107
- }
1108
- /** Все репо, засветившиеся в прогонах — whitelist для валидации spawn из веба. */
1109
- distinctRepos() {
1110
- const rows = this.db.prepare("SELECT DISTINCT repo FROM runs WHERE repo IS NOT NULL AND repo <> ''").all();
1111
- return rows.map((r) => r.repo);
1112
- }
1113
- // --- импорт истории из другой БД (объединение роёв) ---
1114
- /**
1115
- * Идемпотентный перенос runs/events/questions из другой swarm-БД в эту.
1116
- * id прогонов ремапятся (старый→новый); run_id в events/questions и superseded_by
1117
- * в runs правятся по карте; всем импортируемым прогонам проставляется repo (норм.).
1118
- * pid НЕ переносим — чужой pid в объединённой БД бессмыслен и мог бы совпасть с
1119
- * живым процессом (ложный liveness); импортируемые прогоны исторические.
1120
- * Живые статусы нормализуем (running/blocked→stalled, queued→stopped) и открытые
1121
- * вопросы не тащим — иначе импорт даёт вечных зомби: без pid death-detect их
1122
- * пропускает, они навсегда держат (repo,area)-лок и «в работе» в метриках, а их
1123
- * открытые вопросы «воскрешают» архивный прогон через answer.
1124
- * Повторный вызов на том же source — no-op (маркер в таблице imports). Source
1125
- * открываем на ЗАПИСЬ (importDb только ЧИТАЕТ из него): все БД роя — WAL, а bun:sqlite
1126
- * с readonly НЕ открывает WAL-source без -shm (реальный перенос обычно = копия одного
1127
- * файла swarm.db без sidecar'ов → SQLITE_CANTOPEN). Несуществующий путь ловим existsSync
1128
- * ДО открытия — честная ошибка, а не пустой файл (rw создал бы его). Всё в одной
1129
- * транзакции: прервётся — маркер не встанет, повтор безопасен.
1130
- */
1131
- importDb(sourcePath, repo) {
1132
- const abs = resolve2(sourcePath);
1133
- const repoN = normalizeRepo(repo);
1134
- const already = this.db.prepare("SELECT run_count FROM imports WHERE source = ?").get(abs);
1135
- if (already) return { imported: false, runs: already.run_count };
1136
- if (!existsSync(abs)) throw new Error(`import-db: \u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442: ${abs}`);
1137
- const src = new this.databaseCtor(abs);
1138
- try {
1139
- const runs = src.prepare("SELECT * FROM runs ORDER BY id").all();
1140
- const events = src.prepare("SELECT * FROM events ORDER BY id").all();
1141
- const questions = src.prepare("SELECT * FROM questions ORDER BY id").all();
1142
- const txn = this.db.transaction(() => {
1143
- const idMap = /* @__PURE__ */ new Map();
1144
- const insRun = this.db.prepare(
1145
- "INSERT INTO runs (task_slug, repo, worktree_path, branch, status, area, model, cost_usd, pr_url, decision, stop_reason, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
995
+ // --- runs ---
996
+ createRun(input) {
997
+ const name = input.name ?? pickName(this.db.prepare("SELECT id, name, status FROM runs").all());
998
+ const res = this.db.prepare(
999
+ "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
1000
+ ).run(
1001
+ input.task_slug,
1002
+ name,
1003
+ input.kind ?? "drone",
1004
+ // роль прогона: дефолт — рабочий дрон
1005
+ input.hero_run_id ?? null,
1006
+ // отряд героя (задел иерархии, TASWM-78)
1007
+ normalizeRepo(input.repo || this.defaultRepo),
1008
+ // канон при записи → один area-lock на физрепо
1009
+ input.area ?? null,
1010
+ input.depends_on && input.depends_on.length ? JSON.stringify(input.depends_on) : null,
1011
+ input.model ?? null,
1012
+ input.decision ? JSON.stringify(input.decision) : null,
1013
+ input.resolves_run_id ?? null,
1014
+ input.reviews_run_id ?? null,
1015
+ input.review_cycle ?? 0,
1016
+ // цикл ревью для ревьюера (авто-ревью v2, TASWM-162); обычный прогон — 0
1017
+ input.priority ?? 0,
1018
+ // явный приоритет в очереди (TASWM-269); обычный прогон — 0
1019
+ input.base_branch ?? null,
1020
+ // базовая ветка PR (TASWM-285); null → проектная/дефолтная
1021
+ input.workspace_id ?? null
1022
+ // воркспейс задачи прогона (TASWM-288); null → глобальный пин
1023
+ );
1024
+ const id = Number(res.lastInsertRowid);
1025
+ 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}`;
1026
+ this.addEvent(id, "spawned", spawnNote);
1027
+ return this.getRun(id);
1028
+ }
1029
+ /**
1030
+ * Завести стаб-строку прогона #id, если её ещё нет (TASWM-263, «ревьюеры не могут сдать
1031
+ * работу»). Корень: демон в client-режиме (SWARM_BACKEND=client, опубликованный npm-пакет)
1032
+ * ведёт runs в in-memory зеркале SwarmClient + проде (swarm_run_upsert), а НЕ в этой sqlite —
1033
+ * но дрон-MCP-сервер (droneServer.ts:runDroneServer) ВСЕГДА открывает SwarmDb на локальный
1034
+ * DB_PATH, независимо от бэкенда демона. Спавненный из прода прогон (claim/hydrate) в этой
1035
+ * БД строки не получает → первый же write-путь (report_step/ask/done) падает на FK
1036
+ * (events.run_id/questions.run_id REFERENCES runs(id)), а ревьюер не может ни отчитаться,
1037
+ * ни донести вердикт. INSERT OR IGNORE — идемпотентно (конкурентный вызов/уже заведённая
1038
+ * строка не трогаются, id explicit-инсертом двигает автоинкремент дальше, коллизий с
1039
+ * будущими createRun не будет). status='running' — прогон уже фактически работает (иначе
1040
+ * не звал бы write-путь). kind/reviews_run_id/hero_run_id — ЛУЧШЕЕ известное из данных
1041
+ * спавна (runDroneServer читает их из SWARM_RUN_KIND/SWARM_REVIEWS_RUN_ID/SWARM_HERO_RUN_ID,
1042
+ * см. launcher.ts): дефолт "drone" без них исказил бы done() ревьюера — тот принял бы свой
1043
+ * вердикт за обычную сдачу PR (неверная ветка кода в droneServer.done). name — та же
1044
+ * история (TASWM-265): без явного имени колонка оставалась NULL, а rowToRun подставлял
1045
+ * `#id` — droneServer.done() подписывал отчёт номером прогона вместо имени дрона (номер
1046
+ * локальный и читателю задачи ничего не говорит). Честный fallback — заглушка
1047
+ * "(имя неизвестно)", а не номер, выданный за имя.
1048
+ */
1049
+ ensureRun(input) {
1050
+ this.db.prepare(
1051
+ "INSERT OR IGNORE INTO runs (id, task_slug, kind, status, name, reviews_run_id, hero_run_id) VALUES (?, ?, ?, 'running', ?, ?, ?)"
1052
+ ).run(
1053
+ input.id,
1054
+ input.task_slug,
1055
+ input.kind ?? "drone",
1056
+ input.name || "(\u0438\u043C\u044F \u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u043E)",
1057
+ input.reviews_run_id ?? null,
1058
+ input.hero_run_id ?? null
1146
1059
  );
1147
- for (const r of runs) {
1148
- const raw = r.status ?? "done";
1149
- let status = raw;
1150
- let stopReason = r.stop_reason ?? null;
1151
- let normEvent = null;
1152
- if (raw === "running" || raw === "blocked") {
1153
- status = "stalled";
1154
- 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" };
1155
- } else if (raw === "queued") {
1156
- status = "stopped";
1157
- stopReason = "\u0430\u0440\u0445\u0438\u0432\u043D\u044B\u0439 \u0438\u043C\u043F\u043E\u0440\u0442";
1158
- normEvent = { kind: "stopped", text: "\u0430\u0440\u0445\u0438\u0432\u043D\u044B\u0439 \u0438\u043C\u043F\u043E\u0440\u0442" };
1060
+ }
1061
+ getRun(id) {
1062
+ const row = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
1063
+ return row ? rowToRun(row) : null;
1064
+ }
1065
+ /**
1066
+ * Стабильный ключ прогона (TASWM-295). У sqlite он тривиален: id — настоящий PK, живущий
1067
+ * в файле, поэтому ключ это и есть номер с префиксом. Префикс не декоративный: он не даёт
1068
+ * перепутать пространства ключей, если токен канала, выписанный демоном на sqlite-бэкенде,
1069
+ * попадёт в демона на client-бэкенде (там ключи вида `p:<cuid>`) — резолв просто не найдёт
1070
+ * прогон, вместо того чтобы наткнуться на чужой с тем же номером.
1071
+ */
1072
+ runKeyOf(id) {
1073
+ return this.getRun(id) ? `local:${id}` : null;
1074
+ }
1075
+ runByKey(key) {
1076
+ const m = /^local:(\d+)$/.exec(key);
1077
+ return m ? this.getRun(Number(m[1])) : null;
1078
+ }
1079
+ listRuns(status) {
1080
+ 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();
1081
+ return rows.map(rowToRun);
1082
+ }
1083
+ /** Прогоны отряда Героя-лейтенанта #heroId (его дроны: hero_run_id = heroId), новые сверху (TASWM-81). */
1084
+ squadRuns(heroId) {
1085
+ const rows = this.db.prepare("SELECT * FROM runs WHERE hero_run_id = ? ORDER BY id DESC").all(heroId);
1086
+ return rows.map(rowToRun);
1087
+ }
1088
+ updateRun(id, patch) {
1089
+ const fields = Object.keys(patch);
1090
+ if (fields.length === 0) return this.getRun(id);
1091
+ const set = fields.map((f) => `${f} = ?`).join(", ");
1092
+ const vals = fields.map((f) => {
1093
+ const v = patch[f];
1094
+ if (f === "depends_on") {
1095
+ const arr = v;
1096
+ return arr && arr.length ? JSON.stringify(arr) : null;
1159
1097
  }
1160
- const res = insRun.run(
1161
- r.task_slug,
1162
- repoN,
1163
- r.worktree_path ?? null,
1164
- r.branch ?? null,
1165
- status,
1166
- r.area ?? null,
1167
- r.model ?? null,
1168
- r.cost_usd ?? 0,
1169
- r.pr_url ?? null,
1170
- r.decision ?? null,
1171
- stopReason,
1172
- r.created_at ?? null,
1173
- r.updated_at ?? null
1174
- );
1175
- const newId = Number(res.lastInsertRowid);
1176
- idMap.set(r.id, newId);
1177
- if (normEvent) this.addEvent(newId, normEvent.kind, normEvent.text);
1178
- }
1179
- const setSup = this.db.prepare("UPDATE runs SET superseded_by = ? WHERE id = ?");
1180
- for (const r of runs) {
1181
- const sup = r.superseded_by;
1182
- if (sup != null && idMap.has(sup)) setSup.run(idMap.get(sup), idMap.get(r.id));
1098
+ return v;
1099
+ });
1100
+ this.db.prepare(`UPDATE runs SET ${set}, updated_at = datetime('now') WHERE id = ?`).run(...vals, id);
1101
+ return this.getRun(id);
1102
+ }
1103
+ /**
1104
+ * Атомарно увеличить счётчик авто-оживлений прогона (после смерти дрона рой
1105
+ * сам его respawn'ит/--continue'ит). Возвращает новое значение — по нему
1106
+ * death-detect решает, не исчерпан ли лимит (2 оживления на прогон).
1107
+ */
1108
+ incRespawn(id) {
1109
+ this.db.prepare("UPDATE runs SET respawns = respawns + 1, updated_at = datetime('now') WHERE id = ?").run(id);
1110
+ const row = this.db.prepare("SELECT respawns FROM runs WHERE id = ?").get(id);
1111
+ return row?.respawns ?? 0;
1112
+ }
1113
+ /**
1114
+ * Остановить прогон оператором: статус "stopped" + причина + событие.
1115
+ * Прогон НЕ удаляется — остаётся в БД и в истории навсегда (в отличие от failed
1116
+ * с выброшенным worktree мы храним причину и запись). Kill/cleanup — снаружи (cancelRun).
1117
+ */
1118
+ cancel(id, reason) {
1119
+ const run = this.updateRun(id, { status: "stopped", stop_reason: reason });
1120
+ if (run) this.addEvent(id, "stopped", reason);
1121
+ return run;
1122
+ }
1123
+ /**
1124
+ * Живой прогон-резолвер конфликта для review-прогона #id (если есть). «Живой» =
1125
+ * не в терминальном статусе: пока такой в работе/на ревью — второго резолвера на тот
1126
+ * же PR не поднимаем (иначе на каждый тик плодили бы дронов). stalled/failed/done —
1127
+ * не живые: резолвер сорвался/добил → можно, если не исчёрпан лимит попыток.
1128
+ */
1129
+ activeResolverFor(reviewRunId) {
1130
+ const row = this.db.prepare(
1131
+ `SELECT * FROM runs WHERE resolves_run_id = ? AND status NOT IN (${RESOLVER_TERMINAL_SQL}) ORDER BY id DESC LIMIT 1`
1132
+ ).get(reviewRunId);
1133
+ return row ? rowToRun(row) : null;
1134
+ }
1135
+ /** Сколько всего резолверов уже поднимали на этот review-PR — потолок авто-попыток. */
1136
+ resolverCountFor(reviewRunId) {
1137
+ const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE resolves_run_id = ?").get(reviewRunId);
1138
+ return row.n;
1139
+ }
1140
+ /**
1141
+ * Сколько ЖИВЫХ/УСПЕШНЫХ ревьюеров уже поднимали на этот review-PR В ТЕКУЩЕМ ЦИКЛЕ
1142
+ * (авто-ревью, TASWM-148; скоуп по циклу — авто-ревью v2, TASWM-162) — one-shot
1143
+ * идемпотентность БЕЗ лимита попыток (в отличие от резолвера): >0 → в этом цикле ревьюер
1144
+ * уже есть, повторно не спавним. failed/stalled ревьюеры (worktree/launch сорвались ДО
1145
+ * вердикта) в счёт НЕ идут — иначе один транзиентный сбой спавна (диск/git/gh) навсегда
1146
+ * блокировал бы ревью цикла без шанса на повтор. stopped/superseded — тоже НЕ считаем
1147
+ * (TASWM-253): stopped — оператор закрыл зомби-ревьюера руками (POST /swarm/runs/cleanup-
1148
+ * dead), superseded — оператор явно сбросил попытки ревью (POST /swarm/review/retry,
1149
+ * см. packages/api/src/swarm/routes.ts); оба варианта означают «эта попытка не в счёт»,
1150
+ * а не «ревьюер ещё жив». РАНЬШЕ stopped
1151
+ * тут не исключался — операторский kill зомби-ревьюера навсегда держал гейт закрытым
1152
+ * (reviewerCountFor>0 бесконечно), хотя счётчик попыток (ниже) уже не рос. Скоуп «текущий
1153
+ * цикл» = review_cycle ревьюера равен review_cycle проверяемого прогона: после фикс-
1154
+ * итерации исполнитель бампит цикл (droneServer.done), ревьюеры прошлого цикла отваливаются
1155
+ * из счёта → гейт снова открыт для ре-ревьюера (без него бейдж «changes» держал бы слот
1156
+ * навсегда).
1157
+ */
1158
+ reviewerCountFor(reviewRunId) {
1159
+ const row = this.db.prepare(
1160
+ "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)"
1161
+ ).get(reviewRunId, reviewRunId);
1162
+ return row.n;
1163
+ }
1164
+ /**
1165
+ * Жёсткий кап ревью-спавнов (TASWM-204, карусель мёртвых ревьюеров): сколько ревьюеров
1166
+ * ВСЕГО (любой статус, включая failed/stalled/stopped — специально в отличие от
1167
+ * reviewerCountFor выше) поднималось на review-прогон #reviewRunId В ТЕКУЩЕМ ЦИКЛЕ (скоуп
1168
+ * по циклу — авто-ревью v2, TASWM-162). Зеркалит resolverCountFor — плоский потолок
1169
+ * попыток на цикл. Именно это ловит карусель: reviewerCountFor исключает failed/stalled
1170
+ * (чтобы ОДИН транзиентный сбой не блокировал ревью), но тем самым допускает бесконечный
1171
+ * ретрай, если ревьюер стабильно умирает без done(). Скоуп по циклу — чтобы кап считался
1172
+ * заново на каждой фикс-итерации, а не суммировал попытки прошлых (успешно завершённых)
1173
+ * циклов. superseded — ЕДИНСТВЕННОЕ исключение из капа (TASWM-253): это статус, которым
1174
+ * POST /swarm/review/retry помечает попытки при операторском «попробовать ревью заново» —
1175
+ * без исключения сброс не мог бы ничего сбросить (COUNT(*) слеп к статусу). failed/
1176
+ * stalled/stopped по-прежнему считаются — иначе кап потерял бы смысл на органических
1177
+ * смертях (ровно то, для чего его вводили в TASWM-204).
1178
+ */
1179
+ reviewerAttemptCountFor(reviewRunId) {
1180
+ const row = this.db.prepare(
1181
+ "SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status != 'superseded' AND review_cycle = COALESCE((SELECT review_cycle FROM runs WHERE id = ?), 0)"
1182
+ ).get(reviewRunId, reviewRunId);
1183
+ return row.n;
1184
+ }
1185
+ /** Последняя (любой статус) попытка ревьюера ТЕКУЩЕГО ЦИКЛА на review-прогон #reviewRunId
1186
+ * (TASWM-196; скоуп по циклу — TASWM-162) — см. store.ts:lastReviewerAttemptFor. */
1187
+ lastReviewerAttemptFor(reviewRunId) {
1188
+ const row = this.db.prepare(
1189
+ "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"
1190
+ ).get(reviewRunId, reviewRunId);
1191
+ return row ? rowToRun(row) : null;
1192
+ }
1193
+ /**
1194
+ * Живые/ждущие ревьюер-прогоны review-прогона #reviewRunId (TASWM-204): для
1195
+ * closeReviewersOnMerge — PR смержен раньше вердикта, зависшего ревьюера пора закрыть,
1196
+ * не дожидаясь done(). Точечный WHERE, не полный скан listRuns() (code-review TASWM-204).
1197
+ */
1198
+ activeReviewersFor(reviewRunId) {
1199
+ const rows = this.db.prepare(`SELECT * FROM runs WHERE reviews_run_id = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC`).all(reviewRunId);
1200
+ return rows.map(rowToRun);
1201
+ }
1202
+ /**
1203
+ * Последний живой (не терминальный) прогон по slug — для swarm cancel. Служебные
1204
+ * сабраны (reviews_run_id/resolves_run_id — ревьюер/резолвер, TASWM-273) исключены:
1205
+ * они заводятся с ТЕМ ЖЕ task_slug, что и родительский прогон, и всегда бо́льшим id
1206
+ * (спавнятся позже) — без исключения `ORDER BY id DESC LIMIT 1` подсовывал бы
1207
+ * cancelRun паузнутого ревьюера (paused НЕ терминален) вместо реального прогона,
1208
+ * которого целился остановить оператор/`swarm cancel <slug>`: безусловный removeWorktree
1209
+ * в cancelRun сносил бы worktree ревьюера, ждущего CI, и переводил его в stopped мимо
1210
+ * обычной цепочки паузы/побудки — найдено при разборе TASWM-273 (PR #564 завис в stalled
1211
+ * без вердикта: worktree ревьюера пропал так, что статус до этого не менялся). Сабран
1212
+ * отменяют через свой id, не через slug — swarm cancel всегда целится в родительский прогон.
1213
+ */
1214
+ activeRunForSlug(slug) {
1215
+ const row = this.db.prepare(
1216
+ `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`
1217
+ ).get(slug);
1218
+ return row ? rowToRun(row) : null;
1219
+ }
1220
+ /**
1221
+ * Зоны, занятые сейчас работающими дронами — для конфликт-осознанной раздачи.
1222
+ * С repo — только в этом репозитории (одна area в разных репо не конфликтует).
1223
+ * Без repo — по всему рою (обратная совместимость).
1224
+ */
1225
+ runningAreas(repo) {
1226
+ const base3 = "SELECT DISTINCT area FROM runs WHERE status IN ('running','blocked') AND area IS NOT NULL";
1227
+ const rows = repo === void 0 ? this.db.prepare(base3).all() : this.db.prepare(`${base3} AND repo = ?`).all(repo);
1228
+ return rows.map((r) => r.area);
1229
+ }
1230
+ /**
1231
+ * Из depends_on прогона — слаги зависимостей, ещё НЕ закрытых (нет прогона со
1232
+ * статусом done). Пусто → все выполнены, дрона можно спавнить. Явная зависимость
1233
+ * (spawn --after) отдельна от area-lock: area освобождается на review (до мержа),
1234
+ * а тут ждём именно done (смерж), чтобы следующий дрон стартовал от кода предыдущего.
1235
+ * Несуществующий/зациклённый слаг просто никогда не станет done → прогон вечно ждёт,
1236
+ * но очередь не виснет (гейт — пропуск в цикле, не блокировка).
1237
+ */
1238
+ unmetDeps(depends_on) {
1239
+ if (!depends_on || depends_on.length === 0) return [];
1240
+ const rows = this.db.prepare("SELECT DISTINCT task_slug FROM runs WHERE status = 'done' AND resolves_run_id IS NULL").all();
1241
+ const done = new Set(rows.map((r) => r.task_slug));
1242
+ return depends_on.filter((slug) => !done.has(slug));
1243
+ }
1244
+ /** Все репо, засветившиеся в прогонах — whitelist для валидации spawn из веба. */
1245
+ distinctRepos() {
1246
+ const rows = this.db.prepare("SELECT DISTINCT repo FROM runs WHERE repo IS NOT NULL AND repo <> ''").all();
1247
+ return rows.map((r) => r.repo);
1248
+ }
1249
+ // --- импорт истории из другой БД (объединение роёв) ---
1250
+ /**
1251
+ * Идемпотентный перенос runs/events/questions из другой swarm-БД в эту.
1252
+ * id прогонов ремапятся (старый→новый); run_id в events/questions и superseded_by
1253
+ * в runs правятся по карте; всем импортируемым прогонам проставляется repo (норм.).
1254
+ * pid НЕ переносим — чужой pid в объединённой БД бессмыслен и мог бы совпасть с
1255
+ * живым процессом (ложный liveness); импортируемые прогоны исторические.
1256
+ * Живые статусы нормализуем (running/blocked→stalled, queued→stopped) и открытые
1257
+ * вопросы не тащим — иначе импорт даёт вечных зомби: без pid death-detect их
1258
+ * пропускает, они навсегда держат (repo,area)-лок и «в работе» в метриках, а их
1259
+ * открытые вопросы «воскрешают» архивный прогон через answer.
1260
+ * Повторный вызов на том же source — no-op (маркер в таблице imports). Source
1261
+ * открываем на ЗАПИСЬ (importDb только ЧИТАЕТ из него): все БД роя — WAL, а bun:sqlite
1262
+ * с readonly НЕ открывает WAL-source без -shm (реальный перенос обычно = копия одного
1263
+ * файла swarm.db без sidecar'ов → SQLITE_CANTOPEN). Несуществующий путь ловим existsSync
1264
+ * ДО открытия — честная ошибка, а не пустой файл (rw создал бы его). Всё в одной
1265
+ * транзакции: прервётся — маркер не встанет, повтор безопасен.
1266
+ */
1267
+ importDb(sourcePath, repo) {
1268
+ const abs = resolve2(sourcePath);
1269
+ const repoN = normalizeRepo(repo);
1270
+ const already = this.db.prepare("SELECT run_count FROM imports WHERE source = ?").get(abs);
1271
+ if (already) return { imported: false, runs: already.run_count };
1272
+ if (!existsSync8(abs)) throw new Error(`import-db: \u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442: ${abs}`);
1273
+ const src = new this.databaseCtor(abs);
1274
+ try {
1275
+ const runs = src.prepare("SELECT * FROM runs ORDER BY id").all();
1276
+ const events = src.prepare("SELECT * FROM events ORDER BY id").all();
1277
+ const questions = src.prepare("SELECT * FROM questions ORDER BY id").all();
1278
+ const txn = this.db.transaction(() => {
1279
+ const idMap = /* @__PURE__ */ new Map();
1280
+ const insRun = this.db.prepare(
1281
+ "INSERT INTO runs (task_slug, repo, worktree_path, branch, status, area, model, cost_usd, pr_url, decision, stop_reason, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
1282
+ );
1283
+ for (const r of runs) {
1284
+ const raw = r.status ?? "done";
1285
+ let status = raw;
1286
+ let stopReason = r.stop_reason ?? null;
1287
+ let normEvent = null;
1288
+ if (raw === "running" || raw === "blocked") {
1289
+ status = "stalled";
1290
+ 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" };
1291
+ } else if (raw === "queued") {
1292
+ status = "stopped";
1293
+ stopReason = "\u0430\u0440\u0445\u0438\u0432\u043D\u044B\u0439 \u0438\u043C\u043F\u043E\u0440\u0442";
1294
+ normEvent = { kind: "stopped", text: "\u0430\u0440\u0445\u0438\u0432\u043D\u044B\u0439 \u0438\u043C\u043F\u043E\u0440\u0442" };
1295
+ }
1296
+ const res = insRun.run(
1297
+ r.task_slug,
1298
+ repoN,
1299
+ r.worktree_path ?? null,
1300
+ r.branch ?? null,
1301
+ status,
1302
+ r.area ?? null,
1303
+ r.model ?? null,
1304
+ r.cost_usd ?? 0,
1305
+ r.pr_url ?? null,
1306
+ r.decision ?? null,
1307
+ stopReason,
1308
+ r.created_at ?? null,
1309
+ r.updated_at ?? null
1310
+ );
1311
+ const newId = Number(res.lastInsertRowid);
1312
+ idMap.set(r.id, newId);
1313
+ if (normEvent) this.addEvent(newId, normEvent.kind, normEvent.text);
1314
+ }
1315
+ const setSup = this.db.prepare("UPDATE runs SET superseded_by = ? WHERE id = ?");
1316
+ for (const r of runs) {
1317
+ const sup = r.superseded_by;
1318
+ if (sup != null && idMap.has(sup)) setSup.run(idMap.get(sup), idMap.get(r.id));
1319
+ }
1320
+ const insEv = this.db.prepare("INSERT INTO events (run_id, kind, text, actor, at) VALUES (?,?,?,?,?)");
1321
+ for (const e of events) {
1322
+ const oldRun = e.run_id;
1323
+ const newRun = oldRun != null ? idMap.get(oldRun) ?? null : null;
1324
+ insEv.run(newRun, e.kind, e.text ?? null, e.actor ?? null, e.at ?? null);
1325
+ }
1326
+ const insQ = this.db.prepare(
1327
+ "INSERT INTO questions (run_id, text, options, status, answer, answered_by, escalated, asked_at, answered_at) VALUES (?,?,?,?,?,?,?,?,?)"
1328
+ );
1329
+ for (const q of questions) {
1330
+ if (q.status === "open") continue;
1331
+ const newRun = idMap.get(q.run_id);
1332
+ if (newRun == null) continue;
1333
+ insQ.run(
1334
+ newRun,
1335
+ q.text,
1336
+ q.options ?? null,
1337
+ q.status ?? "answered",
1338
+ q.answer ?? null,
1339
+ q.answered_by ?? null,
1340
+ q.escalated ?? 0,
1341
+ q.asked_at ?? null,
1342
+ q.answered_at ?? null
1343
+ );
1344
+ }
1345
+ this.db.prepare("INSERT INTO imports (source, run_count) VALUES (?, ?)").run(abs, runs.length);
1346
+ });
1347
+ txn();
1348
+ return { imported: true, runs: runs.length };
1349
+ } finally {
1350
+ src.close();
1183
1351
  }
1184
- const insEv = this.db.prepare("INSERT INTO events (run_id, kind, text, actor, at) VALUES (?,?,?,?,?)");
1185
- for (const e of events) {
1186
- const oldRun = e.run_id;
1187
- const newRun = oldRun != null ? idMap.get(oldRun) ?? null : null;
1188
- insEv.run(newRun, e.kind, e.text ?? null, e.actor ?? null, e.at ?? null);
1352
+ }
1353
+ // --- questions (петля ask/answer) ---
1354
+ ask(run_id, text, options) {
1355
+ const insert = () => this.db.prepare("INSERT INTO questions (run_id, text, options) VALUES (?, ?, ?)").run(run_id, text, options && options.length ? JSON.stringify(options) : null);
1356
+ let res;
1357
+ try {
1358
+ res = insert();
1359
+ } catch (err) {
1360
+ if (isForeignKeyError(err)) {
1361
+ console.error(
1362
+ `[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.`
1363
+ );
1364
+ this.ensureRun({ id: run_id, task_slug: "?" });
1365
+ res = insert();
1366
+ } else {
1367
+ throw err;
1368
+ }
1189
1369
  }
1190
- const insQ = this.db.prepare(
1191
- "INSERT INTO questions (run_id, text, options, status, answer, answered_by, escalated, asked_at, answered_at) VALUES (?,?,?,?,?,?,?,?,?)"
1192
- );
1193
- for (const q of questions) {
1194
- if (q.status === "open") continue;
1195
- const newRun = idMap.get(q.run_id);
1196
- if (newRun == null) continue;
1197
- insQ.run(
1198
- newRun,
1199
- q.text,
1200
- q.options ?? null,
1201
- q.status ?? "answered",
1202
- q.answer ?? null,
1203
- q.answered_by ?? null,
1204
- q.escalated ?? 0,
1205
- q.asked_at ?? null,
1206
- q.answered_at ?? null
1207
- );
1370
+ const id = Number(res.lastInsertRowid);
1371
+ this.updateRun(run_id, { status: "blocked" });
1372
+ this.addEvent(run_id, "blocked", text);
1373
+ return this.getQuestion(id);
1374
+ }
1375
+ /** Оркестратор поднял вопрос человеку (техническое решил бы сам). */
1376
+ escalateQuestion(id) {
1377
+ this.db.prepare("UPDATE questions SET escalated=1 WHERE id=?").run(id);
1378
+ }
1379
+ /**
1380
+ * Снять открытый вопрос без реального ответа (дрон замер: overmind недоступен,
1381
+ * ответа не будет — TASWM-41). Отдельный статус 'withdrawn' (не 'answered'): вопрос
1382
+ * уходит из openQuestions() (WHERE status='open'), защищён от answer() (тот правит
1383
+ * только open И воскрешает прогон лишь на статусе 'answered') — так снятый вопрос
1384
+ * не оживит замерший прогон. Остаётся в истории как снятый с пояснением.
1385
+ */
1386
+ withdrawQuestion(id, reason) {
1387
+ this.db.prepare("UPDATE questions SET status='withdrawn', answer=?, answered_at=datetime('now') WHERE id=? AND status='open'").run(reason, id);
1388
+ }
1389
+ answer(question_id, answer, by = "human") {
1390
+ this.db.prepare(
1391
+ "UPDATE questions SET status='answered', answer=?, answered_by=?, answered_at=datetime('now') WHERE id=? AND status='open'"
1392
+ ).run(answer, by, question_id);
1393
+ const q = this.getQuestion(question_id);
1394
+ if (q && q.status === "answered") {
1395
+ this.updateRun(q.run_id, { status: "running" });
1396
+ this.addEvent(q.run_id, "answered", answer, by);
1208
1397
  }
1209
- this.db.prepare("INSERT INTO imports (source, run_count) VALUES (?, ?)").run(abs, runs.length);
1210
- });
1211
- txn();
1212
- return { imported: true, runs: runs.length };
1213
- } finally {
1214
- src.close();
1215
- }
1216
- }
1217
- // --- questions (петля ask/answer) ---
1218
- ask(run_id, text, options) {
1219
- const insert = () => this.db.prepare("INSERT INTO questions (run_id, text, options) VALUES (?, ?, ?)").run(run_id, text, options && options.length ? JSON.stringify(options) : null);
1220
- let res;
1221
- try {
1222
- res = insert();
1223
- } catch (err) {
1224
- if (isForeignKeyError(err)) {
1225
- console.error(
1226
- `[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.`
1398
+ return q;
1399
+ }
1400
+ /** Пометить старый прогон переигранным на новый (откат на другой вариант). */
1401
+ supersede(oldId, newId, by = "human") {
1402
+ this.updateRun(oldId, { superseded_by: newId });
1403
+ this.addEvent(oldId, "superseded", `\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u2192 #${newId}`, by);
1404
+ }
1405
+ getQuestion(id) {
1406
+ const row = this.db.prepare("SELECT * FROM questions WHERE id = ?").get(id);
1407
+ return row ? rowToQuestion(row) : null;
1408
+ }
1409
+ openQuestions() {
1410
+ return this.db.prepare("SELECT * FROM questions WHERE status='open' ORDER BY id").all().map(
1411
+ rowToQuestion
1227
1412
  );
1228
- this.ensureRun({ id: run_id, task_slug: "?" });
1229
- res = insert();
1230
- } else {
1231
- throw err;
1232
1413
  }
1233
- }
1234
- const id = Number(res.lastInsertRowid);
1235
- this.updateRun(run_id, { status: "blocked" });
1236
- this.addEvent(run_id, "blocked", text);
1237
- return this.getQuestion(id);
1238
- }
1239
- /** Оркестратор поднял вопрос человеку (техническое решил бы сам). */
1240
- escalateQuestion(id) {
1241
- this.db.prepare("UPDATE questions SET escalated=1 WHERE id=?").run(id);
1242
- }
1243
- /**
1244
- * Снять открытый вопрос без реального ответа (дрон замер: overmind недоступен,
1245
- * ответа не будет — TASWM-41). Отдельный статус 'withdrawn' (не 'answered'): вопрос
1246
- * уходит из openQuestions() (WHERE status='open'), защищён от answer() (тот правит
1247
- * только open И воскрешает прогон лишь на статусе 'answered') — так снятый вопрос
1248
- * не оживит замерший прогон. Остаётся в истории как снятый с пояснением.
1249
- */
1250
- withdrawQuestion(id, reason) {
1251
- this.db.prepare("UPDATE questions SET status='withdrawn', answer=?, answered_at=datetime('now') WHERE id=? AND status='open'").run(reason, id);
1252
- }
1253
- answer(question_id, answer, by = "human") {
1254
- this.db.prepare(
1255
- "UPDATE questions SET status='answered', answer=?, answered_by=?, answered_at=datetime('now') WHERE id=? AND status='open'"
1256
- ).run(answer, by, question_id);
1257
- const q = this.getQuestion(question_id);
1258
- if (q && q.status === "answered") {
1259
- this.updateRun(q.run_id, { status: "running" });
1260
- this.addEvent(q.run_id, "answered", answer, by);
1261
- }
1262
- return q;
1263
- }
1264
- /** Пометить старый прогон переигранным на новый (откат на другой вариант). */
1265
- supersede(oldId, newId, by = "human") {
1266
- this.updateRun(oldId, { superseded_by: newId });
1267
- this.addEvent(oldId, "superseded", `\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u2192 #${newId}`, by);
1268
- }
1269
- getQuestion(id) {
1270
- const row = this.db.prepare("SELECT * FROM questions WHERE id = ?").get(id);
1271
- return row ? rowToQuestion(row) : null;
1272
- }
1273
- openQuestions() {
1274
- return this.db.prepare("SELECT * FROM questions WHERE status='open' ORDER BY id").all().map(
1275
- rowToQuestion
1276
- );
1277
- }
1278
- /** ВСЕ вопросы прогона (любого статуса), по возрастанию id — см. SwarmStore.questionsFor. */
1279
- questionsFor(run_id) {
1280
- return this.db.prepare("SELECT * FROM questions WHERE run_id = ? ORDER BY id").all(run_id).map(
1281
- rowToQuestion
1282
- );
1283
- }
1284
- /** id прогонов, у которых есть хотя бы один вопрос — доступность «Переиграть» в модалке. */
1285
- runIdsWithQuestions() {
1286
- return this.db.prepare("SELECT DISTINCT run_id FROM questions").all().map((r) => r.run_id);
1287
- }
1288
- /**
1289
- * Открытые вопросы дронов отряда Героя #heroId (join questions×runs по hero_run_id).
1290
- * Герой видит их в squad_status и отвечает через answer_drone; вопрос не из отряда сюда
1291
- * не попадёт — это и есть гейт «отвечать только своим» (TASWM-81).
1292
- */
1293
- openQuestionsFor(heroId) {
1294
- return this.db.prepare(
1295
- "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"
1296
- ).all(heroId).map(rowToQuestion);
1297
- }
1298
- // --- events (лог/хроника) ---
1299
- addEvent(run_id, kind, text, actor = null) {
1300
- const clean = sanitizeEventText(text);
1301
- const insert = () => this.db.prepare("INSERT INTO events (run_id, kind, text, actor) VALUES (?, ?, ?, ?)").run(run_id, kind, clean, actor);
1302
- try {
1303
- insert();
1304
- } catch (err) {
1305
- if (run_id != null && isForeignKeyError(err)) {
1306
- console.error(
1307
- `[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.`
1414
+ /** ВСЕ вопросы прогона (любого статуса), по возрастанию id — см. SwarmStore.questionsFor. */
1415
+ questionsFor(run_id) {
1416
+ return this.db.prepare("SELECT * FROM questions WHERE run_id = ? ORDER BY id").all(run_id).map(
1417
+ rowToQuestion
1308
1418
  );
1309
- this.ensureRun({ id: run_id, task_slug: "?" });
1310
- insert();
1311
- return;
1312
1419
  }
1313
- throw err;
1314
- }
1315
- }
1316
- events(run_id) {
1317
- return this.db.prepare("SELECT * FROM events WHERE run_id = ? ORDER BY id").all(run_id);
1318
- }
1319
- /** Хроника роя (вкладка История): Q&A (вопрос+варианты+ответ+автор) + вехи прогонов, новые сверху. */
1320
- history(limit = 50) {
1321
- const items = [];
1322
- const name = (row) => row.name ?? `#${row.run_id}`;
1323
- for (const row of this.db.prepare(
1324
- "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"
1325
- ).all()) {
1326
- const q = rowToQuestion(row);
1327
- items.push({
1328
- kind: "qa",
1329
- at: row.answered_at ?? q.asked_at,
1330
- slug: row.slug ?? "?",
1331
- name: name(row),
1332
- repo: row.repo ?? "",
1333
- area: row.area ?? null,
1334
- qid: q.id,
1335
- question: q.text,
1336
- options: q.options,
1337
- answer: q.answer,
1338
- answered_by: q.answered_by,
1339
- escalated: q.escalated,
1340
- open: q.status === "open",
1341
- title: row.title ?? null,
1342
- pr: row.pr ?? null,
1343
- taskUrl: row.taskUrl ?? null
1344
- });
1345
- }
1346
- for (const row of this.db.prepare(
1347
- "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"
1348
- ).all()) {
1349
- if (!isMilestoneKind(row.kind)) continue;
1350
- const global = row.run_id == null;
1351
- items.push({
1352
- kind: "milestone",
1353
- at: row.at,
1354
- slug: row.slug ?? (global ? "\u0440\u043E\u0439" : "?"),
1355
- name: global ? "\u0440\u043E\u0439" : name(row),
1356
- repo: row.repo ?? "",
1357
- event: row.kind,
1358
- text: row.text ?? null,
1359
- actor: row.actor ?? null,
1360
- title: row.title ?? null,
1361
- pr: row.pr ?? null,
1362
- taskUrl: row.taskUrl ?? null
1363
- });
1364
- }
1365
- items.sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0);
1366
- return items.slice(0, limit);
1367
- }
1368
- /** Сколько PR смержено сегодня (UTC) — для метрики «Смержено сегодня» на Главной. */
1369
- mergedToday() {
1370
- const row = this.db.prepare("SELECT COUNT(*) n FROM events WHERE kind = 'merged' AND date(at) = date('now')").get();
1371
- return row.n;
1372
- }
1373
- // --- штормовой режим (TASWM-23): состояние роя в событиях (source of truth) ---
1374
- /**
1375
- * В шторме ли рой сейчас: смотрим последнее ГЛОБАЛЬНОЕ (run_id NULL) событие
1376
- * storm_start/storm_end. storm_start без последующего storm_end → шторм активен.
1377
- * Выводится из событий → переживает рестарт overmind.
1378
- */
1379
- stormActive() {
1380
- 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();
1381
- return row?.kind === "storm_start";
1382
- }
1383
- /** Время начала ТЕКУЩЕГО шторма (at события storm_start) или null, если шторма нет. */
1384
- currentStormStartedAt() {
1385
- 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();
1386
- return row?.kind === "storm_start" ? row.at : null;
1387
- }
1388
- /**
1389
- * Время ПОСЛЕДНЕГО отбоя (at события storm_end) или null, если шторм сейчас идёт /
1390
- * отбоя ещё не было. Для гистерезиса TASWM-208: свежий отбой держит кулдаун на
1391
- * эвристический перезапуск. Смотрим то же последнее storm_start/storm_end, что и
1392
- * stormActive/currentStormStartedAt — probe-события не участвуют.
1393
- */
1394
- lastStormEndedAt() {
1395
- 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();
1396
- return row?.kind === "storm_end" ? row.at : null;
1397
- }
1398
- /** Пометить вход в шторм (глобальное событие). Идемпотентность — на стороне вызывающего. */
1399
- startStorm(reason) {
1400
- this.addEvent(null, "storm_start", reason);
1401
- }
1402
- /** Пометить отбой шторма (глобальное событие). */
1403
- endStorm(reason) {
1404
- this.addEvent(null, "storm_end", reason);
1405
- }
1406
- /** Записать смерть работающего дрона — семпл для скользящего окна детекции шторма. */
1407
- recordDeath(runId, note) {
1408
- this.addEvent(runId, "died", note ?? "\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440 (dead pid)");
1409
- }
1410
- /**
1411
- * Смертей дронов (событий died) с момента cutoff. cutoffSeconds — epoch-СЕКУНДЫ;
1412
- * datetime(?, 'unixepoch') форматирует его тем же «YYYY-MM-DD HH:MM:SS» UTC, что и
1413
- * datetime('now') в at — строковое сравнение корректно, без парса на стороне JS.
1414
- */
1415
- deathCountSince(cutoffSeconds) {
1416
- const row = this.db.prepare("SELECT COUNT(*) n FROM events WHERE kind='died' AND at >= datetime(?, 'unixepoch')").get(cutoffSeconds);
1417
- return row.n;
1418
- }
1419
- /**
1420
- * Спавн-фейлов (событий failed на НЕ-резолверных/НЕ-ревьюерных прогонах) с момента
1421
- * cutoff. Резолверные (merge-main-in) и ревьюерные (авто-ревью, TASWM-148) failed не
1422
- * считаем штормовым сигналом.
1423
- */
1424
- spawnFailCountSince(cutoffSeconds) {
1425
- const row = this.db.prepare(
1426
- "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')"
1427
- ).get(cutoffSeconds);
1428
- return row.n;
1429
- }
1430
- /** Записать результат периодической проверки шторма (health-probe + внешний сигнал). */
1431
- recordStormProbe(probe, ext, reason, resetHint) {
1432
- this.addEvent(null, "storm_probe", encodeProbe({ probe, ext, reason: reason ?? null, resetHint: resetHint ?? null }));
1433
- }
1434
- /** Последняя запись storm_probe (для троттлинга по времени и последнего внешнего сигнала). */
1435
- latestStormProbe() {
1436
- 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();
1437
- return row ? { rec: decodeProbe(row.text), at: row.at } : null;
1438
- }
1439
- /** Записи storm_probe текущего шторма (at >= sinceIso), по возрастанию — для счётчика успехов подряд. */
1440
- stormProbeRecordsSince(sinceIso) {
1441
- 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);
1442
- return rows.map((r) => decodeProbe(r.text));
1443
- }
1444
- /**
1445
- * Последняя РЕАЛЬНАЯ health-probe (probe !== null; ext-only записи пропускаем) с её
1446
- * временем. TASWM-137 п.д: подтверждающая проба перед входом в шторм по эвристике —
1447
- * зелёная проба вето́ит ложный шторм; свежую (в пределах probeIntervalMs) переиспользуем,
1448
- * не гоняя новую каждый тик. Смотрим только хвост (LIMIT) — старее нам всё равно не нужно.
1449
- */
1450
- lastHealthProbe() {
1451
- 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();
1452
- for (const r of rows) {
1453
- const rec = decodeProbe(r.text);
1454
- if (rec.probe !== null) return { ok: rec.probe, at: r.at };
1455
- }
1456
- return null;
1420
+ /** id прогонов, у которых есть хотя бы один вопрос — доступность «Переиграть» в модалке. */
1421
+ runIdsWithQuestions() {
1422
+ return this.db.prepare("SELECT DISTINCT run_id FROM questions").all().map((r) => r.run_id);
1423
+ }
1424
+ /**
1425
+ * Открытые вопросы дронов отряда Героя #heroId (join questions×runs по hero_run_id).
1426
+ * Герой видит их в squad_status и отвечает через answer_drone; вопрос не из отряда сюда
1427
+ * не попадёт — это и есть гейт «отвечать только своим» (TASWM-81).
1428
+ */
1429
+ openQuestionsFor(heroId) {
1430
+ return this.db.prepare(
1431
+ "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"
1432
+ ).all(heroId).map(rowToQuestion);
1433
+ }
1434
+ // --- events (лог/хроника) ---
1435
+ addEvent(run_id, kind, text, actor = null) {
1436
+ const clean = sanitizeEventText(text);
1437
+ const insert = () => this.db.prepare("INSERT INTO events (run_id, kind, text, actor) VALUES (?, ?, ?, ?)").run(run_id, kind, clean, actor);
1438
+ try {
1439
+ insert();
1440
+ } catch (err) {
1441
+ if (run_id != null && isForeignKeyError(err)) {
1442
+ console.error(
1443
+ `[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.`
1444
+ );
1445
+ this.ensureRun({ id: run_id, task_slug: "?" });
1446
+ insert();
1447
+ return;
1448
+ }
1449
+ throw err;
1450
+ }
1451
+ }
1452
+ events(run_id) {
1453
+ return this.db.prepare("SELECT * FROM events WHERE run_id = ? ORDER BY id").all(run_id);
1454
+ }
1455
+ /** Хроника роя (вкладка История): Q&A (вопрос+варианты+ответ+автор) + вехи прогонов, новые сверху. */
1456
+ history(limit = 50) {
1457
+ const items = [];
1458
+ const name = (row) => row.name ?? `#${row.run_id}`;
1459
+ for (const row of this.db.prepare(
1460
+ "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"
1461
+ ).all()) {
1462
+ const q = rowToQuestion(row);
1463
+ items.push({
1464
+ kind: "qa",
1465
+ at: row.answered_at ?? q.asked_at,
1466
+ slug: row.slug ?? "?",
1467
+ name: name(row),
1468
+ repo: row.repo ?? "",
1469
+ area: row.area ?? null,
1470
+ qid: q.id,
1471
+ question: q.text,
1472
+ options: q.options,
1473
+ answer: q.answer,
1474
+ answered_by: q.answered_by,
1475
+ escalated: q.escalated,
1476
+ open: q.status === "open",
1477
+ title: row.title ?? null,
1478
+ pr: row.pr ?? null,
1479
+ taskUrl: row.taskUrl ?? null
1480
+ });
1481
+ }
1482
+ for (const row of this.db.prepare(
1483
+ "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"
1484
+ ).all()) {
1485
+ if (!isMilestoneKind(row.kind)) continue;
1486
+ const global = row.run_id == null;
1487
+ items.push({
1488
+ kind: "milestone",
1489
+ at: row.at,
1490
+ slug: row.slug ?? (global ? "\u0440\u043E\u0439" : "?"),
1491
+ name: global ? "\u0440\u043E\u0439" : name(row),
1492
+ repo: row.repo ?? "",
1493
+ event: row.kind,
1494
+ text: row.text ?? null,
1495
+ actor: row.actor ?? null,
1496
+ title: row.title ?? null,
1497
+ pr: row.pr ?? null,
1498
+ taskUrl: row.taskUrl ?? null
1499
+ });
1500
+ }
1501
+ items.sort((a, b) => a.at < b.at ? 1 : a.at > b.at ? -1 : 0);
1502
+ return items.slice(0, limit);
1503
+ }
1504
+ /** Сколько PR смержено сегодня (UTC) — для метрики «Смержено сегодня» на Главной. */
1505
+ mergedToday() {
1506
+ const row = this.db.prepare("SELECT COUNT(*) n FROM events WHERE kind = 'merged' AND date(at) = date('now')").get();
1507
+ return row.n;
1508
+ }
1509
+ // --- штормовой режим (TASWM-23): состояние роя в событиях (source of truth) ---
1510
+ /**
1511
+ * В шторме ли рой сейчас: смотрим последнее ГЛОБАЛЬНОЕ (run_id NULL) событие
1512
+ * storm_start/storm_end. storm_start без последующего storm_end → шторм активен.
1513
+ * Выводится из событий → переживает рестарт overmind.
1514
+ */
1515
+ stormActive() {
1516
+ 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();
1517
+ return row?.kind === "storm_start";
1518
+ }
1519
+ /** Время начала ТЕКУЩЕГО шторма (at события storm_start) или null, если шторма нет. */
1520
+ currentStormStartedAt() {
1521
+ 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();
1522
+ return row?.kind === "storm_start" ? row.at : null;
1523
+ }
1524
+ /**
1525
+ * Время ПОСЛЕДНЕГО отбоя (at события storm_end) или null, если шторм сейчас идёт /
1526
+ * отбоя ещё не было. Для гистерезиса TASWM-208: свежий отбой держит кулдаун на
1527
+ * эвристический перезапуск. Смотрим то же последнее storm_start/storm_end, что и
1528
+ * stormActive/currentStormStartedAt — probe-события не участвуют.
1529
+ */
1530
+ lastStormEndedAt() {
1531
+ 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();
1532
+ return row?.kind === "storm_end" ? row.at : null;
1533
+ }
1534
+ /** Пометить вход в шторм (глобальное событие). Идемпотентность — на стороне вызывающего. */
1535
+ startStorm(reason) {
1536
+ this.addEvent(null, "storm_start", reason);
1537
+ }
1538
+ /** Пометить отбой шторма (глобальное событие). */
1539
+ endStorm(reason) {
1540
+ this.addEvent(null, "storm_end", reason);
1541
+ }
1542
+ /** Записать смерть работающего дрона — семпл для скользящего окна детекции шторма. */
1543
+ recordDeath(runId, note) {
1544
+ this.addEvent(runId, "died", note ?? "\u0434\u0440\u043E\u043D \u0443\u043C\u0435\u0440 (dead pid)");
1545
+ }
1546
+ /**
1547
+ * Смертей дронов (событий died) с момента cutoff. cutoffSeconds — epoch-СЕКУНДЫ;
1548
+ * datetime(?, 'unixepoch') форматирует его тем же «YYYY-MM-DD HH:MM:SS» UTC, что и
1549
+ * datetime('now') в at — строковое сравнение корректно, без парса на стороне JS.
1550
+ */
1551
+ deathCountSince(cutoffSeconds) {
1552
+ const row = this.db.prepare("SELECT COUNT(*) n FROM events WHERE kind='died' AND at >= datetime(?, 'unixepoch')").get(cutoffSeconds);
1553
+ return row.n;
1554
+ }
1555
+ /**
1556
+ * Спавн-фейлов (событий failed на НЕ-резолверных/НЕ-ревьюерных прогонах) с момента
1557
+ * cutoff. Резолверные (merge-main-in) и ревьюерные (авто-ревью, TASWM-148) failed не
1558
+ * считаем штормовым сигналом.
1559
+ */
1560
+ spawnFailCountSince(cutoffSeconds) {
1561
+ const row = this.db.prepare(
1562
+ "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')"
1563
+ ).get(cutoffSeconds);
1564
+ return row.n;
1565
+ }
1566
+ /** Записать результат периодической проверки шторма (health-probe + внешний сигнал). */
1567
+ recordStormProbe(probe, ext, reason, resetHint) {
1568
+ this.addEvent(null, "storm_probe", encodeProbe({ probe, ext, reason: reason ?? null, resetHint: resetHint ?? null }));
1569
+ }
1570
+ /** Последняя запись storm_probe (для троттлинга по времени и последнего внешнего сигнала). */
1571
+ latestStormProbe() {
1572
+ 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();
1573
+ return row ? { rec: decodeProbe(row.text), at: row.at } : null;
1574
+ }
1575
+ /** Записи storm_probe текущего шторма (at >= sinceIso), по возрастанию — для счётчика успехов подряд. */
1576
+ stormProbeRecordsSince(sinceIso) {
1577
+ 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);
1578
+ return rows.map((r) => decodeProbe(r.text));
1579
+ }
1580
+ /**
1581
+ * Последняя РЕАЛЬНАЯ health-probe (probe !== null; ext-only записи пропускаем) с её
1582
+ * временем. TASWM-137 п.д: подтверждающая проба перед входом в шторм по эвристике —
1583
+ * зелёная проба вето́ит ложный шторм; свежую (в пределах probeIntervalMs) переиспользуем,
1584
+ * не гоняя новую каждый тик. Смотрим только хвост (LIMIT) — старее нам всё равно не нужно.
1585
+ */
1586
+ lastHealthProbe() {
1587
+ 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();
1588
+ for (const r of rows) {
1589
+ const rec = decodeProbe(r.text);
1590
+ if (rec.probe !== null) return { ok: rec.probe, at: r.at };
1591
+ }
1592
+ return null;
1593
+ }
1594
+ };
1457
1595
  }
1458
- };
1596
+ });
1597
+
1598
+ // src/cli.ts
1599
+ import { basename as basename4 } from "path";
1600
+ import { existsSync as existsSync9, readdirSync as readdirSync6, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
1601
+ import { createInterface } from "readline/promises";
1602
+ import { setTimeout as sleep2 } from "timers/promises";
1459
1603
 
1460
1604
  // src/swarmClient.ts
1461
- import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1605
+ init_config();
1606
+ import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1462
1607
  import { dirname } from "path";
1463
1608
  import { randomUUID } from "crypto";
1464
1609
 
@@ -1477,6 +1622,7 @@ function execQuiet(file, args = [], opts = {}) {
1477
1622
  var PROC_SNAPSHOT_MAX_BUFFER = 16 * 1024 * 1024;
1478
1623
 
1479
1624
  // src/resources.ts
1625
+ init_config();
1480
1626
  import { readFileSync as readFileSync2, statfsSync } from "fs";
1481
1627
  function isTransientResourceError(e) {
1482
1628
  const code = e?.code;
@@ -1618,13 +1764,18 @@ function hostResourceSnapshot() {
1618
1764
  };
1619
1765
  }
1620
1766
 
1767
+ // src/swarmClient.ts
1768
+ init_names2();
1769
+ init_storm();
1770
+
1621
1771
  // src/logTail.ts
1622
- import { closeSync, existsSync as existsSync2, openSync, readSync, readdirSync, statSync } from "fs";
1772
+ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from "fs";
1623
1773
  import { join as join2 } from "path";
1624
1774
  var TAIL_LINES_FALLBACK = 200;
1625
1775
  var TAIL_MAX_BYTES_FALLBACK = 64 * 1024;
1626
1776
 
1627
1777
  // src/swarmClient.ts
1778
+ init_src();
1628
1779
  var RESOLVER_TERMINAL = /* @__PURE__ */ new Set([...TERMINAL_STATUSES, "stalled"]);
1629
1780
  var SLUG_TERMINAL = new Set(TERMINAL_STATUSES);
1630
1781
  function runRef(runKey) {
@@ -1720,6 +1871,9 @@ var SwarmClient = class _SwarmClient {
1720
1871
  flushing = false;
1721
1872
  flushTimer = null;
1722
1873
  backoffMs = 500;
1874
+ /** Сколько раз пробуем отвергнутую запись, прежде чем выбросить её (TASWM-338). */
1875
+ rejected = /* @__PURE__ */ new Map();
1876
+ static REJECT_LIMIT = 5;
1723
1877
  static BACKOFF_BASE = 500;
1724
1878
  static BACKOFF_MAX = 3e4;
1725
1879
  constructor(opts) {
@@ -1731,7 +1885,7 @@ var SwarmClient = class _SwarmClient {
1731
1885
  this.repoFullNames = registry.map((e) => e.repoFullName);
1732
1886
  this.repoPathByFullName = new Map(registry.map((e) => [e.repoFullName, normalizeRepo(e.path)]));
1733
1887
  this.host = opts.host ?? null;
1734
- this.repoExists = opts.repoExists ?? existsSync3;
1888
+ this.repoExists = opts.repoExists ?? existsSync2;
1735
1889
  this.questionSyncMs = opts.questionSyncMs ?? 3e3;
1736
1890
  this.outboxPath = opts.outboxPath;
1737
1891
  this.mapPath = `${opts.outboxPath}.prodmap.json`;
@@ -1741,14 +1895,14 @@ var SwarmClient = class _SwarmClient {
1741
1895
  }
1742
1896
  // ── рестарт: поднять outbox (незаписанные операции) и карту runKey→prodId ─────
1743
1897
  recover() {
1744
- if (existsSync3(this.mapPath)) {
1898
+ if (existsSync2(this.mapPath)) {
1745
1899
  try {
1746
1900
  const obj = JSON.parse(readFileSync3(this.mapPath, "utf8"));
1747
1901
  for (const [k, v] of Object.entries(obj)) this.prodIdByKey.set(k, v);
1748
1902
  } catch {
1749
1903
  }
1750
1904
  }
1751
- if (existsSync3(this.outboxPath)) {
1905
+ if (existsSync2(this.outboxPath)) {
1752
1906
  const txt = readFileSync3(this.outboxPath, "utf8");
1753
1907
  for (const line of txt.split("\n")) {
1754
1908
  const s = line.trim();
@@ -1769,7 +1923,7 @@ var SwarmClient = class _SwarmClient {
1769
1923
  // eventsLog ПЕРВЫМИ (до hydrate), в порядке файла (= хронологический); id
1770
1924
  // переприсваиваем локально (id событий — только порядок, не корреляция с продом).
1771
1925
  replayGlobalEvents() {
1772
- if (!existsSync3(this.stormPath)) return;
1926
+ if (!existsSync2(this.stormPath)) return;
1773
1927
  let txt;
1774
1928
  try {
1775
1929
  txt = readFileSync3(this.stormPath, "utf8");
@@ -2440,7 +2594,7 @@ var SwarmClient = class _SwarmClient {
2440
2594
  * транзиентным сбоем ОС, который этот PR должен переживать, а не превращать в мор.
2441
2595
  */
2442
2596
  reconcileRuntime(opts) {
2443
- const wtExists = opts.worktreeExists ?? existsSync3;
2597
+ const wtExists = opts.worktreeExists ?? existsSync2;
2444
2598
  const pidByWorktree = /* @__PURE__ */ new Map();
2445
2599
  try {
2446
2600
  for (const p of opts.listProcesses()) if (p.worktree) pidByWorktree.set(p.worktree, p.pid);
@@ -3237,7 +3391,23 @@ var SwarmClient = class _SwarmClient {
3237
3391
  } catch {
3238
3392
  break;
3239
3393
  }
3240
- if (res?.isError) break;
3394
+ if (res?.isError) {
3395
+ 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";
3396
+ const tries = (this.rejected.get(e.seq) ?? 0) + 1;
3397
+ this.rejected.set(e.seq, tries);
3398
+ if (tries >= _SwarmClient.REJECT_LIMIT) {
3399
+ this.pending.shift();
3400
+ this.rejected.delete(e.seq);
3401
+ console.error(
3402
+ `[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}`
3403
+ );
3404
+ continue;
3405
+ }
3406
+ console.error(
3407
+ `[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.`
3408
+ );
3409
+ break;
3410
+ }
3241
3411
  if (e.t === "run") {
3242
3412
  const run = res.structuredContent?.run;
3243
3413
  if (run?.id && this.prodIdByKey.get(e.runKey) !== run.id) {
@@ -3297,6 +3467,9 @@ function parseDecision(v) {
3297
3467
  }
3298
3468
  }
3299
3469
 
3470
+ // src/cli.ts
3471
+ init_config();
3472
+
3300
3473
  // src/daemonLaunch.ts
3301
3474
  import { openSync as openSync2, closeSync as closeSync2, renameSync as renameSync2, statSync as statSync2, writeFileSync as writeFileSync3, readFileSync as readFileSync4 } from "fs";
3302
3475
  import { spawn } from "child_process";
@@ -3608,6 +3781,7 @@ async function cleanupWorktreeProcesses(worktree, opts = {}) {
3608
3781
  }
3609
3782
 
3610
3783
  // src/repoRegistry.ts
3784
+ init_config();
3611
3785
  import { $ } from "execa";
3612
3786
  function parseRepoFullName(remoteUrl) {
3613
3787
  const m = remoteUrl.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(\.git)?\/?$/i);
@@ -3633,14 +3807,14 @@ async function buildRepoRegistry(paths) {
3633
3807
 
3634
3808
  // src/hostIdentity.ts
3635
3809
  import { hostname } from "os";
3636
- import { existsSync as existsSync4, readFileSync as readFileSync5, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs";
3810
+ import { existsSync as existsSync3, readFileSync as readFileSync5, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs";
3637
3811
  import { join as join3 } from "path";
3638
3812
  import { randomUUID as randomUUID2 } from "crypto";
3639
3813
  function hostIdentityPath(home) {
3640
3814
  return join3(home, "host.json");
3641
3815
  }
3642
3816
  function loadHostIdentity(deps) {
3643
- const exists = deps.exists ?? existsSync4;
3817
+ const exists = deps.exists ?? existsSync3;
3644
3818
  const readFile2 = deps.readFile ?? ((p) => readFileSync5(p, "utf8"));
3645
3819
  const writeFile2 = deps.writeFile ?? ((p, data) => {
3646
3820
  writeFileSync4(`${p}.tmp`, data);
@@ -3670,6 +3844,7 @@ function loadHostIdentity(deps) {
3670
3844
  }
3671
3845
 
3672
3846
  // src/runList.ts
3847
+ init_src();
3673
3848
  function hostGroupHeader(g, thisHostId) {
3674
3849
  const running = g.runs.filter((r) => r.status === "running").length;
3675
3850
  const mine = !g.unknown && g.key === thisHostId ? " (\u044D\u0442\u0430 \u043C\u0430\u0448\u0438\u043D\u0430)" : "";
@@ -3701,6 +3876,7 @@ function renderRunList(runs, opts) {
3701
3876
  }
3702
3877
 
3703
3878
  // src/hostPick.ts
3879
+ init_src();
3704
3880
  function chooseSpawnHost(flag) {
3705
3881
  if (flag == null) return { kind: "self" };
3706
3882
  const ref = normalizeHostRef(flag);
@@ -3747,6 +3923,7 @@ function hostLines(hosts, opts = {}) {
3747
3923
  }
3748
3924
 
3749
3925
  // src/web.ts
3926
+ init_src();
3750
3927
  import { randomBytes as randomBytes3 } from "crypto";
3751
3928
  import { basename as basename2 } from "path";
3752
3929
  import { serve as serve2 } from "@hono/node-server";
@@ -3940,7 +4117,7 @@ async function collectRunDiff(worktree, opts = {}) {
3940
4117
  // package.json
3941
4118
  var package_default = {
3942
4119
  name: "@taskless-app/swarm",
3943
- version: "0.4.5",
4120
+ version: "0.4.9",
3944
4121
  type: "module",
3945
4122
  description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
3946
4123
  keywords: [
@@ -3989,13 +4166,14 @@ var package_default = {
3989
4166
  "@modelcontextprotocol/sdk": "^1.29.0",
3990
4167
  execa: "^10.0.0",
3991
4168
  zod: "3.25.76"
3992
- },
3993
- optionalDependencies: {
3994
- "better-sqlite3": "^12.11.1"
3995
4169
  }
3996
4170
  };
3997
4171
 
4172
+ // src/web.ts
4173
+ init_storm();
4174
+
3998
4175
  // src/gh.ts
4176
+ init_src();
3999
4177
  import { $ as $3 } from "execa";
4000
4178
  var NO_CHECKS_STDERR = /no checks reported/i;
4001
4179
  var realChecksRunner = async (pr) => {
@@ -4041,7 +4219,11 @@ async function ghChecks(pr, run = realChecksRunner) {
4041
4219
  return { bucket, text, tone, total, pass, fail, pending: pending2 };
4042
4220
  }
4043
4221
 
4222
+ // src/web.ts
4223
+ init_config();
4224
+
4044
4225
  // src/droneChannel.ts
4226
+ init_src();
4045
4227
  import { createHmac, randomBytes, timingSafeEqual } from "crypto";
4046
4228
  import { chmodSync, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
4047
4229
  import { dirname as dirname2 } from "path";
@@ -4087,6 +4269,7 @@ function verifyLocalCliToken(token, secret) {
4087
4269
  var EVENT_KIND_SET = new Set(EVENT_KINDS);
4088
4270
 
4089
4271
  // src/oauth.ts
4272
+ init_config();
4090
4273
  import { createHash, randomBytes as randomBytes2 } from "crypto";
4091
4274
  import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, unlinkSync } from "fs";
4092
4275
  import { join as join5 } from "path";
@@ -5785,14 +5968,14 @@ import { cancelRun, respawnRun, runDaemon, wakeRun, OVERMIND_ENTRY, listDronePro
5785
5968
 
5786
5969
  // src/launcher.ts
5787
5970
  import { spawn as spawn2 } from "child_process";
5788
- import { closeSync as closeSync3, existsSync as existsSync6, mkdirSync as mkdirSync5, openSync as openSync3, readdirSync as readdirSync4, statSync as statSync5, writeFileSync as writeFileSync7 } from "fs";
5789
- import { createRequire as createRequire2 } from "module";
5971
+ import { closeSync as closeSync3, existsSync as existsSync5, mkdirSync as mkdirSync5, openSync as openSync3, readdirSync as readdirSync4, statSync as statSync5, writeFileSync as writeFileSync7 } from "fs";
5972
+ import { createRequire } from "module";
5790
5973
  import { homedir as homedir2 } from "os";
5791
5974
  import { dirname as dirname3, join as join7 } from "path";
5792
5975
  import { fileURLToPath } from "url";
5793
5976
 
5794
5977
  // src/claudeBin.ts
5795
- import { existsSync as existsSync5, readdirSync as readdirSync2 } from "fs";
5978
+ import { existsSync as existsSync4, readdirSync as readdirSync2 } from "fs";
5796
5979
  import { win32 as winPath } from "path";
5797
5980
  var CLAUDE_FALLBACK = "claude";
5798
5981
  function compareVersionDirs(a, b) {
@@ -5829,7 +6012,7 @@ function winVersionRoots(env, listDir) {
5829
6012
  function findWindowsClaude(opts = {}) {
5830
6013
  const env = opts.env ?? process.env;
5831
6014
  const listDir = opts.listDir ?? realListDir;
5832
- const fileExists = opts.fileExists ?? existsSync5;
6015
+ const fileExists = opts.fileExists ?? existsSync4;
5833
6016
  let best = null;
5834
6017
  for (const root of winVersionRoots(env, listDir)) {
5835
6018
  for (const version of listDir(root)) {
@@ -5983,7 +6166,7 @@ function droneTestEnv(droneCap, base3 = process.env, host = hostForkSpec()) {
5983
6166
  }
5984
6167
 
5985
6168
  // src/launcher.ts
5986
- var requireFromHere = createRequire2(import.meta.url);
6169
+ var requireFromHere = createRequire(import.meta.url);
5987
6170
  function resolveTsxCli() {
5988
6171
  try {
5989
6172
  return requireFromHere.resolve("tsx/cli");
@@ -6049,7 +6232,7 @@ function roleDir(worktree, kind) {
6049
6232
  return join7(worktree, ".claude", "roles", kind);
6050
6233
  }
6051
6234
  function roleDirs(worktree, kind) {
6052
- return [bundledRoleDir(kind), roleDir(worktree, kind)].filter((dir) => existsSync6(dir));
6235
+ return [bundledRoleDir(kind), roleDir(worktree, kind)].filter((dir) => existsSync5(dir));
6053
6236
  }
6054
6237
  function nextRunLogPath(runLogsDir, runId) {
6055
6238
  const dir = join7(runLogsDir, String(runId));
@@ -6145,7 +6328,7 @@ function makeLauncher(cfg) {
6145
6328
  const realHome = cfg.droneIsolation?.realHome ?? homedir2();
6146
6329
  const home = cfg.droneIsolation ? buildDroneHome({ realHome, droneHome: cfg.droneIsolation.home }) : void 0;
6147
6330
  const settings = cfg.droneIsolation ? droneSettingsJson(realHome, cfg.droneIsolation.swarmHome) : void 0;
6148
- if (cfg.droneIsolation && process.env.TASKLESS_TOKEN && !existsSync6(join7(cfg.droneIsolation.swarmHome, "token"))) {
6331
+ if (cfg.droneIsolation && process.env.TASKLESS_TOKEN && !existsSync5(join7(cfg.droneIsolation.swarmHome, "token"))) {
6149
6332
  console.error(
6150
6333
  `[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.`
6151
6334
  );
@@ -6178,7 +6361,7 @@ function makeLauncher(cfg) {
6178
6361
  }
6179
6362
 
6180
6363
  // src/git.ts
6181
- import { existsSync as existsSync7, readdirSync as readdirSync5, statSync as statSync6 } from "fs";
6364
+ import { existsSync as existsSync6, readdirSync as readdirSync5, statSync as statSync6 } from "fs";
6182
6365
  import { appendFile, mkdir, readFile, rm } from "fs/promises";
6183
6366
  import { isAbsolute, join as join8 } from "path";
6184
6367
  import { $ as $4 } from "execa";
@@ -6196,7 +6379,7 @@ async function detachWorktree(repo, worktree) {
6196
6379
  }
6197
6380
  }
6198
6381
  async function ensureWorktreeDirGone(worktree) {
6199
- if (!existsSync7(worktree)) return false;
6382
+ if (!existsSync6(worktree)) return false;
6200
6383
  if (!isLinkedWorktree(worktree)) {
6201
6384
  console.error(
6202
6385
  `[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.`
@@ -6211,7 +6394,7 @@ async function ensureWorktreeDirGone(worktree) {
6211
6394
  );
6212
6395
  return true;
6213
6396
  }
6214
- if (existsSync7(worktree)) {
6397
+ if (existsSync6(worktree)) {
6215
6398
  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.`);
6216
6399
  }
6217
6400
  return true;
@@ -6224,6 +6407,7 @@ async function removeWorktree(repo, path) {
6224
6407
  }
6225
6408
 
6226
6409
  // src/agentToken.ts
6410
+ init_config();
6227
6411
  import { readFileSync as readFileSync8, writeFileSync as writeFileSync8, unlinkSync as unlinkSync2 } from "fs";
6228
6412
  import { join as join9 } from "path";
6229
6413
  var STORE_PATH2 = join9(SWARM_HOME, "agent.json");
@@ -6270,6 +6454,7 @@ async function probeAgentToken(args, fetchFn = fetch) {
6270
6454
  }
6271
6455
 
6272
6456
  // src/taskless.ts
6457
+ init_src();
6273
6458
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6274
6459
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6275
6460
 
@@ -6288,7 +6473,11 @@ import { readFile as fsReadFile } from "fs/promises";
6288
6473
  import { basename as basename3, isAbsolute as isAbsolute2, join as join11 } from "path";
6289
6474
  var SHOT_MAX_FILE_BYTES = 10 * 1024 * 1024;
6290
6475
 
6476
+ // src/taskless.ts
6477
+ init_config();
6478
+
6291
6479
  // src/workspace.ts
6480
+ init_config();
6292
6481
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync9, unlinkSync as unlinkSync3 } from "fs";
6293
6482
  import { join as join12 } from "path";
6294
6483
  var WORKSPACE_PATH = join12(SWARM_HOME, "workspace.json");
@@ -6643,6 +6832,7 @@ async function launchChromium(opts = {}) {
6643
6832
  }
6644
6833
 
6645
6834
  // src/shotCli.ts
6835
+ init_config();
6646
6836
  function isFlagValue(v) {
6647
6837
  return v !== void 0 && !v.startsWith("--");
6648
6838
  }
@@ -6717,6 +6907,7 @@ async function runShotCli(argv, deps = {}) {
6717
6907
  }
6718
6908
 
6719
6909
  // src/daemonLink.ts
6910
+ init_config();
6720
6911
  var TIMEOUT_MS = 5e3;
6721
6912
  var ACTION_TIMEOUT_MS = 2e4;
6722
6913
  var BASE = `http://127.0.0.1:${WEB_PORT}`;
@@ -6782,14 +6973,14 @@ async function daemonStatus2() {
6782
6973
  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`;
6783
6974
 
6784
6975
  // src/pauseState.ts
6785
- import { existsSync as existsSync8, readFileSync as readFileSync10, renameSync as renameSync4, unlinkSync as unlinkSync4, writeFileSync as writeFileSync10 } from "fs";
6976
+ import { existsSync as existsSync7, readFileSync as readFileSync10, renameSync as renameSync4, unlinkSync as unlinkSync4, writeFileSync as writeFileSync10 } from "fs";
6786
6977
  import { join as join13 } from "path";
6787
6978
  function pauseStatePath(home) {
6788
6979
  return join13(home, "pause.json");
6789
6980
  }
6790
6981
  var ACTIVE = { paused: false, at: "", source: "cli" };
6791
6982
  function loadPauseState(home, io = {}) {
6792
- const exists = io.exists ?? existsSync8;
6983
+ const exists = io.exists ?? existsSync7;
6793
6984
  const readFile2 = io.readFile ?? ((p) => readFileSync10(p, "utf8"));
6794
6985
  const path = pauseStatePath(home);
6795
6986
  if (!exists(path)) return ACTIVE;
@@ -6809,7 +7000,7 @@ function loadPauseState(home, io = {}) {
6809
7000
  function savePauseState(home, state, io = {}) {
6810
7001
  const path = pauseStatePath(home);
6811
7002
  if (!state.paused) {
6812
- const remove = io.remove ?? ((p) => existsSync8(p) && unlinkSync4(p));
7003
+ const remove = io.remove ?? ((p) => existsSync7(p) && unlinkSync4(p));
6813
7004
  remove(path);
6814
7005
  return;
6815
7006
  }
@@ -6855,6 +7046,7 @@ function readHeartbeat(path) {
6855
7046
  }
6856
7047
 
6857
7048
  // src/cli.ts
7049
+ init_src();
6858
7050
  var PACKAGE_VERSION2 = package_default.version;
6859
7051
  function takeRepo(argv) {
6860
7052
  const i = argv.indexOf("--repo");
@@ -7277,7 +7469,9 @@ function usage() {
7277
7469
  swarm login [--workspace <slug|id>] [--no-open]
7278
7470
  \u0432\u043E\u0439\u0442\u0438 \u0432 Taskless \u0438 \u0432\u044B\u0431\u0440\u0430\u0442\u044C \u0432\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441
7279
7471
  swarm start [--repo <path>] [--foreground]
7280
- \u043F\u043E\u0434\u043D\u044F\u0442\u044C \u0440\u043E\u0439 \u043D\u0430 \u044D\u0442\u043E\u0439 \u043C\u0430\u0448\u0438\u043D\u0435: \u0440\u0430\u0437\u0431\u0438\u0440\u0430\u0435\u0442 \u043E\u0447\u0435\u0440\u0435\u0434\u044C, \u0441\u043F\u0430\u0432\u043D\u0438\u0442 \u0438 \u0431\u0443\u0434\u0438\u0442 \u0434\u0440\u043E\u043D\u043E\u0432
7472
+ \u043F\u043E\u0434\u043D\u044F\u0442\u044C \u0440\u043E\u0439 \u043D\u0430 \u044D\u0442\u043E\u0439 \u043C\u0430\u0448\u0438\u043D\u0435: \u0440\u0430\u0437\u0431\u0438\u0440\u0430\u0435\u0442 \u043E\u0447\u0435\u0440\u0435\u0434\u044C, \u0441\u043F\u0430\u0432\u043D\u0438\u0442 \u0438 \u0431\u0443\u0434\u0438\u0442 \u0434\u0440\u043E\u043D\u043E\u0432.
7473
+ \u0420\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u0438 \u043D\u0430\u0445\u043E\u0434\u0438\u0442 \u0441\u0430\u043C \u2014 \u043A\u043B\u043E\u043D\u044B \u043F\u0440\u043E\u0435\u043A\u0442\u043E\u0432 Taskless \u043D\u0430 \u044D\u0442\u043E\u0439 \u043C\u0430\u0448\u0438\u043D\u0435;
7474
+ --repo \u043D\u0443\u0436\u0435\u043D, \u0442\u043E\u043B\u044C\u043A\u043E \u0435\u0441\u043B\u0438 \u043E\u043D\u0438 \u043B\u0435\u0436\u0430\u0442 \u0432 \u043D\u0435\u043E\u0431\u044B\u0447\u043D\u043E\u043C \u043C\u0435\u0441\u0442\u0435
7281
7475
  swarm status \u0436\u0438\u0432 \u043B\u0438 \u0434\u0435\u043C\u043E\u043D, \u043D\u0430 \u043F\u0430\u0443\u0437\u0435 \u043B\u0438 \u0440\u043E\u0439, \u0441\u043A\u043E\u043B\u044C\u043A\u043E \u0434\u0440\u043E\u043D\u043E\u0432 \u0432 \u0440\u0430\u0431\u043E\u0442\u0435
7282
7476
 
7283
7477
  \u0414\u0410\u0422\u042C \u0420\u041E\u042E \u0420\u0410\u0411\u041E\u0422\u0423
@@ -7350,7 +7544,10 @@ function effectiveWorktreesDir() {
7350
7544
  return effectiveWorktreesDirMemo;
7351
7545
  }
7352
7546
  async function makeCliStore(repo) {
7353
- if (SWARM_BACKEND !== "client") return new SwarmDb(DB_PATH, repo);
7547
+ if (SWARM_BACKEND !== "client") {
7548
+ const { SwarmDb: SwarmDb2 } = await Promise.resolve().then(() => (init_db(), db_exports));
7549
+ return new SwarmDb2(DB_PATH, repo);
7550
+ }
7354
7551
  const call = resilientSwarmMcp({
7355
7552
  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.`),
7356
7553
  onUp: () => console.warn("Swarm MCP \u0441\u043D\u043E\u0432\u0430 \u043D\u0430 \u0441\u0432\u044F\u0437\u0438.")
@@ -7408,7 +7605,8 @@ if (cmd === "import-db") {
7408
7605
  console.error("\u043D\u0443\u0436\u043D\u043E: swarm import-db <path> --repo <path>");
7409
7606
  process.exit(1);
7410
7607
  }
7411
- const sdb = new SwarmDb(DB_PATH, repo);
7608
+ const { SwarmDb: SwarmDb2 } = await Promise.resolve().then(() => (init_db(), db_exports));
7609
+ const sdb = new SwarmDb2(DB_PATH, repo);
7412
7610
  const res = sdb.importDb(path, repo);
7413
7611
  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})`);
7414
7612
  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`);
@@ -7422,14 +7620,11 @@ if (cmd === "start") {
7422
7620
  }
7423
7621
  const { repo: repoFlag } = takeRepo(startArgs);
7424
7622
  const repo = repoFlag ?? SWARM_REPO;
7425
- if (!repo) {
7426
- console.warn("SWARM_REPO \u043D\u0435 \u0437\u0430\u0434\u0430\u043D \u2014 \u043F\u0440\u043E\u0433\u043E\u043D\u044B \u0431\u0435\u0437 \u044F\u0432\u043D\u043E\u0433\u043E repo \u043D\u0435 \u0437\u0430\u043F\u0443\u0441\u0442\u044F\u0442\u0441\u044F (\u043D\u0443\u0436\u0435\u043D spawn --repo).");
7427
- }
7428
7623
  if (!foreground) {
7429
7624
  process.exit(await runStartDetached(startArgs));
7430
7625
  }
7431
7626
  try {
7432
- await runDaemon({ repo });
7627
+ await runDaemon({ repo, repoFlag });
7433
7628
  } catch (e) {
7434
7629
  console.error(`\u0434\u0435\u043C\u043E\u043D \u043D\u0435 \u043F\u043E\u0434\u043D\u044F\u043B\u0441\u044F: ${e instanceof Error ? e.message : e}`);
7435
7630
  process.exit(1);