@taskless-app/swarm 0.6.49 → 0.6.50

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 +224 -20
  2. package/dist/overmind.js +411 -37
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1288,6 +1288,10 @@ function pathZoneCandidate(path) {
1288
1288
  if (parts[0] === "src" && parts.length >= 3 && !GENERIC_DIRS.has(parts[1])) return parts[1];
1289
1289
  return parts[0];
1290
1290
  }
1291
+ function dirOf(path) {
1292
+ const i = path.lastIndexOf("/");
1293
+ return i < 0 ? "" : path.slice(0, i);
1294
+ }
1291
1295
  function tokenize(text) {
1292
1296
  const out = [];
1293
1297
  for (const raw of text.split(/[^\p{L}\p{N}]+/u)) {
@@ -1521,13 +1525,61 @@ function similarTasks(model, text, limit = 5) {
1521
1525
  }
1522
1526
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1523
1527
  }
1528
+ function historyFiles(model) {
1529
+ let idx = historyIndex.get(model);
1530
+ if (!idx) {
1531
+ const files = new Set(model.entries.flatMap((e) => e.files));
1532
+ const byBase = /* @__PURE__ */ new Map();
1533
+ for (const f of files) {
1534
+ const base3 = f.slice(f.lastIndexOf("/") + 1);
1535
+ byBase.set(base3, [...byBase.get(base3) ?? [], f]);
1536
+ }
1537
+ idx = { files, byBase };
1538
+ historyIndex.set(model, idx);
1539
+ }
1540
+ return idx;
1541
+ }
1542
+ function namedPaths(model, text, fileExists) {
1543
+ const { files, byBase } = historyFiles(model);
1544
+ const known = (p) => files.has(p) || (fileExists?.(p) ?? false);
1545
+ const raw = [...new Set((text.match(PATH_CANDIDATE) ?? []).map((c) => c.replace(/^\.?\/+/, "").replace(/\.+$/, "")))].filter((c) => c && !c.split("/").includes(".."));
1546
+ raw.sort((a, b) => b.split("/").length - a.split("/").length);
1547
+ const out = [];
1548
+ const dirs = /* @__PURE__ */ new Set();
1549
+ const take = (p) => {
1550
+ if (out.includes(p)) return;
1551
+ out.push(p);
1552
+ dirs.add(dirOf(p));
1553
+ };
1554
+ for (const c of raw) {
1555
+ if (known(c)) {
1556
+ take(c);
1557
+ continue;
1558
+ }
1559
+ const base3 = c.slice(c.lastIndexOf("/") + 1);
1560
+ let matches = (byBase.get(base3) ?? []).filter((f) => f === c || f.endsWith(`/${c}`));
1561
+ if (matches.length > 1) matches = matches.filter((f) => dirs.has(dirOf(f)));
1562
+ if (matches.length === 1) {
1563
+ take(matches[0]);
1564
+ continue;
1565
+ }
1566
+ if (!c.includes("/") && fileExists) {
1567
+ const sibling = [...dirs].map((d) => d ? `${d}/${c}` : c).filter(fileExists);
1568
+ if (sibling.length === 1) take(sibling[0]);
1569
+ }
1570
+ }
1571
+ return out;
1572
+ }
1524
1573
  function predictFiles(model, opts) {
1525
1574
  if (!hasZoneModel(model)) return [];
1526
1575
  const limit = opts.limit ?? 40;
1527
1576
  const best = /* @__PURE__ */ new Map();
1577
+ const named = opts.text ? namedPaths(model, opts.text, opts.fileExists) : [];
1578
+ for (const path of named) best.set(path, { path, weight: 1, why: "\u043D\u0430\u0437\u0432\u0430\u043D \u0432 \u0437\u0430\u0434\u0430\u0447\u0435", named: true });
1579
+ const factor = named.length ? NAMED_BACKGROUND_FACTOR : 1;
1528
1580
  const put = (path, weight, why) => {
1529
1581
  const cur = best.get(path);
1530
- if (!cur || cur.weight < weight) best.set(path, { path, weight, why });
1582
+ if (!cur || cur.weight < weight * factor) best.set(path, { path, weight: weight * factor, why });
1531
1583
  };
1532
1584
  if (opts.text) {
1533
1585
  const sim = similarTasks(model, opts.text, opts.similar ?? 5);
@@ -1564,7 +1616,7 @@ function checkZoneAgreement(model, entries = model.entries) {
1564
1616
  const n = rows.length;
1565
1617
  return { checked: n, matched, compatible, rate: n ? matched / n : 0, looseRate: n ? (matched + compatible) / n : 0, rows };
1566
1618
  }
1567
- var SPINE_ZONE, DEFAULTS, EMPTY_ZONE_MODEL, GENERIC_DIRS, WORKSPACE_DIRS, GENERIC_TEST_DIRS, STOP_TERMS;
1619
+ var SPINE_ZONE, DEFAULTS, EMPTY_ZONE_MODEL, GENERIC_DIRS, WORKSPACE_DIRS, GENERIC_TEST_DIRS, STOP_TERMS, PATH_CANDIDATE, historyIndex, NAMED_BACKGROUND_FACTOR;
1568
1620
  var init_codeZones = __esm({
1569
1621
  "../swarm-core/src/codeZones.ts"() {
1570
1622
  "use strict";
@@ -1643,6 +1695,9 @@ var init_codeZones = __esm({
1643
1695
  "\u043F\u0435\u0440\u0444",
1644
1696
  "wip"
1645
1697
  ]);
1698
+ PATH_CANDIDATE = /[\w@.\-/]+\.[A-Za-z][A-Za-z0-9]{0,5}/g;
1699
+ historyIndex = /* @__PURE__ */ new WeakMap();
1700
+ NAMED_BACKGROUND_FACTOR = 0.1;
1646
1701
  }
1647
1702
  });
1648
1703
 
@@ -3431,7 +3486,7 @@ var init_package = __esm({
3431
3486
  "package.json"() {
3432
3487
  package_default = {
3433
3488
  name: "@taskless-app/swarm",
3434
- version: "0.6.49",
3489
+ version: "0.6.50",
3435
3490
  type: "module",
3436
3491
  description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
3437
3492
  keywords: [
@@ -3482,7 +3537,7 @@ var init_package = __esm({
3482
3537
  execa: "^10.0.0",
3483
3538
  zod: "3.25.76"
3484
3539
  },
3485
- gitHead: "27600667531d4f65761c74793e8c4ee4859dc8ae"
3540
+ gitHead: "859ffe2d91bf4c791af067147515b14a41b96739"
3486
3541
  };
3487
3542
  }
3488
3543
  });
@@ -4817,6 +4872,10 @@ function rowToRun(row) {
4817
4872
  // вычисляемая фаза ревью (TASWM-230)
4818
4873
  review_cycle: row.review_cycle ?? 0,
4819
4874
  // старые прогоны без колонки → 0
4875
+ // TASWM-577: намерение смержить (человек отметил «пора влить»). В sqlite-бэкенде оно
4876
+ // живёт только здесь — ставить его некому, кроме самого демона, зато читает его
4877
+ // реконсайл, и колонка нужна, чтобы отметка пережила рестарт.
4878
+ merge_requested_at: row.merge_requested_at ?? null,
4820
4879
  task_title: row.task_title ?? null,
4821
4880
  task_desc: row.task_desc ?? null,
4822
4881
  // TASWM-556: когда заведена сама задача — по ней проверка дубля отличает свой
@@ -4892,6 +4951,7 @@ CREATE TABLE IF NOT EXISTS runs (
4892
4951
  verifies_run_id INTEGER,
4893
4952
  verify_verdict TEXT,
4894
4953
  review_cycle INTEGER NOT NULL DEFAULT 0,
4954
+ merge_requested_at TEXT,
4895
4955
  task_title TEXT,
4896
4956
  task_desc TEXT,
4897
4957
  task_created_at TEXT,
@@ -4986,6 +5046,8 @@ CREATE TABLE IF NOT EXISTS imports (
4986
5046
  "ALTER TABLE runs ADD COLUMN verify_verdict TEXT",
4987
5047
  // Авто-ревью v2 (TASWM-162): номер цикла ревью — пере-ревью после фикс-итерации.
4988
5048
  "ALTER TABLE runs ADD COLUMN review_cycle INTEGER NOT NULL DEFAULT 0",
5049
+ // TASWM-577: намерение смержить — по нему рой вливает PR сам, по очереди мержа.
5050
+ "ALTER TABLE runs ADD COLUMN merge_requested_at TEXT",
4989
5051
  // Вычисляемая фаза ревью (TASWM-230): человеко-фраза «чего ждёт PR», её считает демон.
4990
5052
  "ALTER TABLE runs ADD COLUMN review_phase TEXT",
4991
5053
  // Приоритетная очередь (TASWM-269): явный приоритет прогона, выше — раньше из queued.
@@ -7317,6 +7379,12 @@ function depsFailureRetriable(code) {
7317
7379
  function runRef(runKey) {
7318
7380
  return { __runRef: runKey };
7319
7381
  }
7382
+ function isoOrNull(v) {
7383
+ if (v == null) return null;
7384
+ const raw = v instanceof Date ? v.toISOString() : typeof v === "string" ? v : "";
7385
+ const at = Date.parse(raw);
7386
+ return Number.isNaN(at) ? null : new Date(at).toISOString();
7387
+ }
7320
7388
  function isRunRef(v) {
7321
7389
  return typeof v === "object" && v !== null && "__runRef" in v;
7322
7390
  }
@@ -7784,6 +7852,17 @@ var SwarmClient = class _SwarmClient {
7784
7852
  runKeyOf(id) {
7785
7853
  return this.runs.get(id)?.runKey ?? null;
7786
7854
  }
7855
+ /**
7856
+ * Cuid прогона В ПРОДЕ — им адресуются прод-РУЧКИ, а не MCP-тулы (TASWM-577: демон
7857
+ * вливает PR тем же `POST /swarm/merge`, что и кнопка, и снимает намерение тем же
7858
+ * `DELETE /swarm/merge-request/:runId`). null — прогон до прода ещё не долетел
7859
+ * (создан этим демоном, флаш не прошёл): адресовать его там нечем, и вызывающий
7860
+ * обязан прочитать это как «сейчас нельзя», а не как ошибку.
7861
+ */
7862
+ prodRunId(id) {
7863
+ const key = this.runs.get(id)?.runKey;
7864
+ return key ? this.prodIdFor(key) ?? null : null;
7865
+ }
7787
7866
  runByKey(key) {
7788
7867
  const direct = [...this.runs.values()].find((r) => r.runKey === key);
7789
7868
  if (direct) return this.mat(direct);
@@ -9330,6 +9409,9 @@ var SwarmClient = class _SwarmClient {
9330
9409
  verify_verdict: null,
9331
9410
  review_phase: null,
9332
9411
  // вычисляемая фаза ревью (TASWM-230) — прод хранит и раздаёт
9412
+ // TASWM-577: намерение смержить — прод-поле (его ставит человек ручкой
9413
+ // /swarm/merge-request), заполняется ниже из ответа.
9414
+ merge_requested_at: null,
9333
9415
  // Локальная бухгалтерия (в прод-схему не уходит) — на гидрации из прода дефолт 0;
9334
9416
  // существующая локальная запись НЕ перетирается (поле не в field-copy ниже),
9335
9417
  // в отличие от respawns, — авто-ревью v2, TASWM-162.
@@ -9365,6 +9447,7 @@ var SwarmClient = class _SwarmClient {
9365
9447
  local.task_title = pr.taskTitle ?? local.task_title;
9366
9448
  local.task_desc = pr.taskDesc ?? local.task_desc;
9367
9449
  local.task_created_at = pr.taskCreatedAt ?? local.task_created_at;
9450
+ if ("mergeRequestedAt" in pr) local.merge_requested_at = isoOrNull(pr.mergeRequestedAt);
9368
9451
  local.name = pr.droneName ?? local.name;
9369
9452
  local.kind = pr.role ?? local.kind;
9370
9453
  const heroProd = pr.heroId ?? null;
@@ -10397,22 +10480,10 @@ function mergeQueueCandidates(runs, repo, repoFallback) {
10397
10480
  }
10398
10481
  var BASE_TTL_MS = 2e4;
10399
10482
  function makeMergeQueueResolver(deps) {
10400
- const memo = /* @__PURE__ */ new Map();
10483
+ const read = deps.headState ?? makeHeadStateReader(deps);
10401
10484
  const evaluate = async (e, prUrl, ready) => {
10402
- const now = Date.now();
10403
- let cached = memo.get(prUrl);
10404
- const ttl = deps.ttlMs ?? BASE_TTL_MS;
10405
- if (!cached || now - cached.at >= ttl) {
10406
- const base3 = await deps.baseSync(prUrl).catch(() => null);
10407
- let checks = "none";
10408
- if (base3?.behindBy === 0 && deps.checks) {
10409
- const ci = await deps.checks(prUrl).catch(() => null);
10410
- checks = ci ? ci.bucket : null;
10411
- }
10412
- cached = { at: now, base: base3, checks };
10413
- memo.set(prUrl, cached);
10414
- }
10415
- return computeMergeQueue({ entry: e, ready, behindBy: cached.base ? cached.base.behindBy : null, checks: cached.checks });
10485
+ const st = await read(prUrl);
10486
+ return computeMergeQueue({ entry: e, ready, behindBy: st.behindBy, checks: st.checks });
10416
10487
  };
10417
10488
  return async (run) => {
10418
10489
  if (!deps.baseSync || !run.pr_url) return null;
@@ -10447,6 +10518,124 @@ function makeMergeQueueResolver(deps) {
10447
10518
  function entryOf(r) {
10448
10519
  return { runId: r.id, prNumber: prNumberOf(r.pr_url), slug: r.task_slug };
10449
10520
  }
10521
+ function makeHeadStateReader(deps) {
10522
+ const memo = /* @__PURE__ */ new Map();
10523
+ return async (prUrl) => {
10524
+ const now = Date.now();
10525
+ const ttl = deps.ttlMs ?? BASE_TTL_MS;
10526
+ const cached = memo.get(prUrl);
10527
+ if (cached && now - cached.at < ttl) return { behindBy: cached.behindBy, checks: cached.checks, headSha: cached.headSha };
10528
+ const base3 = deps.baseSync ? await deps.baseSync(prUrl).catch(() => null) : null;
10529
+ let checks = "none";
10530
+ if (base3?.behindBy === 0 && deps.checks) {
10531
+ const ci = await deps.checks(prUrl).catch(() => null);
10532
+ checks = ci ? ci.bucket : null;
10533
+ }
10534
+ const fresh = { behindBy: base3 ? base3.behindBy : null, checks, headSha: base3?.headSha ?? null };
10535
+ memo.set(prUrl, { at: now, ...fresh });
10536
+ return fresh;
10537
+ };
10538
+ }
10539
+ function hasMergeIntent(r) {
10540
+ return !!(r.merge_requested_at ?? "").trim();
10541
+ }
10542
+ var NO_CI_GRACE_MS = 10 * 6e4;
10543
+ var shortSha = (sha) => sha.slice(0, 7);
10544
+ function makeMergeIntentResolver(deps) {
10545
+ const read = deps.headState ?? makeHeadStateReader(deps);
10546
+ const firstSeen = /* @__PURE__ */ new Map();
10547
+ const ciSettled = async (prUrl, st, now) => {
10548
+ const sha = st.headSha;
10549
+ if (!sha) {
10550
+ return { action: "wait", code: "checks_unknown", reason: "gh \u043D\u0435 \u0441\u043A\u0430\u0437\u0430\u043B HEAD PR \u2014 \u0431\u0435\u0437 \u043D\u0435\u0433\u043E \u043D\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C, \u0434\u043E\u0431\u0435\u0436\u0430\u043B \u043B\u0438 \u043D\u0430 \u043D\u0451\u043C CI. \u0421\u043F\u0440\u043E\u0448\u0443 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C" };
10551
+ }
10552
+ if (!deps.actionsRuns) {
10553
+ return { action: "wait", code: "checks_unknown", reason: "\u0440\u043E\u044E \u043D\u0435\u0447\u0435\u043C \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions \u043D\u0430 HEAD PR \u2014 \u043F\u043E \u043E\u0434\u043D\u043E\u043C\u0443 \u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0443 \u043F\u0440\u043E\u0432\u0435\u0440\u043E\u043A \u0432\u0441\u043B\u0435\u043F\u0443\u044E \u043D\u0435 \u0432\u043B\u0438\u0432\u0430\u044E" };
10554
+ }
10555
+ const runs = await deps.actionsRuns(prUrl, sha).catch(() => null);
10556
+ if (!runs) {
10557
+ return { action: "wait", code: "checks_unknown", reason: `\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043F\u0440\u043E\u0441\u0438\u0442\u044C \u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions \u043D\u0430 ${shortSha(sha)} \u2014 \u0441\u043F\u0440\u043E\u0448\u0443 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C` };
10558
+ }
10559
+ if (runs.failed.length > 0) {
10560
+ return { action: "clear", code: "checks_red", reason: `\u043D\u0430 ${shortSha(sha)} \u0443\u043F\u0430\u043B \u043F\u0440\u043E\u0433\u043E\u043D GitHub Actions: ${runs.failed.join(", ")} \u2014 \u0432\u043B\u0438\u0432\u0430\u0442\u044C \u043D\u0435\u0447\u0435\u0433\u043E, \u043D\u0443\u0436\u043D\u0430 \u0434\u043E\u0440\u0430\u0431\u043E\u0442\u043A\u0430` };
10561
+ }
10562
+ if (runs.active.length > 0) {
10563
+ return {
10564
+ action: "wait",
10565
+ code: "checks_pending",
10566
+ reason: `\u043D\u0430 ${shortSha(sha)} \u0435\u0449\u0451 \u0438\u0434\u0443\u0442 \u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions: ${runs.active.join(", ")} \u2014 \u0432\u0438\u0434\u0438\u043C\u044B\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u043C\u043E\u0433\u0443\u0442 \u0431\u044B\u0442\u044C \u0437\u0435\u043B\u0451\u043D\u044B\u043C\u0438, \u043F\u043E\u043A\u0430 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0430\u044F \u0432\u043E\u043B\u043D\u0430 \u0434\u0436\u043E\u0431 \u043D\u0435 \u0441\u043E\u0437\u0434\u0430\u043D\u0430`
10567
+ };
10568
+ }
10569
+ if (st.checks === "none") {
10570
+ if (runs.total > 0) {
10571
+ return { action: "wait", code: "checks_pending", reason: `\u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions \u043D\u0430 ${shortSha(sha)} \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043B\u0438\u0441\u044C, \u0430 \u043F\u0440\u043E\u0432\u0435\u0440\u043E\u043A \u0443 PR \u0435\u0449\u0451 \u043D\u0435 \u0432\u0438\u0434\u043D\u043E \u2014 \u0436\u0434\u0443, \u043F\u043E\u043A\u0430 GitHub \u0438\u0445 \u043F\u043E\u043A\u0430\u0436\u0435\u0442` };
10572
+ }
10573
+ const seen = firstSeen.get(prUrl);
10574
+ const waited = seen && seen.sha === sha ? now - seen.at : 0;
10575
+ const graceMin = NO_CI_GRACE_MS / 6e4;
10576
+ if (waited < NO_CI_GRACE_MS) {
10577
+ return {
10578
+ action: "wait",
10579
+ code: "checks_pending",
10580
+ reason: `\u043D\u0430 ${shortSha(sha)} \u043F\u043E\u043A\u0430 \u043D\u0438 \u043E\u0434\u043D\u043E\u0439 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u0438 \u043D\u0438 \u043E\u0434\u043D\u043E\u0433\u043E \u043F\u0440\u043E\u0433\u043E\u043D\u0430 GitHub Actions \u2014 CI \u043C\u043E\u0433 \u0435\u0449\u0451 \u043D\u0435 \u0437\u0430\u0432\u0435\u0441\u0442\u0438 \u0434\u0436\u043E\u0431\u044B \u043F\u043E\u0441\u043B\u0435 \u043F\u0443\u0448\u0430. \u0416\u0434\u0443 \u0434\u043E ${graceMin} \u043C\u0438\u043D (\u043F\u0440\u043E\u0448\u043B\u043E ${Math.floor(waited / 6e4)})`
10581
+ };
10582
+ }
10583
+ return { action: "merge", code: "queue_head", reason: `\u0435\u0433\u043E \u0447\u0435\u0440\u0451\u0434 \u0438 \u0431\u0430\u0437\u0430 \u0441\u0432\u0435\u0436\u0430\u044F, \u0430 ${shortSha(sha)} ${graceMin} \u043C\u0438\u043D \u043F\u0440\u043E\u0441\u0442\u043E\u044F\u043B \u0431\u0435\u0437 \u0435\u0434\u0438\u043D\u043E\u0439 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u0438 \u043F\u0440\u043E\u0433\u043E\u043D\u0430 GitHub Actions \u2014 CI \u0443 \u0440\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u044F \u043D\u0435\u0442` };
10584
+ }
10585
+ return { action: "merge", code: "queue_head", reason: `\u0435\u0433\u043E \u0447\u0435\u0440\u0451\u0434, \u0431\u0430\u0437\u0430 \u0441\u0432\u0435\u0436\u0430\u044F, \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u043D\u0430 ${shortSha(sha)} \u0437\u0435\u043B\u0451\u043D\u044B\u0435 \u0438 \u0432\u0441\u0435 \u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions \u043D\u0430 \u043D\u0451\u043C \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u044B` };
10586
+ };
10587
+ const confirmLive = async (prUrl, sha, verdict) => {
10588
+ if (!sha) {
10589
+ return { action: "wait", code: "checks_unknown", reason: "gh \u043D\u0435 \u0441\u043A\u0430\u0437\u0430\u043B HEAD PR \u2014 \u043D\u0435 \u043F\u043E\u043D\u044F\u0442\u044C, \u043F\u0440\u043E \u043A\u0430\u043A\u043E\u0439 \u043A\u043E\u043C\u043C\u0438\u0442 \u044D\u0442\u043E\u0442 \u0432\u0435\u0440\u0434\u0438\u043A\u0442; \u0441\u043F\u0440\u043E\u0448\u0443 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C" };
10590
+ }
10591
+ const live = deps.baseSync ? await deps.baseSync(prUrl).catch(() => null) : null;
10592
+ if (!live || !live.headSha) {
10593
+ return { action: "wait", code: "base_unknown", reason: "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0435\u0440\u0435\u0441\u043F\u0440\u043E\u0441\u0438\u0442\u044C \u0436\u0438\u0432\u043E\u0439 HEAD PR \u2014 \u0441\u043F\u0440\u043E\u0448\u0443 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C" };
10594
+ }
10595
+ if (verdict.action === "merge" && live.behindBy > 0) {
10596
+ return {
10597
+ action: "refresh_base",
10598
+ code: "base_stale",
10599
+ reason: `\u0431\u0430\u0437\u0430 \u0443\u0448\u043B\u0430 \u0432\u043F\u0435\u0440\u0451\u0434 \u043D\u0430 ${live.behindBy} \u043A\u043E\u043C\u043C\u0438\u0442(\u043E\u0432) \u043F\u043E\u0441\u043B\u0435 \u043F\u0440\u0435\u0434\u044B\u0434\u0443\u0449\u0435\u0433\u043E \u043C\u0435\u0440\u0436\u0430 \u2014 \u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043B\u0438\u0442\u044C main \u0438 \u0434\u043E\u0436\u0434\u0430\u0442\u044C\u0441\u044F CI \u043D\u0430 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D\u043D\u043E\u0439 \u0431\u0430\u0437\u0435`
10600
+ };
10601
+ }
10602
+ if (live.headSha !== sha) {
10603
+ return { action: "wait", code: "checks_pending", reason: `\u043D\u0430 PR \u043D\u043E\u0432\u044B\u0439 \u043A\u043E\u043C\u043C\u0438\u0442 ${shortSha(live.headSha)} \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044F\u044E \u0437\u0430\u043D\u043E\u0432\u043E` };
10604
+ }
10605
+ return verdict;
10606
+ };
10607
+ return async (run) => {
10608
+ if (!deps.baseSync || !run.pr_url || !hasMergeIntent(run)) return null;
10609
+ const num = prNumberOf(run.pr_url);
10610
+ if (num == null) return null;
10611
+ const marked = mergeQueueCandidates(deps.listRuns(), effectiveRepoOf(run.repo, deps.repoFallback), deps.repoFallback).filter(hasMergeIntent);
10612
+ const mine = marked.find((c) => prNumberOf(c.pr_url) === num);
10613
+ if (!mine) return null;
10614
+ const queue = orderMergeQueue(marked.map(entryOf));
10615
+ const entry = entryOf(mine);
10616
+ const position = queue.findIndex((e) => e.runId === entry.runId) + 1;
10617
+ const head = queue[0];
10618
+ if (head.runId !== entry.runId) {
10619
+ return {
10620
+ action: "wait",
10621
+ code: "queue_wait",
10622
+ reason: `\u0440\u043E\u0439 \u0432\u043B\u0438\u0432\u0430\u0435\u0442 \u043F\u043E \u043E\u0434\u043D\u043E\u043C\u0443: \u0441\u0435\u0439\u0447\u0430\u0441 \u043E\u0447\u0435\u0440\u0435\u0434\u044C PR #${head.prNumber}${head.slug ? ` (${head.slug})` : ""}, \u044D\u0442\u043E\u0442 \u2014 ${position}-\u0439 \u0438\u0437 ${queue.length}`,
10623
+ position,
10624
+ size: queue.length
10625
+ };
10626
+ }
10627
+ const st = await read(mine.pr_url);
10628
+ const now = deps.now ? deps.now() : Date.now();
10629
+ if (st.headSha) {
10630
+ const seen = firstSeen.get(mine.pr_url);
10631
+ if (!seen || seen.sha !== st.headSha) firstSeen.set(mine.pr_url, { sha: st.headSha, at: now });
10632
+ }
10633
+ const d = computeMergeQueue({ entry, ready: queue, behindBy: st.behindBy, checks: st.checks });
10634
+ const verdict = d.code === "queue_head" ? await ciSettled(mine.pr_url, st, now) : { action: d.code === "base_stale" ? "refresh_base" : d.code === "checks_red" ? "clear" : "wait", code: d.code, reason: d.reason };
10635
+ const final = verdict.action === "merge" || verdict.action === "clear" ? await confirmLive(mine.pr_url, st.headSha, verdict) : verdict;
10636
+ return { ...final, position, size: queue.length, ...st.headSha ? { headSha: st.headSha } : {} };
10637
+ };
10638
+ }
10450
10639
 
10451
10640
  // src/mergeGate.ts
10452
10641
  function findRunByPr(runs, ref, scope = {}) {
@@ -10475,9 +10664,15 @@ function makeMergeGateParts(deps) {
10475
10664
  });
10476
10665
  };
10477
10666
  const repoOfRun = deps.repoFullNameOf ? (r) => deps.repoFullNameOf(effectiveRepoOf(r.repo, deps.repoFallback)) : void 0;
10667
+ const headState = makeHeadStateReader({
10668
+ ...deps.baseSync ? { baseSync: deps.baseSync } : {},
10669
+ ...deps.checks ? { checks: deps.checks } : {},
10670
+ ...deps.ttlMs != null ? { ttlMs: deps.ttlMs } : {}
10671
+ });
10478
10672
  const queueFor = makeMergeQueueResolver({
10479
10673
  listRuns: deps.listRuns,
10480
10674
  gateAllows: async (r) => (await gateFor(r)).allowed,
10675
+ headState,
10481
10676
  ...deps.repoFallback ? { repoFallback: deps.repoFallback } : {},
10482
10677
  ...deps.baseSync ? { baseSync: deps.baseSync } : {},
10483
10678
  ...deps.checks ? { checks: deps.checks } : {},
@@ -10503,7 +10698,16 @@ function makeMergeGateParts(deps) {
10503
10698
  }
10504
10699
  };
10505
10700
  };
10506
- return { gate, queue: queueFor };
10701
+ const intentFor = makeMergeIntentResolver({
10702
+ listRuns: deps.listRuns,
10703
+ headState,
10704
+ ...deps.repoFallback ? { repoFallback: deps.repoFallback } : {},
10705
+ ...deps.baseSync ? { baseSync: deps.baseSync } : {},
10706
+ ...deps.checks ? { checks: deps.checks } : {},
10707
+ ...deps.actionsRuns ? { actionsRuns: deps.actionsRuns } : {},
10708
+ ...deps.ttlMs != null ? { ttlMs: deps.ttlMs } : {}
10709
+ });
10710
+ return { gate, queue: queueFor, intent: intentFor };
10507
10711
  }
10508
10712
 
10509
10713
  // src/web.ts
package/dist/overmind.js CHANGED
@@ -1723,13 +1723,61 @@ function similarTasks(model, text, limit = 5) {
1723
1723
  }
1724
1724
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1725
1725
  }
1726
+ function historyFiles(model) {
1727
+ let idx = historyIndex.get(model);
1728
+ if (!idx) {
1729
+ const files = new Set(model.entries.flatMap((e) => e.files));
1730
+ const byBase = /* @__PURE__ */ new Map();
1731
+ for (const f of files) {
1732
+ const base3 = f.slice(f.lastIndexOf("/") + 1);
1733
+ byBase.set(base3, [...byBase.get(base3) ?? [], f]);
1734
+ }
1735
+ idx = { files, byBase };
1736
+ historyIndex.set(model, idx);
1737
+ }
1738
+ return idx;
1739
+ }
1740
+ function namedPaths(model, text, fileExists) {
1741
+ const { files, byBase } = historyFiles(model);
1742
+ const known = (p) => files.has(p) || (fileExists?.(p) ?? false);
1743
+ const raw = [...new Set((text.match(PATH_CANDIDATE) ?? []).map((c) => c.replace(/^\.?\/+/, "").replace(/\.+$/, "")))].filter((c) => c && !c.split("/").includes(".."));
1744
+ raw.sort((a, b) => b.split("/").length - a.split("/").length);
1745
+ const out = [];
1746
+ const dirs = /* @__PURE__ */ new Set();
1747
+ const take = (p) => {
1748
+ if (out.includes(p)) return;
1749
+ out.push(p);
1750
+ dirs.add(dirOf(p));
1751
+ };
1752
+ for (const c of raw) {
1753
+ if (known(c)) {
1754
+ take(c);
1755
+ continue;
1756
+ }
1757
+ const base3 = c.slice(c.lastIndexOf("/") + 1);
1758
+ let matches = (byBase.get(base3) ?? []).filter((f) => f === c || f.endsWith(`/${c}`));
1759
+ if (matches.length > 1) matches = matches.filter((f) => dirs.has(dirOf(f)));
1760
+ if (matches.length === 1) {
1761
+ take(matches[0]);
1762
+ continue;
1763
+ }
1764
+ if (!c.includes("/") && fileExists) {
1765
+ const sibling = [...dirs].map((d) => d ? `${d}/${c}` : c).filter(fileExists);
1766
+ if (sibling.length === 1) take(sibling[0]);
1767
+ }
1768
+ }
1769
+ return out;
1770
+ }
1726
1771
  function predictFiles(model, opts) {
1727
1772
  if (!hasZoneModel(model)) return [];
1728
1773
  const limit = opts.limit ?? 40;
1729
1774
  const best = /* @__PURE__ */ new Map();
1775
+ const named = opts.text ? namedPaths(model, opts.text, opts.fileExists) : [];
1776
+ for (const path of named) best.set(path, { path, weight: 1, why: "\u043D\u0430\u0437\u0432\u0430\u043D \u0432 \u0437\u0430\u0434\u0430\u0447\u0435", named: true });
1777
+ const factor = named.length ? NAMED_BACKGROUND_FACTOR : 1;
1730
1778
  const put = (path, weight, why) => {
1731
1779
  const cur = best.get(path);
1732
- if (!cur || cur.weight < weight) best.set(path, { path, weight, why });
1780
+ if (!cur || cur.weight < weight * factor) best.set(path, { path, weight: weight * factor, why });
1733
1781
  };
1734
1782
  if (opts.text) {
1735
1783
  const sim = similarTasks(model, opts.text, opts.similar ?? 5);
@@ -1746,12 +1794,18 @@ function predictFiles(model, opts) {
1746
1794
  }
1747
1795
  return [...best.values()].sort((a, b) => b.weight - a.weight || a.path.localeCompare(b.path)).slice(0, limit);
1748
1796
  }
1797
+ function heldByPrediction(predicted) {
1798
+ const named = predicted.filter((p) => p.named);
1799
+ return (named.length ? named : predicted).map((p) => p.path);
1800
+ }
1749
1801
  function overlapRisk(predicted, actual, opts = {}) {
1750
1802
  const o = { ...OVERLAP_DEFAULTS, ...opts };
1751
1803
  const actualSet = new Set(actual);
1752
1804
  const actualDirs = new Set([...actualSet].map(dirOf).filter(Boolean));
1753
1805
  const totalWeight = predicted.reduce((a, p) => a + p.weight, 0);
1754
1806
  if (!totalWeight) return { overlap: false, score: 0, sharedFiles: [], sharedDirs: [] };
1807
+ const hasNamed = predicted.some((p) => p.named);
1808
+ let namedHit = false;
1755
1809
  let hit = 0;
1756
1810
  let strong = false;
1757
1811
  const sharedFiles = [];
@@ -1760,24 +1814,26 @@ function overlapRisk(predicted, actual, opts = {}) {
1760
1814
  if (actualSet.has(p.path)) {
1761
1815
  hit += p.weight;
1762
1816
  sharedFiles.push(p.path);
1763
- if (p.weight >= o.strongFile) strong = true;
1817
+ if (p.named) namedHit = true;
1818
+ if (p.weight >= o.strongFile && (p.named || !hasNamed)) strong = true;
1764
1819
  continue;
1765
1820
  }
1766
1821
  const d = dirOf(p.path);
1767
1822
  if (d && actualDirs.has(d)) {
1768
1823
  hit += p.weight * 0.25;
1769
1824
  sharedDirs.add(d);
1825
+ if (p.named) namedHit = true;
1770
1826
  }
1771
1827
  }
1772
1828
  const score = Math.min(1, hit / totalWeight);
1773
1829
  return {
1774
- overlap: strong || score >= o.threshold,
1830
+ overlap: strong || score >= o.threshold && (!hasNamed || namedHit),
1775
1831
  score,
1776
1832
  sharedFiles: sharedFiles.slice(0, 10),
1777
1833
  sharedDirs: [...sharedDirs].slice(0, 5)
1778
1834
  };
1779
1835
  }
1780
- var SPINE_ZONE, DEFAULTS, EMPTY_ZONE_MODEL, GENERIC_DIRS, WORKSPACE_DIRS, GENERIC_TEST_DIRS, STOP_TERMS, OVERLAP_DEFAULTS;
1836
+ var SPINE_ZONE, DEFAULTS, EMPTY_ZONE_MODEL, GENERIC_DIRS, WORKSPACE_DIRS, GENERIC_TEST_DIRS, STOP_TERMS, PATH_CANDIDATE, historyIndex, NAMED_BACKGROUND_FACTOR, OVERLAP_DEFAULTS;
1781
1837
  var init_codeZones = __esm({
1782
1838
  "../swarm-core/src/codeZones.ts"() {
1783
1839
  "use strict";
@@ -1856,6 +1912,9 @@ var init_codeZones = __esm({
1856
1912
  "\u043F\u0435\u0440\u0444",
1857
1913
  "wip"
1858
1914
  ]);
1915
+ PATH_CANDIDATE = /[\w@.\-/]+\.[A-Za-z][A-Za-z0-9]{0,5}/g;
1916
+ historyIndex = /* @__PURE__ */ new WeakMap();
1917
+ NAMED_BACKGROUND_FACTOR = 0.1;
1859
1918
  OVERLAP_DEFAULTS = { threshold: 0.2, strongFile: 0.35 };
1860
1919
  }
1861
1920
  });
@@ -6253,6 +6312,10 @@ function rowToRun(row) {
6253
6312
  // вычисляемая фаза ревью (TASWM-230)
6254
6313
  review_cycle: row.review_cycle ?? 0,
6255
6314
  // старые прогоны без колонки → 0
6315
+ // TASWM-577: намерение смержить (человек отметил «пора влить»). В sqlite-бэкенде оно
6316
+ // живёт только здесь — ставить его некому, кроме самого демона, зато читает его
6317
+ // реконсайл, и колонка нужна, чтобы отметка пережила рестарт.
6318
+ merge_requested_at: row.merge_requested_at ?? null,
6256
6319
  task_title: row.task_title ?? null,
6257
6320
  task_desc: row.task_desc ?? null,
6258
6321
  // TASWM-556: когда заведена сама задача — по ней проверка дубля отличает свой
@@ -6328,6 +6391,7 @@ CREATE TABLE IF NOT EXISTS runs (
6328
6391
  verifies_run_id INTEGER,
6329
6392
  verify_verdict TEXT,
6330
6393
  review_cycle INTEGER NOT NULL DEFAULT 0,
6394
+ merge_requested_at TEXT,
6331
6395
  task_title TEXT,
6332
6396
  task_desc TEXT,
6333
6397
  task_created_at TEXT,
@@ -6422,6 +6486,8 @@ CREATE TABLE IF NOT EXISTS imports (
6422
6486
  "ALTER TABLE runs ADD COLUMN verify_verdict TEXT",
6423
6487
  // Авто-ревью v2 (TASWM-162): номер цикла ревью — пере-ревью после фикс-итерации.
6424
6488
  "ALTER TABLE runs ADD COLUMN review_cycle INTEGER NOT NULL DEFAULT 0",
6489
+ // TASWM-577: намерение смержить — по нему рой вливает PR сам, по очереди мержа.
6490
+ "ALTER TABLE runs ADD COLUMN merge_requested_at TEXT",
6425
6491
  // Вычисляемая фаза ревью (TASWM-230): человеко-фраза «чего ждёт PR», её считает демон.
6426
6492
  "ALTER TABLE runs ADD COLUMN review_phase TEXT",
6427
6493
  // Приоритетная очередь (TASWM-269): явный приоритет прогона, выше — раньше из queued.
@@ -7207,7 +7273,7 @@ var init_droneStore = __esm({
7207
7273
 
7208
7274
  // src/overmind.ts
7209
7275
  import { homedir as homedir7 } from "os";
7210
- import { existsSync as existsSync20, readFileSync as readFileSync25, readdirSync as readdirSync14, renameSync as renameSync12 } from "fs";
7276
+ import { existsSync as existsSync20, readFileSync as readFileSync25, readdirSync as readdirSync14, renameSync as renameSync12, statSync as statSync13 } from "fs";
7211
7277
  import { basename as basename6, dirname as dirname5, join as join34 } from "path";
7212
7278
  import { fileURLToPath as fileURLToPath3, pathToFileURL } from "url";
7213
7279
  import { spawn as spawn4 } from "child_process";
@@ -7555,6 +7621,12 @@ function depsFailureRetriable(code) {
7555
7621
  function runRef(runKey) {
7556
7622
  return { __runRef: runKey };
7557
7623
  }
7624
+ function isoOrNull(v) {
7625
+ if (v == null) return null;
7626
+ const raw = v instanceof Date ? v.toISOString() : typeof v === "string" ? v : "";
7627
+ const at = Date.parse(raw);
7628
+ return Number.isNaN(at) ? null : new Date(at).toISOString();
7629
+ }
7558
7630
  function isRunRef(v) {
7559
7631
  return typeof v === "object" && v !== null && "__runRef" in v;
7560
7632
  }
@@ -8022,6 +8094,17 @@ var SwarmClient = class _SwarmClient {
8022
8094
  runKeyOf(id) {
8023
8095
  return this.runs.get(id)?.runKey ?? null;
8024
8096
  }
8097
+ /**
8098
+ * Cuid прогона В ПРОДЕ — им адресуются прод-РУЧКИ, а не MCP-тулы (TASWM-577: демон
8099
+ * вливает PR тем же `POST /swarm/merge`, что и кнопка, и снимает намерение тем же
8100
+ * `DELETE /swarm/merge-request/:runId`). null — прогон до прода ещё не долетел
8101
+ * (создан этим демоном, флаш не прошёл): адресовать его там нечем, и вызывающий
8102
+ * обязан прочитать это как «сейчас нельзя», а не как ошибку.
8103
+ */
8104
+ prodRunId(id) {
8105
+ const key = this.runs.get(id)?.runKey;
8106
+ return key ? this.prodIdFor(key) ?? null : null;
8107
+ }
8025
8108
  runByKey(key) {
8026
8109
  const direct = [...this.runs.values()].find((r) => r.runKey === key);
8027
8110
  if (direct) return this.mat(direct);
@@ -9568,6 +9651,9 @@ var SwarmClient = class _SwarmClient {
9568
9651
  verify_verdict: null,
9569
9652
  review_phase: null,
9570
9653
  // вычисляемая фаза ревью (TASWM-230) — прод хранит и раздаёт
9654
+ // TASWM-577: намерение смержить — прод-поле (его ставит человек ручкой
9655
+ // /swarm/merge-request), заполняется ниже из ответа.
9656
+ merge_requested_at: null,
9571
9657
  // Локальная бухгалтерия (в прод-схему не уходит) — на гидрации из прода дефолт 0;
9572
9658
  // существующая локальная запись НЕ перетирается (поле не в field-copy ниже),
9573
9659
  // в отличие от respawns, — авто-ревью v2, TASWM-162.
@@ -9603,6 +9689,7 @@ var SwarmClient = class _SwarmClient {
9603
9689
  local.task_title = pr.taskTitle ?? local.task_title;
9604
9690
  local.task_desc = pr.taskDesc ?? local.task_desc;
9605
9691
  local.task_created_at = pr.taskCreatedAt ?? local.task_created_at;
9692
+ if ("mergeRequestedAt" in pr) local.merge_requested_at = isoOrNull(pr.mergeRequestedAt);
9606
9693
  local.name = pr.droneName ?? local.name;
9607
9694
  local.kind = pr.role ?? local.kind;
9608
9695
  const heroProd = pr.heroId ?? null;
@@ -12393,7 +12480,7 @@ function parkReasonUnretriable(reason) {
12393
12480
  // package.json
12394
12481
  var package_default = {
12395
12482
  name: "@taskless-app/swarm",
12396
- version: "0.6.49",
12483
+ version: "0.6.50",
12397
12484
  type: "module",
12398
12485
  description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
12399
12486
  keywords: [
@@ -12444,7 +12531,7 @@ var package_default = {
12444
12531
  execa: "^10.0.0",
12445
12532
  zod: "3.25.76"
12446
12533
  },
12447
- gitHead: "27600667531d4f65761c74793e8c4ee4859dc8ae"
12534
+ gitHead: "859ffe2d91bf4c791af067147515b14a41b96739"
12448
12535
  };
12449
12536
 
12450
12537
  // src/version.ts
@@ -12762,18 +12849,48 @@ async function prHeadBranch(prUrl, run = realRunner) {
12762
12849
  return null;
12763
12850
  }
12764
12851
  }
12765
- async function prBaseSync(prUrl, run = realRunner) {
12852
+ function prRepoPath(prUrl) {
12766
12853
  const m = prUrl.trim().match(/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/i);
12767
- if (!isGithubPrUrl(prUrl) || !m) return null;
12768
- const [, owner, repo] = m;
12854
+ return isGithubPrUrl(prUrl) && m ? `${m[1]}/${m[2]}` : null;
12855
+ }
12856
+ async function prBaseSync(prUrl, run = realRunner) {
12857
+ const repoPath = prRepoPath(prUrl);
12858
+ if (!repoPath) return null;
12769
12859
  try {
12770
12860
  const j = JSON.parse(await run(["pr", "view", prUrl, "--json", "baseRefName,headRefOid"]));
12771
12861
  const base3 = j.baseRefName;
12772
12862
  const head = j.headRefOid;
12773
12863
  if (!base3 || !head) return null;
12774
- const out = await run(["api", `repos/${owner}/${repo}/compare/${base3}...${head}?per_page=1`, "--jq", ".behind_by"]);
12864
+ const out = await run(["api", `repos/${repoPath}/compare/${base3}...${head}?per_page=1`, "--jq", ".behind_by"]);
12775
12865
  const behindBy = Number(out.trim());
12776
- return Number.isFinite(behindBy) && behindBy >= 0 ? { behindBy, base: base3 } : null;
12866
+ return Number.isFinite(behindBy) && behindBy >= 0 ? { behindBy, base: base3, headSha: head } : null;
12867
+ } catch {
12868
+ return null;
12869
+ }
12870
+ }
12871
+ var ACTIONS_RUN_OK = /* @__PURE__ */ new Set(["success", "skipped", "neutral"]);
12872
+ async function prActionsRuns(prUrl, headSha2, run = realRunner) {
12873
+ const repoPath = prRepoPath(prUrl);
12874
+ if (!repoPath || !/^[0-9a-f]{7,40}$/i.test(headSha2)) return null;
12875
+ try {
12876
+ const j = JSON.parse(
12877
+ await run(["api", `repos/${repoPath}/actions/runs?head_sha=${headSha2}&per_page=100&exclude_pull_requests=true`])
12878
+ );
12879
+ const runs = j.workflow_runs;
12880
+ if (!Array.isArray(runs)) return null;
12881
+ if (typeof j.total_count === "number" && j.total_count > runs.length) return null;
12882
+ const latest = /* @__PURE__ */ new Map();
12883
+ for (const r of runs) {
12884
+ const key = `${r.workflow_id ?? r.name ?? "?"}:${r.event ?? "?"}`;
12885
+ const seen = latest.get(key);
12886
+ if (!seen || (r.id ?? 0) > (seen.id ?? 0)) latest.set(key, r);
12887
+ }
12888
+ const label2 = (r, what) => `${r.name || "workflow"} (${what || "?"})`;
12889
+ return {
12890
+ total: runs.length,
12891
+ active: runs.filter((r) => r.status !== "completed").map((r) => label2(r, r.status)),
12892
+ failed: [...latest.values()].filter((r) => r.status === "completed" && !ACTIONS_RUN_OK.has(r.conclusion ?? "")).map((r) => label2(r, r.conclusion))
12893
+ };
12777
12894
  } catch {
12778
12895
  return null;
12779
12896
  }
@@ -15337,22 +15454,10 @@ function mergeQueueCandidates(runs, repo, repoFallback) {
15337
15454
  }
15338
15455
  var BASE_TTL_MS = 2e4;
15339
15456
  function makeMergeQueueResolver(deps) {
15340
- const memo = /* @__PURE__ */ new Map();
15457
+ const read = deps.headState ?? makeHeadStateReader(deps);
15341
15458
  const evaluate = async (e, prUrl, ready) => {
15342
- const now = Date.now();
15343
- let cached = memo.get(prUrl);
15344
- const ttl = deps.ttlMs ?? BASE_TTL_MS;
15345
- if (!cached || now - cached.at >= ttl) {
15346
- const base3 = await deps.baseSync(prUrl).catch(() => null);
15347
- let checks = "none";
15348
- if (base3?.behindBy === 0 && deps.checks) {
15349
- const ci = await deps.checks(prUrl).catch(() => null);
15350
- checks = ci ? ci.bucket : null;
15351
- }
15352
- cached = { at: now, base: base3, checks };
15353
- memo.set(prUrl, cached);
15354
- }
15355
- return computeMergeQueue({ entry: e, ready, behindBy: cached.base ? cached.base.behindBy : null, checks: cached.checks });
15459
+ const st = await read(prUrl);
15460
+ return computeMergeQueue({ entry: e, ready, behindBy: st.behindBy, checks: st.checks });
15356
15461
  };
15357
15462
  return async (run) => {
15358
15463
  if (!deps.baseSync || !run.pr_url) return null;
@@ -15387,6 +15492,124 @@ function makeMergeQueueResolver(deps) {
15387
15492
  function entryOf(r) {
15388
15493
  return { runId: r.id, prNumber: prNumberOf(r.pr_url), slug: r.task_slug };
15389
15494
  }
15495
+ function makeHeadStateReader(deps) {
15496
+ const memo = /* @__PURE__ */ new Map();
15497
+ return async (prUrl) => {
15498
+ const now = Date.now();
15499
+ const ttl = deps.ttlMs ?? BASE_TTL_MS;
15500
+ const cached = memo.get(prUrl);
15501
+ if (cached && now - cached.at < ttl) return { behindBy: cached.behindBy, checks: cached.checks, headSha: cached.headSha };
15502
+ const base3 = deps.baseSync ? await deps.baseSync(prUrl).catch(() => null) : null;
15503
+ let checks = "none";
15504
+ if (base3?.behindBy === 0 && deps.checks) {
15505
+ const ci = await deps.checks(prUrl).catch(() => null);
15506
+ checks = ci ? ci.bucket : null;
15507
+ }
15508
+ const fresh = { behindBy: base3 ? base3.behindBy : null, checks, headSha: base3?.headSha ?? null };
15509
+ memo.set(prUrl, { at: now, ...fresh });
15510
+ return fresh;
15511
+ };
15512
+ }
15513
+ function hasMergeIntent(r) {
15514
+ return !!(r.merge_requested_at ?? "").trim();
15515
+ }
15516
+ var NO_CI_GRACE_MS = 10 * 6e4;
15517
+ var shortSha = (sha) => sha.slice(0, 7);
15518
+ function makeMergeIntentResolver(deps) {
15519
+ const read = deps.headState ?? makeHeadStateReader(deps);
15520
+ const firstSeen = /* @__PURE__ */ new Map();
15521
+ const ciSettled = async (prUrl, st, now) => {
15522
+ const sha = st.headSha;
15523
+ if (!sha) {
15524
+ return { action: "wait", code: "checks_unknown", reason: "gh \u043D\u0435 \u0441\u043A\u0430\u0437\u0430\u043B HEAD PR \u2014 \u0431\u0435\u0437 \u043D\u0435\u0433\u043E \u043D\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C, \u0434\u043E\u0431\u0435\u0436\u0430\u043B \u043B\u0438 \u043D\u0430 \u043D\u0451\u043C CI. \u0421\u043F\u0440\u043E\u0448\u0443 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C" };
15525
+ }
15526
+ if (!deps.actionsRuns) {
15527
+ return { action: "wait", code: "checks_unknown", reason: "\u0440\u043E\u044E \u043D\u0435\u0447\u0435\u043C \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions \u043D\u0430 HEAD PR \u2014 \u043F\u043E \u043E\u0434\u043D\u043E\u043C\u0443 \u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0443 \u043F\u0440\u043E\u0432\u0435\u0440\u043E\u043A \u0432\u0441\u043B\u0435\u043F\u0443\u044E \u043D\u0435 \u0432\u043B\u0438\u0432\u0430\u044E" };
15528
+ }
15529
+ const runs = await deps.actionsRuns(prUrl, sha).catch(() => null);
15530
+ if (!runs) {
15531
+ return { action: "wait", code: "checks_unknown", reason: `\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043F\u0440\u043E\u0441\u0438\u0442\u044C \u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions \u043D\u0430 ${shortSha(sha)} \u2014 \u0441\u043F\u0440\u043E\u0448\u0443 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C` };
15532
+ }
15533
+ if (runs.failed.length > 0) {
15534
+ return { action: "clear", code: "checks_red", reason: `\u043D\u0430 ${shortSha(sha)} \u0443\u043F\u0430\u043B \u043F\u0440\u043E\u0433\u043E\u043D GitHub Actions: ${runs.failed.join(", ")} \u2014 \u0432\u043B\u0438\u0432\u0430\u0442\u044C \u043D\u0435\u0447\u0435\u0433\u043E, \u043D\u0443\u0436\u043D\u0430 \u0434\u043E\u0440\u0430\u0431\u043E\u0442\u043A\u0430` };
15535
+ }
15536
+ if (runs.active.length > 0) {
15537
+ return {
15538
+ action: "wait",
15539
+ code: "checks_pending",
15540
+ reason: `\u043D\u0430 ${shortSha(sha)} \u0435\u0449\u0451 \u0438\u0434\u0443\u0442 \u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions: ${runs.active.join(", ")} \u2014 \u0432\u0438\u0434\u0438\u043C\u044B\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u043C\u043E\u0433\u0443\u0442 \u0431\u044B\u0442\u044C \u0437\u0435\u043B\u0451\u043D\u044B\u043C\u0438, \u043F\u043E\u043A\u0430 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0430\u044F \u0432\u043E\u043B\u043D\u0430 \u0434\u0436\u043E\u0431 \u043D\u0435 \u0441\u043E\u0437\u0434\u0430\u043D\u0430`
15541
+ };
15542
+ }
15543
+ if (st.checks === "none") {
15544
+ if (runs.total > 0) {
15545
+ return { action: "wait", code: "checks_pending", reason: `\u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions \u043D\u0430 ${shortSha(sha)} \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043B\u0438\u0441\u044C, \u0430 \u043F\u0440\u043E\u0432\u0435\u0440\u043E\u043A \u0443 PR \u0435\u0449\u0451 \u043D\u0435 \u0432\u0438\u0434\u043D\u043E \u2014 \u0436\u0434\u0443, \u043F\u043E\u043A\u0430 GitHub \u0438\u0445 \u043F\u043E\u043A\u0430\u0436\u0435\u0442` };
15546
+ }
15547
+ const seen = firstSeen.get(prUrl);
15548
+ const waited = seen && seen.sha === sha ? now - seen.at : 0;
15549
+ const graceMin = NO_CI_GRACE_MS / 6e4;
15550
+ if (waited < NO_CI_GRACE_MS) {
15551
+ return {
15552
+ action: "wait",
15553
+ code: "checks_pending",
15554
+ reason: `\u043D\u0430 ${shortSha(sha)} \u043F\u043E\u043A\u0430 \u043D\u0438 \u043E\u0434\u043D\u043E\u0439 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u0438 \u043D\u0438 \u043E\u0434\u043D\u043E\u0433\u043E \u043F\u0440\u043E\u0433\u043E\u043D\u0430 GitHub Actions \u2014 CI \u043C\u043E\u0433 \u0435\u0449\u0451 \u043D\u0435 \u0437\u0430\u0432\u0435\u0441\u0442\u0438 \u0434\u0436\u043E\u0431\u044B \u043F\u043E\u0441\u043B\u0435 \u043F\u0443\u0448\u0430. \u0416\u0434\u0443 \u0434\u043E ${graceMin} \u043C\u0438\u043D (\u043F\u0440\u043E\u0448\u043B\u043E ${Math.floor(waited / 6e4)})`
15555
+ };
15556
+ }
15557
+ return { action: "merge", code: "queue_head", reason: `\u0435\u0433\u043E \u0447\u0435\u0440\u0451\u0434 \u0438 \u0431\u0430\u0437\u0430 \u0441\u0432\u0435\u0436\u0430\u044F, \u0430 ${shortSha(sha)} ${graceMin} \u043C\u0438\u043D \u043F\u0440\u043E\u0441\u0442\u043E\u044F\u043B \u0431\u0435\u0437 \u0435\u0434\u0438\u043D\u043E\u0439 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u0438 \u043F\u0440\u043E\u0433\u043E\u043D\u0430 GitHub Actions \u2014 CI \u0443 \u0440\u0435\u043F\u043E\u0437\u0438\u0442\u043E\u0440\u0438\u044F \u043D\u0435\u0442` };
15558
+ }
15559
+ return { action: "merge", code: "queue_head", reason: `\u0435\u0433\u043E \u0447\u0435\u0440\u0451\u0434, \u0431\u0430\u0437\u0430 \u0441\u0432\u0435\u0436\u0430\u044F, \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 \u043D\u0430 ${shortSha(sha)} \u0437\u0435\u043B\u0451\u043D\u044B\u0435 \u0438 \u0432\u0441\u0435 \u043F\u0440\u043E\u0433\u043E\u043D\u044B GitHub Actions \u043D\u0430 \u043D\u0451\u043C \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u044B` };
15560
+ };
15561
+ const confirmLive = async (prUrl, sha, verdict) => {
15562
+ if (!sha) {
15563
+ return { action: "wait", code: "checks_unknown", reason: "gh \u043D\u0435 \u0441\u043A\u0430\u0437\u0430\u043B HEAD PR \u2014 \u043D\u0435 \u043F\u043E\u043D\u044F\u0442\u044C, \u043F\u0440\u043E \u043A\u0430\u043A\u043E\u0439 \u043A\u043E\u043C\u043C\u0438\u0442 \u044D\u0442\u043E\u0442 \u0432\u0435\u0440\u0434\u0438\u043A\u0442; \u0441\u043F\u0440\u043E\u0448\u0443 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C" };
15564
+ }
15565
+ const live = deps.baseSync ? await deps.baseSync(prUrl).catch(() => null) : null;
15566
+ if (!live || !live.headSha) {
15567
+ return { action: "wait", code: "base_unknown", reason: "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0435\u0440\u0435\u0441\u043F\u0440\u043E\u0441\u0438\u0442\u044C \u0436\u0438\u0432\u043E\u0439 HEAD PR \u2014 \u0441\u043F\u0440\u043E\u0448\u0443 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C" };
15568
+ }
15569
+ if (verdict.action === "merge" && live.behindBy > 0) {
15570
+ return {
15571
+ action: "refresh_base",
15572
+ code: "base_stale",
15573
+ reason: `\u0431\u0430\u0437\u0430 \u0443\u0448\u043B\u0430 \u0432\u043F\u0435\u0440\u0451\u0434 \u043D\u0430 ${live.behindBy} \u043A\u043E\u043C\u043C\u0438\u0442(\u043E\u0432) \u043F\u043E\u0441\u043B\u0435 \u043F\u0440\u0435\u0434\u044B\u0434\u0443\u0449\u0435\u0433\u043E \u043C\u0435\u0440\u0436\u0430 \u2014 \u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043B\u0438\u0442\u044C main \u0438 \u0434\u043E\u0436\u0434\u0430\u0442\u044C\u0441\u044F CI \u043D\u0430 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D\u043D\u043E\u0439 \u0431\u0430\u0437\u0435`
15574
+ };
15575
+ }
15576
+ if (live.headSha !== sha) {
15577
+ return { action: "wait", code: "checks_pending", reason: `\u043D\u0430 PR \u043D\u043E\u0432\u044B\u0439 \u043A\u043E\u043C\u043C\u0438\u0442 ${shortSha(live.headSha)} \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044F\u044E \u0437\u0430\u043D\u043E\u0432\u043E` };
15578
+ }
15579
+ return verdict;
15580
+ };
15581
+ return async (run) => {
15582
+ if (!deps.baseSync || !run.pr_url || !hasMergeIntent(run)) return null;
15583
+ const num = prNumberOf(run.pr_url);
15584
+ if (num == null) return null;
15585
+ const marked = mergeQueueCandidates(deps.listRuns(), effectiveRepoOf(run.repo, deps.repoFallback), deps.repoFallback).filter(hasMergeIntent);
15586
+ const mine = marked.find((c) => prNumberOf(c.pr_url) === num);
15587
+ if (!mine) return null;
15588
+ const queue = orderMergeQueue(marked.map(entryOf));
15589
+ const entry = entryOf(mine);
15590
+ const position = queue.findIndex((e) => e.runId === entry.runId) + 1;
15591
+ const head = queue[0];
15592
+ if (head.runId !== entry.runId) {
15593
+ return {
15594
+ action: "wait",
15595
+ code: "queue_wait",
15596
+ reason: `\u0440\u043E\u0439 \u0432\u043B\u0438\u0432\u0430\u0435\u0442 \u043F\u043E \u043E\u0434\u043D\u043E\u043C\u0443: \u0441\u0435\u0439\u0447\u0430\u0441 \u043E\u0447\u0435\u0440\u0435\u0434\u044C PR #${head.prNumber}${head.slug ? ` (${head.slug})` : ""}, \u044D\u0442\u043E\u0442 \u2014 ${position}-\u0439 \u0438\u0437 ${queue.length}`,
15597
+ position,
15598
+ size: queue.length
15599
+ };
15600
+ }
15601
+ const st = await read(mine.pr_url);
15602
+ const now = deps.now ? deps.now() : Date.now();
15603
+ if (st.headSha) {
15604
+ const seen = firstSeen.get(mine.pr_url);
15605
+ if (!seen || seen.sha !== st.headSha) firstSeen.set(mine.pr_url, { sha: st.headSha, at: now });
15606
+ }
15607
+ const d = computeMergeQueue({ entry, ready: queue, behindBy: st.behindBy, checks: st.checks });
15608
+ const verdict = d.code === "queue_head" ? await ciSettled(mine.pr_url, st, now) : { action: d.code === "base_stale" ? "refresh_base" : d.code === "checks_red" ? "clear" : "wait", code: d.code, reason: d.reason };
15609
+ const final = verdict.action === "merge" || verdict.action === "clear" ? await confirmLive(mine.pr_url, st.headSha, verdict) : verdict;
15610
+ return { ...final, position, size: queue.length, ...st.headSha ? { headSha: st.headSha } : {} };
15611
+ };
15612
+ }
15390
15613
  function needsBaseRefresh(d) {
15391
15614
  return d?.code === "base_stale";
15392
15615
  }
@@ -15418,9 +15641,15 @@ function makeMergeGateParts(deps) {
15418
15641
  });
15419
15642
  };
15420
15643
  const repoOfRun = deps.repoFullNameOf ? (r) => deps.repoFullNameOf(effectiveRepoOf(r.repo, deps.repoFallback)) : void 0;
15644
+ const headState = makeHeadStateReader({
15645
+ ...deps.baseSync ? { baseSync: deps.baseSync } : {},
15646
+ ...deps.checks ? { checks: deps.checks } : {},
15647
+ ...deps.ttlMs != null ? { ttlMs: deps.ttlMs } : {}
15648
+ });
15421
15649
  const queueFor = makeMergeQueueResolver({
15422
15650
  listRuns: deps.listRuns,
15423
15651
  gateAllows: async (r) => (await gateFor(r)).allowed,
15652
+ headState,
15424
15653
  ...deps.repoFallback ? { repoFallback: deps.repoFallback } : {},
15425
15654
  ...deps.baseSync ? { baseSync: deps.baseSync } : {},
15426
15655
  ...deps.checks ? { checks: deps.checks } : {},
@@ -15446,7 +15675,68 @@ function makeMergeGateParts(deps) {
15446
15675
  }
15447
15676
  };
15448
15677
  };
15449
- return { gate, queue: queueFor };
15678
+ const intentFor = makeMergeIntentResolver({
15679
+ listRuns: deps.listRuns,
15680
+ headState,
15681
+ ...deps.repoFallback ? { repoFallback: deps.repoFallback } : {},
15682
+ ...deps.baseSync ? { baseSync: deps.baseSync } : {},
15683
+ ...deps.checks ? { checks: deps.checks } : {},
15684
+ ...deps.actionsRuns ? { actionsRuns: deps.actionsRuns } : {},
15685
+ ...deps.ttlMs != null ? { ttlMs: deps.ttlMs } : {}
15686
+ });
15687
+ return { gate, queue: queueFor, intent: intentFor };
15688
+ }
15689
+
15690
+ // src/prodMerge.ts
15691
+ init_config();
15692
+ init_taskless();
15693
+ var TIMEOUT_MS = 3e4;
15694
+ async function tokenFor(api, deps) {
15695
+ return deps.token ? deps.token() : resolveApiToken(api);
15696
+ }
15697
+ async function prodMergePr(runId, sha, deps = {}) {
15698
+ const api = deps.api ?? TASKLESS_API;
15699
+ const doFetch = deps.fetchFn ?? fetch;
15700
+ try {
15701
+ const token = await tokenFor(api, deps);
15702
+ if (!token) return { ok: false, error: "\u043D\u0435\u0447\u0435\u043C \u0445\u043E\u0434\u0438\u0442\u044C \u0432 \u043F\u0440\u043E\u0434 (\u043D\u0435\u0442 \u0442\u043E\u043A\u0435\u043D\u0430)" };
15703
+ const res = await doFetch(restEndpoint(api, "/swarm/merge"), {
15704
+ method: "POST",
15705
+ headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
15706
+ body: JSON.stringify(sha ? { runId, sha } : { runId }),
15707
+ signal: AbortSignal.timeout(TIMEOUT_MS)
15708
+ });
15709
+ if (!res.ok) {
15710
+ const text = await res.text().catch(() => "");
15711
+ let code;
15712
+ try {
15713
+ code = JSON.parse(text).error;
15714
+ } catch {
15715
+ code = void 0;
15716
+ }
15717
+ if (code === "head_moved") return { ok: false, error: "HEAD PR \u0441\u043C\u0435\u043D\u0438\u043B\u0441\u044F \u043F\u043E\u0441\u043B\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438", headMoved: true };
15718
+ return { ok: false, error: `\u043F\u0440\u043E\u0434 \u043E\u0442\u043A\u0430\u0437\u0430\u043B (${res.status}): ${text.slice(0, 300)}` };
15719
+ }
15720
+ return { ok: true };
15721
+ } catch (e) {
15722
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
15723
+ }
15724
+ }
15725
+ async function prodClearMergeIntent(runId, deps = {}) {
15726
+ const api = deps.api ?? TASKLESS_API;
15727
+ const doFetch = deps.fetchFn ?? fetch;
15728
+ try {
15729
+ const token = await tokenFor(api, deps);
15730
+ if (!token) return false;
15731
+ const res = await doFetch(restEndpoint(api, `/swarm/merge-request/${encodeURIComponent(runId)}`), {
15732
+ method: "DELETE",
15733
+ headers: { Authorization: `Bearer ${token}` },
15734
+ signal: AbortSignal.timeout(TIMEOUT_MS)
15735
+ });
15736
+ return res.ok;
15737
+ } catch {
15738
+ return false;
15739
+ }
15450
15740
  }
15451
15741
 
15452
15742
  // src/envGate.ts
@@ -18034,8 +18324,8 @@ async function headSha(repo) {
18034
18324
  function runText(run) {
18035
18325
  return [run.task_title ?? "", run.task_desc ?? ""].join(" ").trim();
18036
18326
  }
18037
- function predictForRun(model, run) {
18038
- return predictFiles(model, { zone: run.area, text: runText(run) });
18327
+ function predictForRun(model, run, fileExists) {
18328
+ return predictFiles(model, { zone: run.area, text: runText(run), ...fileExists ? { fileExists } : {} });
18039
18329
  }
18040
18330
  function short(files, n = 3) {
18041
18331
  return files.slice(0, n).join(", ") + (files.length > n ? ` \u0438 \u0435\u0449\u0451 ${files.length - n}` : "");
@@ -18050,17 +18340,17 @@ var SPINE_PREDICTION_WEIGHT = 0.7;
18050
18340
  function spineOfPrediction(spine, predicted) {
18051
18341
  return predicted.filter((p) => spine.has(p.path) && p.weight >= SPINE_PREDICTION_WEIGHT).map((p) => p.path);
18052
18342
  }
18053
- function decideConflict({ model, candidate, active }) {
18343
+ function decideConflict({ model, candidate, active, fileExists }) {
18054
18344
  if ((candidate.kind ?? "drone") !== "drone") return { kind: "clear" };
18055
18345
  if (!hasZoneModel(model)) return { kind: "unknown" };
18056
- const predicted = predictForRun(model, candidate);
18346
+ const predicted = predictForRun(model, candidate, fileExists);
18057
18347
  if (!predicted.length) return { kind: "unknown" };
18058
18348
  if (!active.length) return { kind: "clear" };
18059
18349
  const spine = new Set(model.spine);
18060
18350
  const candidateSpine = new Set(spineOfPrediction(spine, predicted));
18061
18351
  if (candidateSpine.size) {
18062
18352
  for (const w of active) {
18063
- const theirSpine = w.actual ? w.files.filter((f) => spine.has(f)) : spineOfPrediction(spine, predictForRun(model, w.run));
18353
+ const theirSpine = w.actual ? w.files.filter((f) => spine.has(f)) : spineOfPrediction(spine, predictForRun(model, w.run, fileExists));
18064
18354
  const shared = theirSpine.filter((f) => candidateSpine.has(f));
18065
18355
  if (!shared.length) continue;
18066
18356
  return {
@@ -18282,6 +18572,31 @@ async function needsBaseRefreshNow(deps, run, stormActive, now) {
18282
18572
  if (last != null && now - last < CONFLICT_RESOLVE_COOLDOWN_MS) return false;
18283
18573
  return needsBaseRefresh(await deps.mergeQueue(run).catch(() => null));
18284
18574
  }
18575
+ async function dropMergeIntent(deps, run, reason) {
18576
+ if (!deps.clearMergeIntent) return false;
18577
+ if (!await deps.clearMergeIntent(run).catch(() => false)) return false;
18578
+ deps.db.updateRun(run.id, { merge_requested_at: null });
18579
+ deps.db.addEvent(run.id, "step", `\u043C\u0435\u0440\u0436 \u0441\u043D\u044F\u0442: ${reason}`);
18580
+ narrate(deps, `\u0421\u043D\u044F\u043B \u043D\u0430\u043C\u0435\u0440\u0435\u043D\u0438\u0435 \u0441\u043C\u0435\u0440\u0436\u0438\u0442\u044C PR ${droneLabel(run)} \u2014 ${reason}.`);
18581
+ return true;
18582
+ }
18583
+ function mergeIntentDropReason(deps, run, conflicting) {
18584
+ if (needsFixIteration(run)) return "\u043F\u043E PR \u043F\u0440\u0438\u0448\u0451\u043B \u0432\u0435\u0440\u0434\u0438\u043A\u0442 \xAB\u043D\u0443\u0436\u043D\u044B \u043F\u0440\u0430\u0432\u043A\u0438\xBB \u2014 \u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0434\u043E\u0440\u0430\u0431\u043E\u0442\u043A\u0430";
18585
+ if (conflicting && resolveEscalated(deps.db, run.id)) return "\u0440\u043E\u0439 \u043D\u0435 \u0441\u043C\u043E\u0433 \u0441\u0432\u0435\u0441\u0442\u0438 \u0432\u0435\u0442\u043A\u0443 \u0441 \u0431\u0430\u0437\u043E\u0439 \u2014 \u043D\u0443\u0436\u0435\u043D \u0447\u0435\u043B\u043E\u0432\u0435\u043A";
18586
+ return null;
18587
+ }
18588
+ var MERGE_INTENT_FAIL_MARK = "\u043C\u0435\u0440\u0436 \u043F\u043E \u043D\u0430\u043C\u0435\u0440\u0435\u043D\u0438\u044E \u043D\u0435 \u043F\u0440\u043E\u0448\u0451\u043B";
18589
+ var MERGE_INTENT_FAIL_NOTE_MS = 60 * 6e4;
18590
+ function noteMergeIntentFailure(deps, run, error, now) {
18591
+ const text = `${MERGE_INTENT_FAIL_MARK}: ${error} \u2014 \u043F\u043E\u0432\u0442\u043E\u0440\u044E \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C`;
18592
+ const prev = deps.db.events(run.id).filter((e) => (e.text ?? "").startsWith(MERGE_INTENT_FAIL_MARK));
18593
+ const last = prev[prev.length - 1];
18594
+ if (last && last.text === text) {
18595
+ const at = Date.parse(last.at.replace(" ", "T") + "Z");
18596
+ if (!Number.isNaN(at) && now - at < MERGE_INTENT_FAIL_NOTE_MS) return;
18597
+ }
18598
+ deps.db.addEvent(run.id, "step", text);
18599
+ }
18285
18600
  function lastCiCheckMs(db, runId) {
18286
18601
  const evs = db.events(runId).filter((e) => e.kind === "ci_wake" || e.kind === "ci_checked");
18287
18602
  if (!evs.length) return null;
@@ -19712,6 +20027,13 @@ async function zoneConflictFor(db, models, activeFiles, run, repo, repoFallback)
19712
20027
  if (!hasZoneModel(model)) return { kind: "unknown" };
19713
20028
  const holders = db.listRuns().filter((r) => holdsFiles(r));
19714
20029
  activeFiles.prune(holders.map((r) => r.id));
20030
+ const fileExists = (p) => {
20031
+ try {
20032
+ return statSync13(join34(repo, p), { throwIfNoEntry: false })?.isFile() ?? false;
20033
+ } catch {
20034
+ return false;
20035
+ }
20036
+ };
19715
20037
  const active = [];
19716
20038
  for (const r of holders) {
19717
20039
  if (r.id === run.id) continue;
@@ -19720,10 +20042,10 @@ async function zoneConflictFor(db, models, activeFiles, run, repo, repoFallback)
19720
20042
  const files = snapshot.length ? snapshot : await activeFiles.filesOf(r);
19721
20043
  const prUrl = r.status === "review" ? r.pr_url : null;
19722
20044
  active.push(
19723
- files.length ? { run: r, files, actual: true, prUrl } : { run: r, files: predictForRun(model, r).map((p) => p.path), actual: false, prUrl }
20045
+ files.length ? { run: r, files, actual: true, prUrl } : { run: r, files: heldByPrediction(predictForRun(model, r, fileExists)), actual: false, prUrl }
19724
20046
  );
19725
20047
  }
19726
- return decideConflict({ model, candidate: run, active });
20048
+ return decideConflict({ model, candidate: run, active, fileExists });
19727
20049
  }
19728
20050
  function activeDronesOnRepo(deps, repo, exceptRunId) {
19729
20051
  return deps.db.listRuns().filter(
@@ -20911,6 +21233,7 @@ async function tickOnce(deps) {
20911
21233
  continue;
20912
21234
  }
20913
21235
  if (prLiveness(st) === "closed") {
21236
+ if (hasMergeIntent(run)) await dropMergeIntent(deps, run, "PR \u0437\u0430\u043A\u0440\u044B\u0442 \u0431\u0435\u0437 \u043C\u0435\u0440\u0436\u0430");
20914
21237
  await finalizeClosedPrReview(deps, run, "PR \u0437\u0430\u043A\u0440\u044B\u0442 \u0431\u0435\u0437 \u043C\u0435\u0440\u0436\u0430 \u2014 \u0440\u0435\u0432\u044C\u044E \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u043E, \u043F\u0440\u043E\u0433\u043E\u043D \u0437\u0430\u043A\u0440\u044B\u0442");
20915
21238
  narrate(deps, `PR ${droneLabel(run)} \u0437\u0430\u043A\u0440\u044B\u0442 \u0431\u0435\u0437 \u043C\u0435\u0440\u0436\u0430 \u2014 ${run.task_slug} \u0441\u043D\u0438\u043C\u0430\u0435\u0442\u0441\u044F \u0441 \u0440\u0435\u0432\u044C\u044E, \u0441\u043B\u043E\u0442 \u0441\u0432\u043E\u0431\u043E\u0434\u0435\u043D.`);
20916
21239
  continue;
@@ -20940,7 +21263,37 @@ async function tickOnce(deps) {
20940
21263
  continue;
20941
21264
  }
20942
21265
  await retireOutdatedReviewers(deps, run);
20943
- const baseStale = !st.conflicting && await needsBaseRefreshNow(deps, run, stormActive, now);
21266
+ let intentBaseStale = false;
21267
+ if (hasMergeIntent(run)) {
21268
+ const drop = mergeIntentDropReason(deps, run, st.conflicting === true);
21269
+ if (drop) {
21270
+ if (await dropMergeIntent(deps, run, drop)) run = deps.db.getRun(run.id) ?? run;
21271
+ } else if (deps.mergeIntent && !stormActive) {
21272
+ const decision = await deps.mergeIntent(run).catch(() => null);
21273
+ if (decision?.action === "clear") {
21274
+ if (await dropMergeIntent(deps, run, decision.reason)) run = deps.db.getRun(run.id) ?? run;
21275
+ } else if (decision?.action === "refresh_base") {
21276
+ if (resolveEscalated(deps.db, run.id)) {
21277
+ if (await dropMergeIntent(deps, run, "\u0440\u043E\u0439 \u043D\u0435 \u0441\u043C\u043E\u0433 \u043F\u0440\u0438\u0432\u0435\u0441\u0442\u0438 \u0432\u0435\u0442\u043A\u0443 \u043A \u0441\u0432\u0435\u0436\u0435\u0439 \u0431\u0430\u0437\u0435 \u2014 \u043D\u0443\u0436\u0435\u043D \u0447\u0435\u043B\u043E\u0432\u0435\u043A")) run = deps.db.getRun(run.id) ?? run;
21278
+ } else {
21279
+ intentBaseStale = true;
21280
+ }
21281
+ } else if (decision?.action === "merge" && deps.mergePr && !st.conflicting) {
21282
+ const res = await deps.mergePr(run, decision.headSha).catch((e) => ({ ok: false, error: describeError(e) }));
21283
+ if (res.ok) {
21284
+ deps.db.addEvent(run.id, "step", `\u0440\u043E\u0439 \u0432\u043B\u0438\u043B PR \u043F\u043E \u043E\u0447\u0435\u0440\u0435\u0434\u0438 \u043C\u0435\u0440\u0436\u0430: ${decision.reason} (TASWM-577)`);
21285
+ narrate(deps, `\u0412\u043B\u0438\u043B PR ${droneLabel(run)} \u043F\u043E \u043E\u0447\u0435\u0440\u0435\u0434\u0438 \u043C\u0435\u0440\u0436\u0430: ${decision.reason}.`);
21286
+ continue;
21287
+ }
21288
+ if ("headMoved" in res && res.headMoved) {
21289
+ deps.db.addEvent(run.id, "step", `\u043C\u0435\u0440\u0436 \u043F\u043E \u043D\u0430\u043C\u0435\u0440\u0435\u043D\u0438\u044E \u043E\u0442\u043B\u043E\u0436\u0435\u043D: HEAD PR \u0441\u043C\u0435\u043D\u0438\u043B\u0441\u044F \u043F\u043E\u0441\u043B\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438 ${(decision.headSha ?? "").slice(0, 7)} \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044E \u043D\u043E\u0432\u044B\u0439 \u043A\u043E\u043C\u043C\u0438\u0442 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u043C \u0442\u0438\u043A\u043E\u043C (TASWM-580)`);
21290
+ } else {
21291
+ noteMergeIntentFailure(deps, run, res.error, now);
21292
+ }
21293
+ }
21294
+ }
21295
+ }
21296
+ const baseStale = !st.conflicting && (intentBaseStale || await needsBaseRefreshNow(deps, run, stormActive, now));
20944
21297
  if ((st.conflicting || baseStale) && deps.resolveConflict && !stormActive) {
20945
21298
  if (deps.db.activeResolverFor(run.id)) continue;
20946
21299
  if (deps.db.resolverCountFor(run.id) >= RESOLVE_LIMIT) {
@@ -22458,7 +22811,10 @@ async function runDaemon(opts) {
22458
22811
  const ci = await prCiStatus(url);
22459
22812
  if (ci.bucket === "denied" || ci.bucket === "unavailable") return null;
22460
22813
  return { bucket: ci.bucket };
22461
- }
22814
+ },
22815
+ // TASWM-577: прогоны GitHub Actions на HEAD — только вливанию по намерению: рой
22816
+ // вливает сам и обязан дождаться, пока CI на этом коммите добежит целиком.
22817
+ actionsRuns: (url, sha) => prActionsRuns(url, sha)
22462
22818
  } : {}
22463
22819
  });
22464
22820
  const deps = {
@@ -22468,6 +22824,24 @@ async function runDaemon(opts) {
22468
22824
  worktreesDir,
22469
22825
  // TASWM-472: реконсайлу от очереди нужен ровно исход `base_stale` (см. needsBaseRefreshNow).
22470
22826
  ...SWARM_MERGE_QUEUE ? { mergeQueue: mergeParts.queue } : {},
22827
+ // TASWM-577: вливание ПО НАМЕРЕНИЮ — та же ось, тот же вход (mergeParts), но очередь из
22828
+ // одних отмеченных PR. Выключается тем же ключом, что и очередь: SWARM_MERGE_QUEUE=0 —
22829
+ // рой не вливает ничего, мержит человек кнопкой, как до этой задачи.
22830
+ ...SWARM_MERGE_QUEUE ? { mergeIntent: mergeParts.intent } : {},
22831
+ // Сам мерж и снятие намерения идут в ПРОД-ручки (`POST /swarm/merge` — GitHub App
22832
+ // воркспейса, squash; `DELETE /swarm/merge-request/:runId`), а адресуются они cuid'ом
22833
+ // прогона в проде. Он есть только у клиентского бэкенда: в sqlite-режиме прогонов в
22834
+ // проде нет вовсе, и рой там по-прежнему не мержит — мержит человек.
22835
+ ...db instanceof SwarmClient ? {
22836
+ mergePr: async (run, sha) => {
22837
+ const prodId = db.prodRunId(run.id);
22838
+ return prodId ? prodMergePr(prodId, sha) : { ok: false, error: "\u043F\u0440\u043E\u0433\u043E\u043D \u0435\u0449\u0451 \u043D\u0435 \u0434\u043E\u043B\u0435\u0442\u0435\u043B \u0434\u043E \u043F\u0440\u043E\u0434\u0430 \u2014 \u0432\u043B\u0438\u0432\u0430\u0442\u044C \u043D\u0435\u0447\u0435\u043C" };
22839
+ },
22840
+ clearMergeIntent: async (run) => {
22841
+ const prodId = db.prodRunId(run.id);
22842
+ return prodId ? prodClearMergeIntent(prodId) : false;
22843
+ }
22844
+ } : {},
22471
22845
  narrate: narrator.narrate,
22472
22846
  control,
22473
22847
  spawn: async (run) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taskless-app/swarm",
3
- "version": "0.6.49",
3
+ "version": "0.6.50",
4
4
  "type": "module",
5
5
  "description": "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
6
6
  "keywords": [
@@ -39,7 +39,7 @@
39
39
  "execa": "^10.0.0",
40
40
  "zod": "3.25.76"
41
41
  },
42
- "gitHead": "27600667531d4f65761c74793e8c4ee4859dc8ae",
42
+ "gitHead": "859ffe2d91bf4c791af067147515b14a41b96739",
43
43
  "scripts": {
44
44
  "overmind": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --conditions=development\" tsx src/overmind.ts",
45
45
  "swarm": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --conditions=development\" tsx src/cli.ts",