@taskless-app/swarm 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +372 -37
  2. package/dist/overmind.js +1030 -155
  3. package/package.json +7 -5
package/dist/cli.js CHANGED
@@ -3,10 +3,10 @@
3
3
  // src/cli.ts
4
4
  import { basename as basename3 } from "path";
5
5
  import { createInterface } from "readline/promises";
6
- import { setTimeout as sleep } from "timers/promises";
6
+ import { setTimeout as sleep2 } from "timers/promises";
7
7
 
8
8
  // src/db.ts
9
- import Database from "better-sqlite3";
9
+ import { createRequire } from "module";
10
10
  import { existsSync } from "fs";
11
11
  import { resolve as resolve2 } from "path";
12
12
 
@@ -92,6 +92,17 @@ function buildSegments(created, events, laneEnd) {
92
92
  push(e.at);
93
93
  phase = "running";
94
94
  break;
95
+ // TASWM-166: раньше эти два kind'а не двигали фазу — хвост дорожки
96
+ // дорисовывался ТЕКУЩЕЙ (до остановки) фазой, напр. "running" сплошным
97
+ // зелёным до самого конца — визуально неотличимо от реально идущего прогона.
98
+ case "stopped":
99
+ push(e.at);
100
+ phase = "stopped";
101
+ break;
102
+ case "superseded":
103
+ push(e.at);
104
+ phase = "superseded";
105
+ break;
95
106
  }
96
107
  }
97
108
  push(laneEnd);
@@ -112,7 +123,7 @@ function computeBlocker(status, stopReason, lastStep, pr, ci, deps, openQ) {
112
123
  case "blocked":
113
124
  return { icon: "\u2753", text: openQ ? `\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430: ${openQ}` : "\u0436\u0434\u0451\u0442 \u043E\u0442\u0432\u0435\u0442\u0430 \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u0430", tone: "blocked" };
114
125
  case "paused":
115
- return { icon: "\u{1F4A4}", text: "\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430", tone: "paused" };
126
+ return { icon: "\u{1F4A4}", text: stopReason || "\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430", tone: "paused" };
116
127
  case "storm_dead":
117
128
  return { icon: "\u{1F4A4}", text: "\u0432 \u043A\u043E\u043A\u043E\u043D\u0435 \u2014 \u0448\u0442\u043E\u0440\u043C Anthropic, \u0436\u0434\u0451\u0442 \u043E\u0442\u0431\u043E\u044F", tone: "paused" };
118
129
  case "review": {
@@ -126,7 +137,9 @@ function computeBlocker(status, stopReason, lastStep, pr, ci, deps, openQ) {
126
137
  case "failed":
127
138
  return { icon: "\u274C", text: stopReason || "\u0443\u043F\u0430\u043B", tone: "fail" };
128
139
  case "stopped":
129
- return { icon: "\u23F9", text: stopReason || "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", tone: "queued" };
140
+ return { icon: "\u23F9", text: stopReason || "\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D", tone: "stopped" };
141
+ case "superseded":
142
+ return { icon: "\u21BA", text: "\u043F\u0435\u0440\u0435\u0438\u0433\u0440\u0430\u043D \u043D\u0430 \u0434\u0440\u0443\u0433\u043E\u0439 \u0432\u0430\u0440\u0438\u0430\u043D\u0442", tone: "superseded" };
130
143
  case "done":
131
144
  return { icon: "\u2705", text: "\u0441\u043C\u0435\u0440\u0436\u0435\u043D\u043E", tone: "done" };
132
145
  default:
@@ -311,11 +324,16 @@ var SWARM_CLAIM_INTERVAL_MS = envNum("SWARM_CLAIM_INTERVAL_MS", 6e4);
311
324
  var SWARM_MAX_LOAD_FACTOR = envNum("SWARM_MAX_LOAD_FACTOR", 0.9);
312
325
  var SWARM_MIN_FREE_GB = envNum("SWARM_MIN_FREE_GB", 1.5);
313
326
  var SWARM_MAX_FD_LOAD_FACTOR = envNum("SWARM_MAX_FD_LOAD_FACTOR", 0.9);
327
+ var SWARM_RELIEF_LOAD_FACTOR = envNum("SWARM_RELIEF_LOAD_FACTOR", 1.3);
328
+ var SWARM_RELIEF_MIN_FREE_GB = envNum("SWARM_RELIEF_MIN_FREE_GB", 0.8);
329
+ var SWARM_RELIEF_MAX_FD_LOAD_FACTOR = envNum("SWARM_RELIEF_MAX_FD_LOAD_FACTOR", 0.95);
314
330
  var SWARM_REHYDRATE_MS = envNum("SWARM_REHYDRATE_MS", 15e3);
315
331
  var SWARM_PROD_HEARTBEAT_MS = envNum("SWARM_PROD_HEARTBEAT_MS", 15e3);
332
+ var SWARM_SWEEP_INTERVAL_MS = envNum("SWARM_SWEEP_INTERVAL_MS", 10 * 6e4);
316
333
  var HEARTBEAT_PATH = process.env.SWARM_HEARTBEAT || join(SWARM_HOME, "overmind.heartbeat");
317
334
  var HEARTBEAT_STALE_MS = Number(process.env.SWARM_HEARTBEAT_STALE_MS) || 3e4;
318
335
  var SWARM_REPO = process.env.SWARM_REPO || "";
336
+ var SWARM_REPO_PATHS = (process.env.SWARM_REPOS || SWARM_REPO).split(",").map((s) => s.trim()).filter(Boolean);
319
337
  var TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
320
338
  var SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
321
339
  var TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
@@ -515,12 +533,25 @@ function rowToRun(row) {
515
533
  updated_at: row.updated_at
516
534
  };
517
535
  }
536
+ function loadBetterSqlite3() {
537
+ const require2 = createRequire(import.meta.url);
538
+ try {
539
+ return require2("better-sqlite3");
540
+ } catch (err) {
541
+ throw new Error(
542
+ `better-sqlite3 \u043D\u0435 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D. Sqlite-\u0440\u0435\u0436\u0438\u043C (SwarmDb) \u2014 \u0434\u0435\u0444\u043E\u043B\u0442 \u043C\u043E\u043D\u043E\u0440\u0435\u043F\u044B/\u0442\u0435\u0441\u0442\u043E\u0432; \u043E\u043F\u0443\u0431\u043B\u0438\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0439 \u043F\u0430\u043A\u0435\u0442 \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E \u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442 \u0432 client-\u0440\u0435\u0436\u0438\u043C\u0435 \u0438 sqlite \u043D\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442. \u0423\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u0435 optional dependency (npm i, \u0431\u0435\u0437 --omit=optional) \u0438\u043B\u0438 \u0437\u0430\u0434\u0430\u0439\u0442\u0435 SWARM_BACKEND=client. \u0418\u0441\u0445\u043E\u0434\u043D\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430: ${err.message}`
543
+ );
544
+ }
545
+ }
518
546
  var SwarmDb = class {
519
547
  db;
520
548
  defaultRepo;
549
+ databaseCtor;
550
+ // для importDb (второй Database, source)
521
551
  constructor(path = "swarm.db", defaultRepo = "") {
522
552
  this.defaultRepo = normalizeRepo(defaultRepo);
523
- this.db = new Database(path);
553
+ this.databaseCtor = loadBetterSqlite3();
554
+ this.db = new this.databaseCtor(path);
524
555
  this.db.exec("PRAGMA journal_mode = WAL;");
525
556
  this.db.exec(SCHEMA);
526
557
  for (const alter of [
@@ -656,6 +687,33 @@ var SwarmDb = class {
656
687
  const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ? AND status NOT IN ('failed','stalled')").get(reviewRunId);
657
688
  return row.n;
658
689
  }
690
+ /**
691
+ * Жёсткий кап ревью-спавнов (TASWM-204, карусель мёртвых ревьюеров): сколько ревьюеров
692
+ * ВСЕГО (любой статус, включая failed/stalled — специально в отличие от reviewerCountFor
693
+ * выше) когда-либо поднималось на review-прогон #reviewRunId. Зеркалит resolverCountFor —
694
+ * плоский потолок попыток. Именно это ловит карусель: reviewerCountFor исключает
695
+ * failed/stalled (чтобы ОДИН транзиентный сбой не блокировал ревью навсегда), но тем
696
+ * самым допускает бесконечный ретрай, если ревьюер стабильно умирает без done().
697
+ */
698
+ reviewerAttemptCountFor(reviewRunId) {
699
+ const row = this.db.prepare("SELECT COUNT(*) n FROM runs WHERE reviews_run_id = ?").get(reviewRunId);
700
+ return row.n;
701
+ }
702
+ /** Последняя (любой статус) попытка ревьюера на review-прогон #reviewRunId (TASWM-196) —
703
+ * см. store.ts:lastReviewerAttemptFor. */
704
+ lastReviewerAttemptFor(reviewRunId) {
705
+ const row = this.db.prepare("SELECT * FROM runs WHERE reviews_run_id = ? ORDER BY id DESC LIMIT 1").get(reviewRunId);
706
+ return row ? rowToRun(row) : null;
707
+ }
708
+ /**
709
+ * Живые/ждущие ревьюер-прогоны review-прогона #reviewRunId (TASWM-204): для
710
+ * closeReviewersOnMerge — PR смержен раньше вердикта, зависшего ревьюера пора закрыть,
711
+ * не дожидаясь done(). Точечный WHERE, не полный скан listRuns() (code-review TASWM-204).
712
+ */
713
+ activeReviewersFor(reviewRunId) {
714
+ const rows = this.db.prepare(`SELECT * FROM runs WHERE reviews_run_id = ? AND status NOT IN (${TERMINAL_SQL}) ORDER BY id DESC`).all(reviewRunId);
715
+ return rows.map(rowToRun);
716
+ }
659
717
  /** Последний живой (не терминальный) прогон по slug — для swarm cancel. */
660
718
  activeRunForSlug(slug) {
661
719
  const row = this.db.prepare(
@@ -716,7 +774,7 @@ var SwarmDb = class {
716
774
  const already = this.db.prepare("SELECT run_count FROM imports WHERE source = ?").get(abs);
717
775
  if (already) return { imported: false, runs: already.run_count };
718
776
  if (!existsSync(abs)) throw new Error(`import-db: \u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442: ${abs}`);
719
- const src = new Database(abs);
777
+ const src = new this.databaseCtor(abs);
720
778
  try {
721
779
  const runs = src.prepare("SELECT * FROM runs ORDER BY id").all();
722
780
  const events = src.prepare("SELECT * FROM events ORDER BY id").all();
@@ -1027,6 +1085,10 @@ var SwarmClient = class _SwarmClient {
1027
1085
  defaultRepo;
1028
1086
  autoFlush;
1029
1087
  listLimit;
1088
+ // TASWM-169: репо-реестр демона — repoFullName[] на claim/heartbeat + обратная
1089
+ // карта repoFullName→локальный путь для трансляции run.repo из прода (см. resolveRepo).
1090
+ repoFullNames;
1091
+ repoPathByFullName;
1030
1092
  // Зеркало.
1031
1093
  runs = /* @__PURE__ */ new Map();
1032
1094
  runtime = /* @__PURE__ */ new Map();
@@ -1059,6 +1121,9 @@ var SwarmClient = class _SwarmClient {
1059
1121
  this.defaultRepo = normalizeRepo(opts.defaultRepo ?? "");
1060
1122
  this.autoFlush = opts.autoFlush ?? true;
1061
1123
  this.listLimit = opts.listLimit ?? 200;
1124
+ const registry = opts.repoRegistry ?? [];
1125
+ this.repoFullNames = registry.map((e) => e.repoFullName);
1126
+ this.repoPathByFullName = new Map(registry.map((e) => [e.repoFullName, normalizeRepo(e.path)]));
1062
1127
  this.outboxPath = opts.outboxPath;
1063
1128
  this.mapPath = `${opts.outboxPath}.prodmap.json`;
1064
1129
  this.stormPath = `${opts.outboxPath}.storm.jsonl`;
@@ -1305,6 +1370,19 @@ var SwarmClient = class _SwarmClient {
1305
1370
  (r) => r.reviews_run_id === reviewRunId && r.status !== "failed" && r.status !== "stalled"
1306
1371
  ).length;
1307
1372
  }
1373
+ // Жёсткий кап ревью-спавнов (TASWM-204) — см. db.ts:reviewerAttemptCountFor.
1374
+ reviewerAttemptCountFor(reviewRunId) {
1375
+ return [...this.runs.values()].filter((r) => r.reviews_run_id === reviewRunId).length;
1376
+ }
1377
+ // Последняя попытка ревьюера (TASWM-196) — см. db.ts:lastReviewerAttemptFor.
1378
+ lastReviewerAttemptFor(reviewRunId) {
1379
+ const cands = [...this.runs.values()].filter((r) => r.reviews_run_id === reviewRunId).sort((a, b) => b.id - a.id);
1380
+ return cands.length ? this.mat(cands[0]) : null;
1381
+ }
1382
+ // Живые/ждущие ревьюер-прогоны (TASWM-204) — см. db.ts:activeReviewersFor.
1383
+ activeReviewersFor(reviewRunId) {
1384
+ return [...this.runs.values()].filter((r) => r.reviews_run_id === reviewRunId && !TERMINAL_STATUSES.includes(r.status)).sort((a, b) => b.id - a.id).map((d) => this.mat(d));
1385
+ }
1308
1386
  activeRunForSlug(slug) {
1309
1387
  const cands = [...this.runs.values()].filter((r) => r.task_slug === slug && !SLUG_TERMINAL.has(r.status)).sort((a, b) => b.id - a.id);
1310
1388
  return cands.length ? this.mat(cands[0]) : null;
@@ -1625,11 +1703,17 @@ var SwarmClient = class _SwarmClient {
1625
1703
  * cuid известен сразу, никакого create-flush. Возвращает локальный Run (queued; overmind
1626
1704
  * заспавнит его следующим тиком) либо null: нет claimable-задачи / прод недоступен / тул
1627
1705
  * отверг — во всех случаях тик продолжается, следующий ретраит.
1706
+ *
1707
+ * TASWM-169: repoFullNames — ЖИВОЙ реестр демона на момент ЭТОГО вызова, а не
1708
+ * последняя heartbeat-декларация (heartbeat — своя кадансность, ~15с; claim — ~60с;
1709
+ * плюс окно до первого успешного heartbeat при рестарте) — свежий аргумент здесь
1710
+ * убирает то окно рассинхрона целиком. Пусто (демон без SWARM_REPOS) → ключ не
1711
+ * шлём — прод фолбэкается на последнюю heartbeat-декларацию (или без фильтра).
1628
1712
  */
1629
1713
  async claim() {
1630
1714
  let res;
1631
1715
  try {
1632
- res = await this.call("swarm_claim_work", {});
1716
+ res = await this.call("swarm_claim_work", this.repoFullNames.length ? { repoFullNames: this.repoFullNames } : {});
1633
1717
  } catch {
1634
1718
  return null;
1635
1719
  }
@@ -1659,16 +1743,41 @@ var SwarmClient = class _SwarmClient {
1659
1743
  const n = obj?.maxDrones;
1660
1744
  return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
1661
1745
  }
1746
+ /**
1747
+ * Live verifyMode проекта из Taskless (TASWM-171): читает swarm_repo_config по
1748
+ * repoFullName (owner/repo из origin — см. git.remoteRepoFullName) — Project.swarmConfig.
1749
+ * verifyMode ("local"/"ci"). Прод недоступен / repo не смэплен на swarm-enabled проект
1750
+ * владельца токена (config=null в ответе) → null: ридер (makeVerifyModeReader) берёт
1751
+ * env-фолбэк SWARM_VERIFY.
1752
+ */
1753
+ async repoVerifyMode(repoFullName) {
1754
+ let res;
1755
+ try {
1756
+ res = await this.call("swarm_repo_config", { repoFullName });
1757
+ } catch {
1758
+ return null;
1759
+ }
1760
+ if (res?.isError) return null;
1761
+ const sc = res.structuredContent ?? {};
1762
+ const v = sc.config?.verifyMode;
1763
+ return v === "local" || v === "ci" ? v : null;
1764
+ }
1662
1765
  /**
1663
1766
  * Пульс overmind в прод (TASWM-129): swarm_heartbeat обновляет User.swarmHeartbeatAt —
1664
1767
  * дашборд /swarm сравнивает свежесть со своим порогом устаревания («нет связи с
1665
1768
  * овермайндом» вместо устаревшего «живого» роя). Best-effort, как ownerMaxDrones/claim:
1666
1769
  * прод недоступен/тул отверг → просто не обновили в этом тике, следующий ретраит —
1667
1770
  * не роняем тик демона из-за упавшего пульса.
1771
+ *
1772
+ * TASWM-169: заодно декларирует repoFullNames (репо-реестр демона) — та же
1773
+ * кадансность, что и пульс, естественная точка «сказать серверу, что обслуживаю».
1774
+ * Пустой реестр (демон не сконфигурирован на multi-repo/SWARM_REPOS) → ключ не
1775
+ * шлём вовсе (undefined) — прод НЕ трогает прошлую декларацию (см. swarm.ts), не
1776
+ * стирает её ложным «репо больше нет» из-за демона без SWARM_REPOS в env.
1668
1777
  */
1669
1778
  async heartbeat() {
1670
1779
  try {
1671
- await this.call("swarm_heartbeat", {});
1780
+ await this.call("swarm_heartbeat", this.repoFullNames.length ? { repoFullNames: this.repoFullNames } : {});
1672
1781
  } catch {
1673
1782
  }
1674
1783
  }
@@ -1702,6 +1811,19 @@ var SwarmClient = class _SwarmClient {
1702
1811
  });
1703
1812
  if (res?.isError) throw new Error(`swarm_notify \u043E\u0442\u0432\u0435\u0440\u0433 \xAB${signal.key}\xBB`);
1704
1813
  }
1814
+ /**
1815
+ * TASWM-169: прод присылает run.repo как GitHub `owner/repo` для задач, взятых
1816
+ * через claim-фильтр (matchedRepo в swarm_claim_work, см. api/mcp/tools/swarm.ts) —
1817
+ * сервер не знает локальных fs-путей. Транслируем в путь по СВОЕМУ репо-реестру
1818
+ * (см. repoRegistry.ts), т.к. git-операции (addWorktree/droneWorktreePath) ждут
1819
+ * именно путь. Не нашли в реестре (репо не наш / ручной `--repo <path>` спавн, где
1820
+ * repo — ВСЕГДА уже путь, старый формат) → возвращаем как есть без изменений
1821
+ * (обратная совместимость: раньше pr.repo ВСЕГДА был локальным путём).
1822
+ */
1823
+ resolveRepo(raw) {
1824
+ if (!raw) return "";
1825
+ return this.repoPathByFullName.get(raw) ?? raw;
1826
+ }
1705
1827
  hydrateRun(pr) {
1706
1828
  const prodId = String(pr.id ?? "");
1707
1829
  if (!prodId) return null;
@@ -1757,7 +1879,7 @@ var SwarmClient = class _SwarmClient {
1757
1879
  const revProd = pr.reviewsRunId ?? null;
1758
1880
  if (revProd) local.reviews_run_id = this.localIdForProdId(revProd) ?? local.reviews_run_id;
1759
1881
  local.review_verdict = pr.reviewVerdict ?? local.review_verdict;
1760
- local.repo = pr.repo ?? "";
1882
+ local.repo = this.resolveRepo(pr.repo ?? null);
1761
1883
  local.branch = pr.branch ?? null;
1762
1884
  local.status = pr.status ?? local.status;
1763
1885
  local.area = pr.area ?? null;
@@ -1823,11 +1945,66 @@ var SwarmClient = class _SwarmClient {
1823
1945
  }, this.backoffMs);
1824
1946
  if (typeof this.flushTimer.unref === "function") this.flushTimer.unref();
1825
1947
  }
1826
- resolveArgs(e) {
1948
+ // TASWM-202: create-флаш без прод-id слал taskId=e.taskRef НАПРЯМУЮ — а taskRef это
1949
+ // человеческий слаг (task_slug, напр. «TASWM-171»), не cuid. Резолверы конфликта
1950
+ // рождаются ЛОКАЛЬНО (createRun с uuid-runKey) и попадают в outbox БЕЗ прод-id —
1951
+ // обычные прогоны рождаются в проде (claim) и гидрируются сразу с cuid, эта ветка
1952
+ // их не касается. Прод create-схема ждёт cuid → отбивает слаг → строго
1953
+ // упорядоченный flush стоит на этой голове НАВЕЧНО (ядовитая голова, инцидент
1954
+ // 14.07/18.07, TASWM-201 — общий карантин; здесь — конкретный источник). Резолвим
1955
+ // слаг→cuid через get_task (кэш — taskIdCache) ПЕРЕД тем как слать create.
1956
+ taskIdCache = /* @__PURE__ */ new Map();
1957
+ // task_slug → cuid прода
1958
+ // Create для этого runKey резолвился окончательно неудачно (задача не найдена/
1959
+ // недоступна) — событие/вопросы того же прогона тоже никогда не получат prod-id,
1960
+ // без карантина они бы бросали «нет prod-id» на каждом флаше и СНОВА вставали
1961
+ // ядовитой головой (просто на одну запись ниже по очереди).
1962
+ poisonedRunKeys = /* @__PURE__ */ new Set();
1963
+ // "transient" — прод/сеть недоступны, ответа нет или ошибка НЕ про отсутствие задачи
1964
+ // (offline-стаб CLI, auth-протух, 5xx): НЕ терминально — запись остаётся в outbox и
1965
+ // повторится на следующем флаше (иначе сетевой сбой = потеря прогона). null —
1966
+ // терминально: прод ЯВНО ответил «задачи нет» (task_not_found / not_found в тексте
1967
+ // tool-ошибки) либо успешный ответ без id — только это карантиним.
1968
+ async resolveTaskCuid(taskRef) {
1969
+ const cached = this.taskIdCache.get(taskRef);
1970
+ if (cached) return cached;
1971
+ let res;
1972
+ try {
1973
+ res = await this.call("get_task", { id: taskRef });
1974
+ } catch {
1975
+ return "transient";
1976
+ }
1977
+ if (!res) return "transient";
1978
+ if (res.isError) {
1979
+ const text = (res.content ?? []).map((c) => c.text ?? "").join(" ");
1980
+ return /not_found/i.test(text) ? null : "transient";
1981
+ }
1982
+ const id = res.structuredContent?.task?.id;
1983
+ if (!id) return null;
1984
+ this.taskIdCache.set(taskRef, id);
1985
+ return id;
1986
+ }
1987
+ async resolveArgs(e) {
1827
1988
  if (e.t === "run") {
1828
1989
  const fields = this.materializeRefs(e.fields);
1829
1990
  const prodId2 = this.prodIdByKey.get(e.runKey);
1830
- return prodId2 ? { tool: "swarm_run_upsert", args: { runId: prodId2, ...fields } } : { tool: "swarm_run_upsert", args: { taskId: e.taskRef, ...fields } };
1991
+ if (prodId2) return { tool: "swarm_run_upsert", args: { runId: prodId2, ...fields } };
1992
+ const taskId = await this.resolveTaskCuid(e.taskRef);
1993
+ if (taskId === "transient") {
1994
+ throw new Error(`slug\u2192cuid \u0434\u043B\u044F \xAB${e.taskRef}\xBB \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D (\u043F\u0440\u043E\u0434 \u043C\u043E\u0440\u0433\u043D\u0443\u043B) \u2014 retry`);
1995
+ }
1996
+ if (!taskId) {
1997
+ console.error(
1998
+ `[swarm-client] create-\u0444\u043B\u0430\u0448 \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D: \u043F\u0440\u043E\u0434 \u043D\u0435 \u0437\u043D\u0430\u0435\u0442 \u0437\u0430\u0434\u0430\u0447\u0443 \xAB${e.taskRef}\xBB (runKey=${e.runKey}) \u2014 \u0437\u0430\u043F\u0438\u0441\u044C \u0432\u044B\u0431\u0440\u043E\u0448\u0435\u043D\u0430 \u0438\u0437 outbox`
1999
+ );
2000
+ this.poisonedRunKeys.add(e.runKey);
2001
+ return "skip";
2002
+ }
2003
+ return { tool: "swarm_run_upsert", args: { taskId, ...fields } };
2004
+ }
2005
+ if (this.poisonedRunKeys.has(e.runKey)) {
2006
+ console.error(`[swarm-client] ${e.t}-\u0444\u043B\u0430\u0448 \u043F\u0440\u043E\u043F\u0443\u0449\u0435\u043D: create \u043F\u0440\u043E\u0433\u043E\u043D\u0430 runKey=${e.runKey} \u043D\u0435 \u0440\u0435\u0437\u043E\u043B\u0432\u0438\u043B\u0441\u044F \u0440\u0430\u043D\u0435\u0435 \u2014 \u0437\u0430\u043F\u0438\u0441\u044C \u0432\u044B\u0431\u0440\u043E\u0448\u0435\u043D\u0430 \u0438\u0437 outbox`);
2007
+ return "skip";
1831
2008
  }
1832
2009
  const prodId = this.prodIdByKey.get(e.runKey);
1833
2010
  if (!prodId) throw new Error(`\u043D\u0435\u0442 prod-id \u0434\u043B\u044F ${e.runKey} \u2014 \u043F\u0440\u043E\u0433\u043E\u043D \u0435\u0449\u0451 \u043D\u0435 \u0441\u043E\u0437\u0434\u0430\u043D \u0432 \u043F\u0440\u043E\u0434\u0435`);
@@ -1861,10 +2038,14 @@ var SwarmClient = class _SwarmClient {
1861
2038
  const e = this.pending[0];
1862
2039
  let call;
1863
2040
  try {
1864
- call = this.resolveArgs(e);
2041
+ call = await this.resolveArgs(e);
1865
2042
  } catch {
1866
2043
  break;
1867
2044
  }
2045
+ if (call === "skip") {
2046
+ this.pending.shift();
2047
+ continue;
2048
+ }
1868
2049
  let res;
1869
2050
  try {
1870
2051
  res = await this.call(call.tool, call.args);
@@ -1930,6 +2111,30 @@ function parseDecision(v) {
1930
2111
  }
1931
2112
  }
1932
2113
 
2114
+ // src/repoRegistry.ts
2115
+ import { $ } from "execa";
2116
+ function parseRepoFullName(remoteUrl) {
2117
+ const m = remoteUrl.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(\.git)?\/?$/i);
2118
+ if (!m) return null;
2119
+ return `${m[1]}/${m[2]}`.toLowerCase();
2120
+ }
2121
+ async function resolveRepoFullName(path) {
2122
+ const res = await $({ reject: false })`git -C ${path} remote get-url origin`;
2123
+ if (res.exitCode !== 0) return null;
2124
+ return parseRepoFullName(res.stdout);
2125
+ }
2126
+ async function buildRepoRegistry(paths) {
2127
+ const resolved = await Promise.all(
2128
+ paths.map(async (raw) => {
2129
+ const path = normalizeRepo(raw);
2130
+ if (!path) return null;
2131
+ const repoFullName = await resolveRepoFullName(path);
2132
+ return repoFullName ? { repoFullName, path } : null;
2133
+ })
2134
+ );
2135
+ return resolved.filter((e) => e !== null);
2136
+ }
2137
+
1933
2138
  // src/web.ts
1934
2139
  import { randomBytes as randomBytes2 } from "crypto";
1935
2140
  import { basename as basename2 } from "path";
@@ -1938,7 +2143,7 @@ import { serve } from "@hono/node-server";
1938
2143
  // src/diff.ts
1939
2144
  import { statSync } from "fs";
1940
2145
  import { join as join2 } from "path";
1941
- import { $ } from "execa";
2146
+ import { $ as $2 } from "execa";
1942
2147
  var MAX_FILES = 200;
1943
2148
  var MAX_LINES_PER_FILE = 1500;
1944
2149
  var MAX_UNTRACKED = 60;
@@ -2058,12 +2263,12 @@ function truncateFile(f) {
2058
2263
  }
2059
2264
  async function resolveBase(wt) {
2060
2265
  const tryRef = async (ref) => {
2061
- const sha = (await $({ reject: false })`git -C ${wt} rev-parse --verify --quiet ${ref + "^{commit}"}`).stdout.trim();
2266
+ const sha = (await $2({ reject: false })`git -C ${wt} rev-parse --verify --quiet ${ref + "^{commit}"}`).stdout.trim();
2062
2267
  if (!sha) return null;
2063
- const mb = (await $({ reject: false })`git -C ${wt} merge-base HEAD ${ref}`).stdout.trim();
2268
+ const mb = (await $2({ reject: false })`git -C ${wt} merge-base HEAD ${ref}`).stdout.trim();
2064
2269
  return mb || null;
2065
2270
  };
2066
- const originHead = (await $({ reject: false })`git -C ${wt} symbolic-ref --quiet --short refs/remotes/origin/HEAD`).stdout.trim();
2271
+ const originHead = (await $2({ reject: false })`git -C ${wt} symbolic-ref --quiet --short refs/remotes/origin/HEAD`).stdout.trim();
2067
2272
  const candidates = [originHead, "origin/main", "origin/master", "main", "master"].filter(Boolean);
2068
2273
  for (const c of candidates) {
2069
2274
  const mb = await tryRef(c);
@@ -2082,16 +2287,16 @@ async function collectRunDiff(worktree, opts = {}) {
2082
2287
  const stub = (reason) => ({ ok: false, reason, pr: opts.pr ?? null, files: [], added: 0, removed: 0 });
2083
2288
  const gone = opts.status === "queued" ? "\u0434\u0440\u043E\u043D \u0435\u0449\u0451 \u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438 \u2014 \u043A\u043E\u0434\u0430 \u043F\u043E\u043A\u0430 \u043D\u0435\u0442" : "worktree \u0443\u0431\u0440\u0430\u043D \u2014 \u043A\u043E\u0434 \u0441\u043C\u043E\u0442\u0440\u0438 \u0432 PR";
2084
2289
  if (!worktree) return stub(gone);
2085
- const isWt = (await $({ reject: false })`git -C ${worktree} rev-parse --is-inside-work-tree`).stdout.trim();
2290
+ const isWt = (await $2({ reject: false })`git -C ${worktree} rev-parse --is-inside-work-tree`).stdout.trim();
2086
2291
  if (isWt !== "true") return stub(gone);
2087
2292
  const base2 = await resolveBase(worktree);
2088
2293
  const from = base2 ?? "HEAD";
2089
- const trackedTxt = (await $({ reject: false })`git -C ${worktree} diff --no-color --find-renames ${from} --`).stdout;
2294
+ const trackedTxt = (await $2({ reject: false })`git -C ${worktree} diff --no-color --find-renames ${from} --`).stdout;
2090
2295
  let files = parseUnifiedDiff(trackedTxt);
2091
- const untrackedRaw = (await $({ reject: false })`git -C ${worktree} ls-files --others --exclude-standard -z`).stdout;
2296
+ const untrackedRaw = (await $2({ reject: false })`git -C ${worktree} ls-files --others --exclude-standard -z`).stdout;
2092
2297
  const untracked = untrackedRaw.split("\0").filter(Boolean).slice(0, MAX_UNTRACKED);
2093
2298
  for (const rel of untracked) {
2094
- const t = (await $({ reject: false })`git -C ${worktree} diff --no-color --no-index -- /dev/null ${rel}`).stdout;
2299
+ const t = (await $2({ reject: false })`git -C ${worktree} diff --no-color --no-index -- /dev/null ${rel}`).stdout;
2095
2300
  for (const pf of parseUnifiedDiff(t)) {
2096
2301
  pf.status = "added";
2097
2302
  if (!pf.path || pf.path === "/dev/null") pf.path = rel;
@@ -2115,8 +2320,8 @@ async function collectRunDiff(worktree, opts = {}) {
2115
2320
  }
2116
2321
 
2117
2322
  // src/gh.ts
2118
- import { $ as $2 } from "execa";
2119
- var realChecksRunner = async (pr) => (await $2({ reject: false, timeout: 15e3 })`gh pr checks ${pr} --json state,name,bucket,link`).stdout;
2323
+ import { $ as $3 } from "execa";
2324
+ var realChecksRunner = async (pr) => (await $3({ reject: false, timeout: 15e3 })`gh pr checks ${pr} --json state,name,bucket,link`).stdout;
2120
2325
  async function prCheckRows(pr, run) {
2121
2326
  try {
2122
2327
  const out = await run(pr);
@@ -2459,7 +2664,8 @@ function timelineState(db2, now = Date.now(), overmindLog = []) {
2459
2664
  cost: r.cost_usd || 0,
2460
2665
  role: r.kind,
2461
2666
  heroRunId: r.hero_run_id,
2462
- reviewVerdict: r.review_verdict
2667
+ reviewVerdict: r.review_verdict,
2668
+ model: r.model
2463
2669
  });
2464
2670
  }
2465
2671
  const liveLanes = lanes.filter((l) => l.live);
@@ -2957,7 +3163,7 @@ function cardStep(d){
2957
3163
  if(d.status==='stalled') return d.reason || '\u0434\u0440\u043E\u043D \u0437\u0430\u0432\u0438\u0441/\u043F\u043E\u0433\u0438\u0431';
2958
3164
  if(d.status==='stopped') return d.stopReason || d.reason || '\u043E\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D';
2959
3165
  if(d.status==='storm_dead') return '\u0434\u0440\u043E\u043D \u0432 \u043A\u043E\u043A\u043E\u043D\u0435 \u2014 \u043F\u0441\u0438\u043E\u043D\u043D\u0430\u044F \u0441\u0432\u044F\u0437\u044C \u043F\u043E\u0442\u0435\u0440\u044F\u043D\u0430 (Anthropic \u0448\u0442\u043E\u0440\u043C\u0438\u0442), worktree \u0446\u0435\u043B, \u043F\u043E\u0434\u043D\u0438\u043C\u0435\u0442\u0441\u044F \u043F\u043E\u0441\u043B\u0435 \u043E\u0442\u0431\u043E\u044F';
2960
- if(d.status==='paused') return '\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430';
3166
+ if(d.status==='paused') return d.stopReason || '\u0437\u0430\u043C\u0435\u0440 \u2014 overmind \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u0436\u0434\u0451\u0442 \u043F\u0435\u0440\u0435\u043F\u043E\u0434\u044A\u0451\u043C\u0430'; // TASWM-151: \u0440\u0435\u0441\u0443\u0440\u0441-\u0440\u0430\u0437\u0433\u0440\u0443\u0437\u043A\u0430 \u0437\u0430\u0434\u0430\u0451\u0442 \u0441\u0432\u043E\u0439 stopReason
2961
3167
  if(d.status==='review') { var n=prNum(d.pr); return 'PR'+(n?(' #'+n):'')+' \u043D\u0430 \u0440\u0435\u0432\u044C\u044E'; }
2962
3168
  if(d.status==='done') return '\u0441\u043C\u0435\u0440\u0436\u0435\u043D';
2963
3169
  if(d.status==='queued') return (d.waiting&&d.waiting.length) ? '\u0436\u0434\u0451\u0442: '+d.waiting.join(', ') : '\u0432 \u043E\u0447\u0435\u0440\u0435\u0434\u0438 \u2014 \u0436\u0434\u0451\u0442 \u0441\u043B\u043E\u0442\u0430';
@@ -3626,7 +3832,10 @@ function makeLauncher(cfg) {
3626
3832
  overmind: {
3627
3833
  command: isCompiled ? process.execPath : "bun",
3628
3834
  args: isCompiled ? [cfg.overmindEntry, "--drone", "--run", String(run.id)] : ["run", cfg.overmindEntry, "--drone", "--run", String(run.id)],
3629
- env: { SWARM_DB: cfg.dbPath }
3835
+ // SWARM_TASK_SLUG (TASWM-211): task_slug зафиксирован ЗДЕСЬ, на спавне — устойчив к
3836
+ // пересозданию локального стора между спавном и вызовами read_task (droneServer.ts
3837
+ // доверяет ему, а не db.getRun(run.id).task_slug, который может протухнуть/переехать).
3838
+ env: { SWARM_DB: cfg.dbPath, SWARM_TASK_SLUG: run.task_slug }
3630
3839
  }
3631
3840
  }
3632
3841
  };
@@ -3654,11 +3863,132 @@ function makeLauncher(cfg) {
3654
3863
  }
3655
3864
 
3656
3865
  // src/git.ts
3657
- import { $ as $3 } from "execa";
3866
+ import { $ as $4 } from "execa";
3867
+
3868
+ // src/procCleanup.ts
3869
+ import { execFileSync as execFileSync2 } from "child_process";
3870
+ function sleep(ms) {
3871
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
3872
+ }
3873
+ function parseLsofCwd(output) {
3874
+ const rows = [];
3875
+ let pid = null;
3876
+ for (const line of output.split("\n")) {
3877
+ const pMatch = /^p(\d+)$/.exec(line);
3878
+ if (pMatch) {
3879
+ pid = Number(pMatch[1]);
3880
+ } else if (line.startsWith("n") && pid != null) {
3881
+ rows.push({ pid, cwd: line.slice(1) });
3882
+ pid = null;
3883
+ }
3884
+ }
3885
+ return rows;
3886
+ }
3887
+ function cwdUnder(cwd, dir) {
3888
+ return cwd === dir || cwd.startsWith(`${dir}/`);
3889
+ }
3890
+ function pidsWithCwdUnder(dir) {
3891
+ let out;
3892
+ try {
3893
+ out = execFileSync2("lsof", ["-a", "-d", "cwd", "-F", "pn"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
3894
+ } catch (e) {
3895
+ logTransientSpawnError("lsof", e);
3896
+ return [];
3897
+ }
3898
+ return parseLsofCwd(out).filter((r) => cwdUnder(r.cwd, dir));
3899
+ }
3900
+ function pidAlive(pid) {
3901
+ try {
3902
+ process.kill(pid, 0);
3903
+ return true;
3904
+ } catch (e) {
3905
+ return e.code === "EPERM";
3906
+ }
3907
+ }
3908
+ async function killPidsGraceful(pids, opts = {}) {
3909
+ const graceMs = opts.graceMs ?? 3e3;
3910
+ const pollMs = opts.pollMs ?? 150;
3911
+ const kill = opts.kill ?? ((pid, signal) => process.kill(pid, signal));
3912
+ const isAlive = opts.isAlive ?? pidAlive;
3913
+ const sleepFn = opts.sleepFn ?? sleep;
3914
+ const signalled = [];
3915
+ for (const pid of pids) {
3916
+ try {
3917
+ kill(pid, "SIGTERM");
3918
+ signalled.push(pid);
3919
+ } catch {
3920
+ }
3921
+ }
3922
+ if (!signalled.length) return signalled;
3923
+ const deadline = Date.now() + graceMs;
3924
+ while (Date.now() < deadline && signalled.some((pid) => isAlive(pid))) {
3925
+ await sleepFn(pollMs);
3926
+ }
3927
+ for (const pid of signalled) {
3928
+ try {
3929
+ if (isAlive(pid)) kill(pid, "SIGKILL");
3930
+ } catch {
3931
+ }
3932
+ }
3933
+ return signalled;
3934
+ }
3935
+ function composeProjectPrefix(worktreeDirName) {
3936
+ return worktreeDirName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3937
+ }
3938
+ function containerBelongsToPrefix(name, prefix) {
3939
+ return name === prefix || name.startsWith(`${prefix}-`);
3940
+ }
3941
+ function dockerContainerNames() {
3942
+ try {
3943
+ return execFileSync2("docker", ["ps", "-a", "--format", "{{.Names}}"], { encoding: "utf8" }).split("\n").map((s) => s.trim()).filter(Boolean);
3944
+ } catch (e) {
3945
+ logTransientSpawnError("docker ps", e);
3946
+ return [];
3947
+ }
3948
+ }
3949
+ function removeContainers(names) {
3950
+ const removed = [];
3951
+ for (const name of names) {
3952
+ try {
3953
+ execFileSync2("docker", ["rm", "-f", name], { encoding: "utf8" });
3954
+ removed.push(name);
3955
+ } catch (e) {
3956
+ console.warn(`[procCleanup] docker rm -f ${name} \u0441\u043E\u0440\u0432\u0430\u043B\u0441\u044F: ${String(e)}`);
3957
+ }
3958
+ }
3959
+ return removed;
3960
+ }
3961
+ async function cleanupWorktreeProcesses(worktree, opts = {}) {
3962
+ try {
3963
+ const found = pidsWithCwdUnder(worktree);
3964
+ if (found.length) {
3965
+ const killed = await killPidsGraceful(
3966
+ found.map((f) => f.pid),
3967
+ opts
3968
+ );
3969
+ if (killed.length) console.log(`[procCleanup] worktree ${worktree}: \u043F\u043E\u0433\u0430\u0448\u0435\u043D\u043E \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u0432 ${killed.length} (${killed.join(", ")})`);
3970
+ }
3971
+ } catch (e) {
3972
+ console.error(`[procCleanup] \u0433\u0430\u0448\u0435\u043D\u0438\u0435 \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u0432 worktree ${worktree} \u0441\u043E\u0440\u0432\u0430\u043B\u043E\u0441\u044C:`, e);
3973
+ }
3974
+ try {
3975
+ const prefix = composeProjectPrefix(worktree.split("/").filter(Boolean).pop() ?? "");
3976
+ if (!prefix) return;
3977
+ const names = dockerContainerNames().filter((n) => containerBelongsToPrefix(n, prefix));
3978
+ if (!names.length) return;
3979
+ const removed = removeContainers(names);
3980
+ if (removed.length) console.log(`[procCleanup] worktree ${worktree}: \u0441\u043D\u0435\u0441\u0435\u043D\u043E docker-\u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u043E\u0432 ${removed.length} (${removed.join(", ")})`);
3981
+ } catch (e) {
3982
+ console.error(`[procCleanup] \u0441\u043D\u043E\u0441 docker-\u043A\u043E\u043D\u0442\u0435\u0439\u043D\u0435\u0440\u043E\u0432 worktree ${worktree} \u0441\u043E\u0440\u0432\u0430\u043B\u0441\u044F:`, e);
3983
+ }
3984
+ }
3985
+
3986
+ // src/git.ts
3658
3987
  async function removeWorktree(repo, path) {
3659
- const branch = (await $3({ reject: false })`git -C ${path} symbolic-ref --quiet --short HEAD`).stdout.trim();
3660
- await $3({ reject: false })`git -C ${repo} worktree remove --force ${path}`;
3661
- if (branch) await $3({ reject: false })`git -C ${repo} branch -D ${branch}`;
3988
+ const branch = (await $4({ reject: false })`git -C ${path} symbolic-ref --quiet --short HEAD`).stdout.trim();
3989
+ await cleanupWorktreeProcesses(path);
3990
+ await $4({ reject: false })`git -C ${repo} worktree remove --force ${path}`;
3991
+ if (branch) await $4({ reject: false })`git -C ${repo} branch -D ${branch}`;
3662
3992
  }
3663
3993
 
3664
3994
  // src/taskless.ts
@@ -3915,7 +4245,7 @@ async function runShotCli(argv, deps = {}) {
3915
4245
  // package.json
3916
4246
  var package_default = {
3917
4247
  name: "@taskless-app/swarm",
3918
- version: "0.2.0",
4248
+ version: "0.2.2",
3919
4249
  type: "module",
3920
4250
  description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
3921
4251
  keywords: [
@@ -3959,15 +4289,17 @@ var package_default = {
3959
4289
  lint: "echo 'no lint in swarm-daemon yet'"
3960
4290
  },
3961
4291
  dependencies: {
3962
- "@hono/node-server": "^1.13.0",
4292
+ "@hono/node-server": "^2.0.10",
3963
4293
  "@modelcontextprotocol/sdk": "^1.29.0",
3964
- "better-sqlite3": "^11.8.0",
3965
- execa: "^9.5.0",
4294
+ execa: "^10.0.0",
3966
4295
  zod: "3.25.76"
3967
4296
  },
4297
+ optionalDependencies: {
4298
+ "better-sqlite3": "^12.11.1"
4299
+ },
3968
4300
  devDependencies: {
3969
4301
  "@taskless/swarm-core": "workspace:*",
3970
- "@types/better-sqlite3": "^7.6.11",
4302
+ "@types/better-sqlite3": "^7.6.13",
3971
4303
  "@types/node": "22.10.5",
3972
4304
  "playwright-core": "1.49.1",
3973
4305
  tsup: "^8.5.1",
@@ -4032,7 +4364,7 @@ async function runLogin(db2, opts) {
4032
4364
  loadStore,
4033
4365
  beforeToken: null,
4034
4366
  now: Date.now,
4035
- sleep,
4367
+ sleep: sleep2,
4036
4368
  timeoutMs: 5 * 6e4,
4037
4369
  pollMs: 1e3
4038
4370
  });
@@ -4113,8 +4445,11 @@ function usage() {
4113
4445
  async function makeCliStore(repo) {
4114
4446
  if (SWARM_BACKEND !== "client") return new SwarmDb(DB_PATH, repo);
4115
4447
  const failing = async () => ({ isError: true });
4116
- const call = await connectSwarmMcp() ?? failing;
4117
- const client = new SwarmClient({ call, outboxPath: SWARM_OUTBOX, defaultRepo: repo });
4448
+ const [call, repoRegistry] = await Promise.all([
4449
+ connectSwarmMcp().then((c) => c ?? failing),
4450
+ buildRepoRegistry(SWARM_REPO_PATHS)
4451
+ ]);
4452
+ const client = new SwarmClient({ call, outboxPath: SWARM_OUTBOX, defaultRepo: repo, repoRegistry });
4118
4453
  await client.hydrate();
4119
4454
  client.reconcileRuntime({ worktreesDir: WORKTREES_DIR, listProcesses: listDroneProcesses });
4120
4455
  return client;