@taskless-app/swarm 0.6.14 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -332,9 +332,22 @@ var init_timeline = __esm({
332
332
  });
333
333
 
334
334
  // ../swarm-core/src/reviewPhase.ts
335
+ var REVIEW_PHASE_TEXT, PHASE_BY_LENGTH;
335
336
  var init_reviewPhase = __esm({
336
337
  "../swarm-core/src/reviewPhase.ts"() {
337
338
  "use strict";
339
+ REVIEW_PHASE_TEXT = {
340
+ checks: "\u0418\u0434\u0443\u0442 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438",
341
+ awaiting_reviewer: "\u0417\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0436\u0434\u0451\u0442 \u0440\u0435\u0432\u044C\u044E\u0435\u0440\u0430",
342
+ ci_fixing: "CI \u043A\u0440\u0430\u0441\u043D\u044B\u0439 \u2014 \u0434\u0440\u043E\u043D \u0447\u0438\u043D\u0438\u0442",
343
+ in_review: "\u041D\u0430 \u0440\u0435\u0432\u044C\u044E",
344
+ changes: "\u041D\u0443\u0436\u043D\u044B \u043F\u0440\u0430\u0432\u043A\u0438 \u2014 \u0434\u0440\u043E\u043D \u043F\u0440\u0430\u0432\u0438\u0442",
345
+ ready: "\u0417\u0435\u043B\u0451\u043D\u044B\u0439 \u2014 \u0433\u043E\u0442\u043E\u0432 \u043A \u043C\u0435\u0440\u0436\u0443",
346
+ reviewer_dead: "\u0420\u0435\u0432\u044C\u044E\u0435\u0440 \u0443\u043C\u0435\u0440 \u2014 \u0432\u0435\u0440\u0434\u0438\u043A\u0442\u0430 \u043D\u0435\u0442, \u043D\u0443\u0436\u0435\u043D \u0447\u0435\u043B\u043E\u0432\u0435\u043A"
347
+ };
348
+ PHASE_BY_LENGTH = Object.keys(REVIEW_PHASE_TEXT).sort(
349
+ (a, b) => REVIEW_PHASE_TEXT[b].length - REVIEW_PHASE_TEXT[a].length
350
+ );
338
351
  }
339
352
  });
340
353
 
@@ -534,7 +547,7 @@ var init_status = __esm({
534
547
  // ../swarm-core/src/deps.ts
535
548
  function judgeDep(runs) {
536
549
  const real = runs.filter((r) => !r.service);
537
- if (real.length === 0) return { ...PENDING };
550
+ if (real.length === 0) return { ...UNKNOWN };
538
551
  if (real.some((r) => !isTerminalStatus(r.status))) return { ...PENDING };
539
552
  if (real.some((r) => r.status === "done")) {
540
553
  return { outcome: "done", met: true, status: "done", name: null, stopReason: null };
@@ -548,12 +561,13 @@ function judgeDep(runs) {
548
561
  stopReason: last.stopReason ?? null
549
562
  };
550
563
  }
551
- var PENDING;
564
+ var PENDING, UNKNOWN;
552
565
  var init_deps = __esm({
553
566
  "../swarm-core/src/deps.ts"() {
554
567
  "use strict";
555
568
  init_status();
556
569
  PENDING = { outcome: "pending", met: false, status: null, name: null, stopReason: null };
570
+ UNKNOWN = { outcome: "unknown", met: false, status: null, name: null, stopReason: null };
557
571
  }
558
572
  });
559
573
 
@@ -1939,7 +1953,10 @@ function normalizeRepo(repo) {
1939
1953
  }
1940
1954
  }
1941
1955
  function droneWorktreePath(worktreesDir, repo, slug) {
1942
- return join4(worktreesDir, basename(normalizeRepo(repo)), slug);
1956
+ return join4(droneWorktreeRoot(worktreesDir, repo), slug);
1957
+ }
1958
+ function droneWorktreeRoot(worktreesDir, repo) {
1959
+ return join4(worktreesDir, basename(normalizeRepo(repo)));
1943
1960
  }
1944
1961
  function resolveEffectiveWorktreesDir(deps) {
1945
1962
  if (deps.envOverride) return deps.envOverride;
@@ -1965,7 +1982,7 @@ function tokenFromFile() {
1965
1982
  return "";
1966
1983
  }
1967
1984
  }
1968
- var DB_PATH, EXECUTOR, DRONE_HOME, DRONE_HOME_ISOLATION, DRONE_ISOLATION, WORKTREES_NOINDEX_DIR, WORKTREES_LEGACY_DIR, WORKTREES_DIR, RUN_LOGS_DIR, DAEMON_LOG_PATH, DAEMON_LOG_MAX_BYTES, DAEMON_LOG_KEEP, DAEMON_LOG_CHECK_MS, DIST_BUILD, SWARM_BACKEND, SWARM_OUTBOX, SWARM_MAX_IN_FLIGHT, SWARM_CLAIM_INTERVAL_MS, SWARM_MAX_LOAD_FACTOR, SWARM_MIN_FREE_GB, SWARM_MAX_FD_LOAD_FACTOR, SWARM_MAX_CPU_BUSY, SWARM_CPU_WINDOW_MS, SWARM_CPU_SANITY_BUSY, SWARM_CPU_SANITY_LOAD_FACTOR, SWARM_CPU_SANITY_LAG_MS, SWARM_RELIEF_LOAD_FACTOR, SWARM_RELIEF_MIN_FREE_GB, SWARM_RELIEF_MAX_FD_LOAD_FACTOR, SWARM_RELIEF_CPU_BUSY, SWARM_RELIEF_COST_WINDOW_MS, SWARM_RELIEF_ALERT_PAUSES, SWARM_RELIEF_ALERT_LOST_MS, SWARM_RELIEF_ALERT_MIN_STEPS, SWARM_RELIEF_ALERT_SUSTAIN_MS, SWARM_LOW_DISK_GB, SWARM_REHYDRATE_MS, SWARM_DRONE_BRIDGE_MS, SWARM_PROD_HEARTBEAT_MS, SWARM_VERSION_CHECK_MS, SWARM_SWEEP_INTERVAL_MS, DRONE_CHANNEL_ENABLED, DRONE_CHANNEL_PORT, DRONE_CHANNEL_URL, DRONE_CHANNEL_SECRET_PATH, DRONE_CHANNEL_GRACE_MS, HEARTBEAT_PATH, HEARTBEAT_STALE_MS, SWARM_REPO, SWARM_SCAN_DEPTH, SWARM_REPO_PATHS, SWARM_SCAN_ROOTS_RAW, REPO_MAP_PATH, SWARM_REPO_MAP_MS, TASKLESS_API, SWARM_SHOT_TIMEOUT_MS, TASKLESS_MCP_PATH, TASKLESS_TOKEN, TASKLESS_WORKSPACE, TASKLESS_WORKSPACE_SLUG, TASKLESS_TASK_URL_BASE, SWARM_VERIFY, SWARM_AUTO_REVIEW, SWARM_AUTO_VERIFY, REVIEW_CUTOFF_PATH, SWARM_REVIEW_CAP, SWARM_STORM_URL, WEB_PORT, WEB_REDIRECT_URI, SWARM_ANOMALY_RENOTIFY_MS, SWARM_ANOMALY_MAX_REPEATS, SWARM_ANOMALY_RUN_DOWN_WINDOW_MS, SWARM_ANOMALY_FAILED_SPIKE_COUNT, SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS, SWARM_ANOMALY_QUEUE_STALLED_MS, SWARM_ANOMALY_PR_STUCK_MERGE_MS, SWARM_ANOMALY_PR_NO_REVIEWER_MS, SWARM_ANOMALY_QUESTION_UNANSWERED_MS, SWARM_ANOMALY_OUTBOX_STUCK_MS, SWARM_ANOMALY_RESOURCE_GATE_STALL_MS, SWARM_WATCHDOG_STALL_MS;
1985
+ var DB_PATH, EXECUTOR, DRONE_HOME, DRONE_HOME_ISOLATION, DRONE_ISOLATION, WORKTREES_NOINDEX_DIR, WORKTREES_LEGACY_DIR, WORKTREES_DIR, RUN_LOGS_DIR, DAEMON_LOG_PATH, DAEMON_LOG_MAX_BYTES, DAEMON_LOG_KEEP, DAEMON_LOG_CHECK_MS, DIST_BUILD, SWARM_BACKEND, SWARM_OUTBOX, SWARM_MAX_IN_FLIGHT, SWARM_CLAIM_INTERVAL_MS, SWARM_MAX_LOAD_FACTOR, SWARM_MIN_FREE_GB, SWARM_MAX_FD_LOAD_FACTOR, SWARM_MAX_CPU_BUSY, SWARM_CPU_WINDOW_MS, SWARM_CPU_SANITY_BUSY, SWARM_CPU_SANITY_LOAD_FACTOR, SWARM_CPU_SANITY_LAG_MS, SWARM_RELIEF_LOAD_FACTOR, SWARM_RELIEF_MIN_FREE_GB, SWARM_RELIEF_MAX_FD_LOAD_FACTOR, SWARM_RELIEF_CPU_BUSY, SWARM_RELIEF_COST_WINDOW_MS, SWARM_RELIEF_ALERT_PAUSES, SWARM_RELIEF_ALERT_LOST_MS, SWARM_RELIEF_ALERT_MIN_STEPS, SWARM_RELIEF_ALERT_SUSTAIN_MS, SWARM_LOW_DISK_GB, SWARM_REHYDRATE_MS, SWARM_DRONE_BRIDGE_MS, SWARM_PROD_HEARTBEAT_MS, SWARM_VERSION_CHECK_MS, SWARM_SWEEP_INTERVAL_MS, DRONE_CHANNEL_ENABLED, DRONE_CHANNEL_PORT, DRONE_CHANNEL_URL, DRONE_CHANNEL_SECRET_PATH, DRONE_CHANNEL_GRACE_MS, HEARTBEAT_PATH, HEARTBEAT_STALE_MS, SWARM_REPO, SWARM_SCAN_DEPTH, SWARM_REPO_PATHS, SWARM_SCAN_ROOTS_RAW, REPO_MAP_PATH, SWARM_REPO_MAP_MS, SWARM_REPO_SCAN_TTL_MS, TASKLESS_API, SWARM_SHOT_TIMEOUT_MS, TASKLESS_MCP_PATH, TASKLESS_TOKEN, TASKLESS_WORKSPACE, TASKLESS_WORKSPACE_SLUG, TASKLESS_TASK_URL_BASE, SWARM_VERIFY, SWARM_AUTO_REVIEW, SWARM_AUTO_VERIFY, REVIEW_CUTOFF_PATH, SWARM_REVIEW_CAP, SWARM_STORM_URL, WEB_PORT, WEB_REDIRECT_URI, SWARM_ANOMALY_RENOTIFY_MS, SWARM_ANOMALY_MAX_REPEATS, SWARM_ANOMALY_RUN_DOWN_WINDOW_MS, SWARM_ANOMALY_FAILED_SPIKE_COUNT, SWARM_ANOMALY_FAILED_SPIKE_WINDOW_MS, SWARM_ANOMALY_QUEUE_STALLED_MS, SWARM_ANOMALY_PR_STUCK_MERGE_MS, SWARM_ANOMALY_PR_NO_REVIEWER_MS, SWARM_ANOMALY_QUESTION_UNANSWERED_MS, SWARM_ANOMALY_OUTBOX_STUCK_MS, SWARM_ANOMALY_RESOURCE_GATE_STALL_MS, SWARM_ANOMALY_DEP_MISSING_MS, SWARM_WATCHDOG_STALL_MS;
1969
1986
  var init_config = __esm({
1970
1987
  "src/config.ts"() {
1971
1988
  "use strict";
@@ -2027,6 +2044,7 @@ var init_config = __esm({
2027
2044
  SWARM_SCAN_ROOTS_RAW = process.env.SWARM_SCAN_ROOTS || "";
2028
2045
  REPO_MAP_PATH = process.env.SWARM_REPO_MAP || join4(SWARM_HOME, "repo-map.json");
2029
2046
  SWARM_REPO_MAP_MS = envNum("SWARM_REPO_MAP_MS", 15 * 6e4);
2047
+ SWARM_REPO_SCAN_TTL_MS = envNum("SWARM_REPO_SCAN_TTL_MS", 24 * 60 * 6e4);
2030
2048
  TASKLESS_API = process.env.TASKLESS_API || "https://taskless.ru";
2031
2049
  SWARM_SHOT_TIMEOUT_MS = envNum("SWARM_SHOT_TIMEOUT_MS", 2e4);
2032
2050
  TASKLESS_MCP_PATH = process.env.TASKLESS_MCP_PATH || "/api/mcp";
@@ -2053,6 +2071,7 @@ var init_config = __esm({
2053
2071
  SWARM_ANOMALY_QUESTION_UNANSWERED_MS = envNum("SWARM_ANOMALY_QUESTION_UNANSWERED_MS", 20 * 6e4);
2054
2072
  SWARM_ANOMALY_OUTBOX_STUCK_MS = envNum("SWARM_ANOMALY_OUTBOX_STUCK_MS", 10 * 6e4);
2055
2073
  SWARM_ANOMALY_RESOURCE_GATE_STALL_MS = envNum("SWARM_ANOMALY_RESOURCE_GATE_STALL_MS", 5 * 6e4);
2074
+ SWARM_ANOMALY_DEP_MISSING_MS = envNum("SWARM_ANOMALY_DEP_MISSING_MS", 2 * 6e4);
2056
2075
  SWARM_WATCHDOG_STALL_MS = envNum("SWARM_WATCHDOG_STALL_MS", 15 * 6e4);
2057
2076
  }
2058
2077
  });
@@ -2167,7 +2186,7 @@ var init_package = __esm({
2167
2186
  "package.json"() {
2168
2187
  package_default = {
2169
2188
  name: "@taskless-app/swarm",
2170
- version: "0.6.14",
2189
+ version: "0.6.15",
2171
2190
  type: "module",
2172
2191
  description: "Local control plane for a fleet of AI coding agents (drones), coordinating through Taskless. The overmind runs the swarm.",
2173
2192
  keywords: [
@@ -2217,7 +2236,7 @@ var init_package = __esm({
2217
2236
  execa: "^10.0.0",
2218
2237
  zod: "3.25.76"
2219
2238
  },
2220
- gitHead: "a3b11498dbed5db3642fe4fc83bcd2054efde055"
2239
+ gitHead: "5bc8e0c067de58fd72b0eb434e80ad29c7a00223"
2221
2240
  };
2222
2241
  }
2223
2242
  });
@@ -2235,7 +2254,7 @@ var init_version = __esm({
2235
2254
 
2236
2255
  // src/droneChannel.ts
2237
2256
  import { createHmac, randomBytes, timingSafeEqual } from "crypto";
2238
- import { chmodSync, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2257
+ import { chmodSync, mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2239
2258
  import { dirname as dirname2 } from "path";
2240
2259
  import { serve } from "@hono/node-server";
2241
2260
  function loadChannelSecret(path) {
@@ -2245,7 +2264,7 @@ function loadChannelSecret(path) {
2245
2264
  } catch {
2246
2265
  }
2247
2266
  const secret = randomBytes(32).toString("hex");
2248
- mkdirSync5(dirname2(path), { recursive: true });
2267
+ mkdirSync6(dirname2(path), { recursive: true });
2249
2268
  writeFileSync5(path, secret, { mode: 384 });
2250
2269
  try {
2251
2270
  chmodSync(path, 384);
@@ -2287,7 +2306,7 @@ var init_droneChannel = __esm({
2287
2306
 
2288
2307
  // src/agentToken.ts
2289
2308
  import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, unlinkSync as unlinkSync2 } from "fs";
2290
- import { join as join8 } from "path";
2309
+ import { join as join9 } from "path";
2291
2310
  function loadAgentToken() {
2292
2311
  try {
2293
2312
  const s = JSON.parse(readFileSync7(STORE_PATH, "utf8"));
@@ -2368,7 +2387,7 @@ var init_agentToken = __esm({
2368
2387
  "src/agentToken.ts"() {
2369
2388
  "use strict";
2370
2389
  init_config();
2371
- STORE_PATH = join8(SWARM_HOME, "agent.json");
2390
+ STORE_PATH = join9(SWARM_HOME, "agent.json");
2372
2391
  base = (api) => api.replace(/\/$/, "");
2373
2392
  TOKEN_PROBE_TIMEOUT_MS = 8e3;
2374
2393
  }
@@ -2619,13 +2638,13 @@ var init_daemonLink = __esm({
2619
2638
  });
2620
2639
 
2621
2640
  // src/pauseState.ts
2622
- import { existsSync as existsSync9, readFileSync as readFileSync10, renameSync as renameSync4, unlinkSync as unlinkSync5, writeFileSync as writeFileSync9 } from "fs";
2623
- import { join as join16 } from "path";
2641
+ import { existsSync as existsSync10, readFileSync as readFileSync10, renameSync as renameSync5, unlinkSync as unlinkSync5, writeFileSync as writeFileSync9 } from "fs";
2642
+ import { join as join17 } from "path";
2624
2643
  function pauseStatePath(home) {
2625
- return join16(home, "pause.json");
2644
+ return join17(home, "pause.json");
2626
2645
  }
2627
2646
  function loadPauseState(home, io = {}) {
2628
- const exists = io.exists ?? existsSync9;
2647
+ const exists = io.exists ?? existsSync10;
2629
2648
  const readFile2 = io.readFile ?? ((p) => readFileSync10(p, "utf8"));
2630
2649
  const path = pauseStatePath(home);
2631
2650
  if (!exists(path)) return ACTIVE;
@@ -2645,13 +2664,13 @@ function loadPauseState(home, io = {}) {
2645
2664
  function savePauseState(home, state, io = {}) {
2646
2665
  const path = pauseStatePath(home);
2647
2666
  if (!state.paused) {
2648
- const remove = io.remove ?? ((p) => existsSync9(p) && unlinkSync5(p));
2667
+ const remove = io.remove ?? ((p) => existsSync10(p) && unlinkSync5(p));
2649
2668
  remove(path);
2650
2669
  return;
2651
2670
  }
2652
2671
  const writeFile3 = io.writeFile ?? ((p, data) => {
2653
2672
  writeFileSync9(`${p}.tmp`, data);
2654
- renameSync4(`${p}.tmp`, p);
2673
+ renameSync5(`${p}.tmp`, p);
2655
2674
  });
2656
2675
  writeFile3(path, JSON.stringify(state, null, 2));
2657
2676
  }
@@ -2687,7 +2706,7 @@ var init_pauseState = __esm({
2687
2706
  });
2688
2707
 
2689
2708
  // src/heartbeat.ts
2690
- import { readFileSync as readFileSync11, renameSync as renameSync5, writeFileSync as writeFileSync10 } from "fs";
2709
+ import { readFileSync as readFileSync11, renameSync as renameSync6, writeFileSync as writeFileSync10 } from "fs";
2691
2710
  function readHeartbeat(path) {
2692
2711
  try {
2693
2712
  const ms = Number(readFileSync11(path, "utf8").trim());
@@ -2708,7 +2727,8 @@ __export(db_exports, {
2708
2727
  SwarmDb: () => SwarmDb
2709
2728
  });
2710
2729
  import { createRequire as createRequire2 } from "module";
2711
- import { existsSync as existsSync10 } from "fs";
2730
+ import { randomUUID as randomUUID3 } from "crypto";
2731
+ import { existsSync as existsSync11 } from "fs";
2712
2732
  import { resolve as resolve2 } from "path";
2713
2733
  function rowToQuestion(row) {
2714
2734
  return {
@@ -2870,6 +2890,12 @@ CREATE INDEX IF NOT EXISTS idx_runs_area ON runs(area);
2870
2890
  CREATE INDEX IF NOT EXISTS idx_questions_run ON questions(run_id);
2871
2891
  CREATE INDEX IF NOT EXISTS idx_questions_status ON questions(status);
2872
2892
  CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id);
2893
+ -- \u043A\u043B\u044E\u0447-\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u043F\u0440\u043E \u0421\u0410\u041C \u0424\u0410\u0419\u041B \u0431\u0430\u0437\u044B (\u043D\u0435 \u043F\u0440\u043E \u043F\u0440\u043E\u0433\u043E\u043D\u044B). \u041F\u043E\u043A\u0430 \u0432 \u043D\u0451\u043C \u043E\u0434\u043D\u0430 \u0437\u0430\u043F\u0438\u0441\u044C \u2014 instance_id
2894
+ -- (TASWM-419): \u0438\u0434\u0435\u043D\u0442\u0438\u0444\u0438\u043A\u0430\u0442\u043E\u0440 \u042D\u0422\u041E\u0419 \u0436\u0438\u0437\u043D\u0438 \u0431\u0430\u0437\u044B, \u043A\u043E\u0442\u043E\u0440\u044B\u043C \u043D\u0435\u0439\u043C\u0441\u043F\u0435\u0439\u0441\u044F\u0442\u0441\u044F \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0438 run-log'\u043E\u0432.
2895
+ CREATE TABLE IF NOT EXISTS meta (
2896
+ key TEXT PRIMARY KEY,
2897
+ value TEXT NOT NULL
2898
+ );
2873
2899
  -- \u043C\u0430\u0440\u043A\u0435\u0440 \u0432\u044B\u043F\u043E\u043B\u043D\u0435\u043D\u043D\u044B\u0445 \u0438\u043C\u043F\u043E\u0440\u0442\u043E\u0432 \u0438\u0441\u0442\u043E\u0440\u0438\u0438 \u0438\u0437 \u0434\u0440\u0443\u0433\u0438\u0445 \u0411\u0414 (\u0438\u0434\u0435\u043C\u043F\u043E\u0442\u0435\u043D\u0442\u043D\u043E\u0441\u0442\u044C importDb)
2874
2900
  CREATE TABLE IF NOT EXISTS imports (
2875
2901
  source TEXT PRIMARY KEY,
@@ -2882,6 +2908,8 @@ CREATE TABLE IF NOT EXISTS imports (
2882
2908
  defaultRepo;
2883
2909
  databaseCtor;
2884
2910
  // для importDb (второй Database, source)
2911
+ /** Идентификатор этой жизни файла базы (TASWM-419) — им неймспейсятся каталоги run-log'ов. */
2912
+ instanceId;
2885
2913
  constructor(path = "swarm.db", defaultRepo = "") {
2886
2914
  this.defaultRepo = normalizeRepo(defaultRepo);
2887
2915
  this.databaseCtor = loadBetterSqlite3();
@@ -2949,6 +2977,22 @@ CREATE TABLE IF NOT EXISTS imports (
2949
2977
  if (this.defaultRepo) {
2950
2978
  this.db.prepare("UPDATE runs SET repo = ? WHERE repo IS NULL").run(this.defaultRepo);
2951
2979
  }
2980
+ this.instanceId = this.ensureInstanceId();
2981
+ }
2982
+ /**
2983
+ * Идентификатор ЭТОЙ жизни файла базы (TASWM-419). Заводится при первом открытии и живёт
2984
+ * в самой базе, поэтому переживает рестарт демона, но НЕ переживает пересоздание файла:
2985
+ * снесли базу — новый id, и каталоги run-log'ов нового прогона #1 не подселяются к файлам
2986
+ * прогона #1 из прошлой жизни. Именно это и было корнем «лог не того прогона»: номер там
2987
+ * был вся информация об адресе.
2988
+ */
2989
+ ensureInstanceId() {
2990
+ const read = () => this.db.prepare("SELECT value FROM meta WHERE key = 'instance_id'").get()?.value;
2991
+ const existing = read();
2992
+ if (existing) return existing;
2993
+ const fresh = randomUUID3().replace(/-/g, "").slice(0, 12);
2994
+ this.db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('instance_id', ?)").run(fresh);
2995
+ return read() ?? fresh;
2952
2996
  }
2953
2997
  close() {
2954
2998
  this.db.close();
@@ -3043,6 +3087,15 @@ CREATE TABLE IF NOT EXISTS imports (
3043
3087
  const m = /^local:(\d+)$/.exec(key);
3044
3088
  return m ? this.getRun(Number(m[1])) : null;
3045
3089
  }
3090
+ /**
3091
+ * TASWM-419: адрес каталога run-log'ов — номер, НЕЙМСПЕЙСНУТЫЙ идентификатором файла базы
3092
+ * (`local:<instance>:<id>`). Тот же номер в пересозданной базе даёт другой instance, то
3093
+ * есть другой каталог: подселиться к файлам чужого прогона с тем же номером больше нечем.
3094
+ * Псевдонимов у sqlite-прогона нет — ключ не меняется, пока жив файл базы.
3095
+ */
3096
+ runLogKeys(id) {
3097
+ return this.getRun(id) ? [`local:${this.instanceId}:${id}`] : [];
3098
+ }
3046
3099
  listRuns(status) {
3047
3100
  const rows = status ? this.db.prepare("SELECT * FROM runs WHERE status = ? ORDER BY id DESC").all(status) : this.db.prepare("SELECT * FROM runs ORDER BY id DESC").all();
3048
3101
  return rows.map(rowToRun);
@@ -3283,7 +3336,7 @@ CREATE TABLE IF NOT EXISTS imports (
3283
3336
  const repoN = normalizeRepo(repo);
3284
3337
  const already = this.db.prepare("SELECT run_count FROM imports WHERE source = ?").get(abs);
3285
3338
  if (already) return { imported: false, runs: already.run_count };
3286
- if (!existsSync10(abs)) throw new Error(`import-db: \u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442: ${abs}`);
3339
+ if (!existsSync11(abs)) throw new Error(`import-db: \u0444\u0430\u0439\u043B\u0430 \u043D\u0435\u0442: ${abs}`);
3287
3340
  const src = new this.databaseCtor(abs);
3288
3341
  try {
3289
3342
  const runs = src.prepare("SELECT * FROM runs ORDER BY id").all();
@@ -3610,8 +3663,8 @@ CREATE TABLE IF NOT EXISTS imports (
3610
3663
  });
3611
3664
 
3612
3665
  // src/repoScan.ts
3613
- import { readdirSync as readdirSync8, readFileSync as readFileSync12, statSync as statSync8 } from "fs";
3614
- import { join as join17 } from "path";
3666
+ import { readdirSync as readdirSync9, readFileSync as readFileSync12, statSync as statSync9 } from "fs";
3667
+ import { join as join18 } from "path";
3615
3668
  var init_repoScan = __esm({
3616
3669
  "src/repoScan.ts"() {
3617
3670
  "use strict";
@@ -3621,8 +3674,8 @@ var init_repoScan = __esm({
3621
3674
 
3622
3675
  // src/repoDiscovery.ts
3623
3676
  import { homedir as homedir4 } from "os";
3624
- import { readFileSync as readFileSync13, renameSync as renameSync6, writeFileSync as writeFileSync11 } from "fs";
3625
- import { isAbsolute as isAbsolute3, join as join18, resolve as resolve3 } from "path";
3677
+ import { readFileSync as readFileSync13, renameSync as renameSync7, writeFileSync as writeFileSync11 } from "fs";
3678
+ import { isAbsolute as isAbsolute3, join as join19, resolve as resolve3 } from "path";
3626
3679
  function repoMapLines(rows) {
3627
3680
  if (!rows?.length) return [];
3628
3681
  const missing = rows.filter((r) => !r.path).length;
@@ -3635,18 +3688,24 @@ function repoMapLines(rows) {
3635
3688
  });
3636
3689
  return [head, ...body];
3637
3690
  }
3638
- function readRepoMapCache(path) {
3691
+ function loadRepoMapCache(path) {
3639
3692
  try {
3640
3693
  const raw = JSON.parse(readFileSync13(path, "utf8"));
3641
- if (!Array.isArray(raw?.entries)) return [];
3642
- return raw.entries.filter(
3643
- (e) => !!e && typeof e.repoFullName === "string" && typeof e.path === "string" && !!e.repoFullName && !!e.path
3644
- );
3694
+ if (!Array.isArray(raw?.entries)) return { scannedAt: null, entries: [] };
3695
+ return {
3696
+ scannedAt: typeof raw.scannedAt === "string" && raw.scannedAt ? raw.scannedAt : null,
3697
+ entries: raw.entries.filter(
3698
+ (e) => !!e && typeof e.repoFullName === "string" && typeof e.path === "string" && !!e.repoFullName && !!e.path
3699
+ )
3700
+ };
3645
3701
  } catch {
3646
- return [];
3702
+ return { scannedAt: null, entries: [] };
3647
3703
  }
3648
3704
  }
3649
- var SOURCE_TEXT;
3705
+ function readRepoMapCache(path) {
3706
+ return loadRepoMapCache(path).entries;
3707
+ }
3708
+ var REPO_SCAN_TTL_MS, UNKNOWN_LIST_SCAN_TTL_MS, SOURCE_TEXT;
3650
3709
  var init_repoDiscovery = __esm({
3651
3710
  "src/repoDiscovery.ts"() {
3652
3711
  "use strict";
@@ -3654,6 +3713,8 @@ var init_repoDiscovery = __esm({
3654
3713
  init_config();
3655
3714
  init_linkFailure();
3656
3715
  init_repoScan();
3716
+ REPO_SCAN_TTL_MS = 24 * 60 * 6e4;
3717
+ UNKNOWN_LIST_SCAN_TTL_MS = 60 * 6e4;
3657
3718
  SOURCE_TEXT = {
3658
3719
  configured: "\u043F\u0443\u0442\u044C \u0437\u0430\u0434\u0430\u043D \u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440\u043E\u043C",
3659
3720
  cache: "\u0438\u0437 \u043A\u044D\u0448\u0430 \u043C\u0430\u0448\u0438\u043D\u044B",
@@ -3998,10 +4059,10 @@ __export(statusCli_exports, {
3998
4059
  probeLink: () => probeLink,
3999
4060
  runStatusCli: () => runStatusCli
4000
4061
  });
4001
- import { existsSync as existsSync11, readFileSync as readFileSync14 } from "fs";
4062
+ import { existsSync as existsSync12, readFileSync as readFileSync14 } from "fs";
4002
4063
  function outboxPending(path) {
4003
4064
  try {
4004
- if (!existsSync11(path)) return 0;
4065
+ if (!existsSync12(path)) return 0;
4005
4066
  return readFileSync14(path, "utf8").split("\n").filter((l) => l.trim()).length;
4006
4067
  } catch {
4007
4068
  return null;
@@ -4140,8 +4201,8 @@ var init_statusCli = __esm({
4140
4201
  });
4141
4202
 
4142
4203
  // src/platform.ts
4143
- import { existsSync as existsSync12, readFileSync as readFileSync15 } from "fs";
4144
- import { delimiter, join as join19 } from "path";
4204
+ import { existsSync as existsSync13, readFileSync as readFileSync15 } from "fs";
4205
+ import { delimiter, join as join20 } from "path";
4145
4206
  function safeReadText(path) {
4146
4207
  try {
4147
4208
  return readFileSync15(path, "utf8");
@@ -4150,13 +4211,13 @@ function safeReadText(path) {
4150
4211
  }
4151
4212
  }
4152
4213
  function resolveCommandPath(name, env = process.env, platform = process.platform) {
4153
- if (name.includes("/") || name.includes("\\")) return existsSync12(name) ? name : null;
4214
+ if (name.includes("/") || name.includes("\\")) return existsSync13(name) ? name : null;
4154
4215
  const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
4155
4216
  const exts = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean) : [""];
4156
4217
  for (const dir of dirs) {
4157
4218
  for (const ext of exts) {
4158
- const candidate = join19(dir, name + ext);
4159
- if (existsSync12(candidate)) return candidate;
4219
+ const candidate = join20(dir, name + ext);
4220
+ if (existsSync13(candidate)) return candidate;
4160
4221
  }
4161
4222
  }
4162
4223
  return null;
@@ -4259,13 +4320,13 @@ var init_platform = __esm({
4259
4320
  });
4260
4321
 
4261
4322
  // src/envGate.ts
4262
- import { existsSync as existsSync13 } from "fs";
4263
- import { join as join20 } from "path";
4323
+ import { existsSync as existsSync14 } from "fs";
4324
+ import { join as join21 } from "path";
4264
4325
  import { createConnection } from "net";
4265
4326
  import { execa as execa3 } from "execa";
4266
4327
  function detectRequirements(repo, io = {}) {
4267
- const exists = io.exists ?? existsSync13;
4268
- const marker = COMPOSE_MARKERS.find((f) => exists(join20(repo, f)));
4328
+ const exists = io.exists ?? existsSync14;
4329
+ const marker = COMPOSE_MARKERS.find((f) => exists(join21(repo, f)));
4269
4330
  return marker ? [{ kind: "docker", raw: `docker (\u0434\u0435\u0442\u0435\u043A\u0442: ${marker})` }] : [];
4270
4331
  }
4271
4332
  async function dockerAlive() {
@@ -4297,7 +4358,7 @@ var init_envGate = __esm({
4297
4358
  });
4298
4359
 
4299
4360
  // src/doctor.ts
4300
- import { isAbsolute as isAbsolute4, join as join21 } from "path";
4361
+ import { isAbsolute as isAbsolute4, join as join22 } from "path";
4301
4362
  function ok(id, title, detail) {
4302
4363
  return { id, title, status: "ok", detail, remedies: [], critical: false };
4303
4364
  }
@@ -4504,7 +4565,7 @@ function checkRepos(io, snap) {
4504
4565
  critical: true
4505
4566
  };
4506
4567
  }
4507
- if (env && !io.exists(join21(env, ".git"))) {
4568
+ if (env && !io.exists(join22(env, ".git"))) {
4508
4569
  return {
4509
4570
  id: "repos",
4510
4571
  title,
@@ -4514,7 +4575,7 @@ function checkRepos(io, snap) {
4514
4575
  critical: true
4515
4576
  };
4516
4577
  }
4517
- const broken = snap.paths.filter((p) => !io.exists(join21(p, ".git")));
4578
+ const broken = snap.paths.filter((p) => !io.exists(join22(p, ".git")));
4518
4579
  if (broken.length) {
4519
4580
  return {
4520
4581
  id: "repos",
@@ -4679,11 +4740,11 @@ var init_doctor = __esm({
4679
4740
 
4680
4741
  // src/repoMemory.ts
4681
4742
  import { readFileSync as readFileSync16, writeFileSync as writeFileSync12 } from "fs";
4682
- import { join as join22 } from "path";
4743
+ import { join as join23 } from "path";
4683
4744
  function loadRememberedRepos(home) {
4684
4745
  let raw;
4685
4746
  try {
4686
- raw = readFileSync16(join22(home, FILE), "utf8");
4747
+ raw = readFileSync16(join23(home, FILE), "utf8");
4687
4748
  } catch {
4688
4749
  return [];
4689
4750
  }
@@ -4718,7 +4779,7 @@ __export(installCli_exports, {
4718
4779
  runInstallCli: () => runInstallCli,
4719
4780
  shortenError: () => shortenError
4720
4781
  });
4721
- import { existsSync as existsSync14 } from "fs";
4782
+ import { existsSync as existsSync15 } from "fs";
4722
4783
  function classifyChromiumError(message) {
4723
4784
  if (/playwright-core не установлен|Cannot find (module|package)/i.test(message)) return "no-module";
4724
4785
  if (/error while loading shared libraries|cannot open shared object file|missing dependencies|\blib[\w.+-]*\.so[.\d]*\b/i.test(message)) return "no-libs";
@@ -4773,7 +4834,7 @@ function defaultDoctorIO(packageVersion) {
4773
4834
  packageVersion,
4774
4835
  entry,
4775
4836
  hasCommand: (name) => hasCommandInPath(name),
4776
- exists: existsSync14,
4837
+ exists: existsSync15,
4777
4838
  run: (command) => runQuiet(command),
4778
4839
  latestVersion: () => latestMemo ??= fetchLatestVersion(),
4779
4840
  probeChromium: () => probeChromium(),
@@ -4982,13 +5043,13 @@ var init_updateCli = __esm({
4982
5043
 
4983
5044
  // src/cli.ts
4984
5045
  init_cliIo();
4985
- import { basename as basename4, join as join23 } from "path";
4986
- import { existsSync as existsSync15, readFileSync as readFileSync17, readdirSync as readdirSync9, statSync as statSync9, openSync as openSync4, readSync as readSync2, closeSync as closeSync4, rmSync as rmSync2 } from "fs";
5046
+ import { basename as basename4, join as join24 } from "path";
5047
+ import { existsSync as existsSync16, readFileSync as readFileSync17, readdirSync as readdirSync10, statSync as statSync10, openSync as openSync4, readSync as readSync2, closeSync as closeSync4, rmSync as rmSync2 } from "fs";
4987
5048
  import { setTimeout as sleep2 } from "timers/promises";
4988
5049
 
4989
5050
  // src/swarmClient.ts
4990
5051
  init_config();
4991
- import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
5052
+ import { appendFileSync, existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
4992
5053
  import { dirname } from "path";
4993
5054
  import { randomUUID } from "crypto";
4994
5055
 
@@ -5195,8 +5256,34 @@ init_names2();
5195
5256
  init_storm();
5196
5257
 
5197
5258
  // src/logTail.ts
5198
- import { closeSync, existsSync as existsSync3, openSync, readSync, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
5259
+ import { closeSync, existsSync as existsSync4, openSync, readSync, readdirSync as readdirSync5, statSync as statSync4 } from "fs";
5260
+ import { join as join6 } from "path";
5261
+
5262
+ // src/runLogDir.ts
5263
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync4, renameSync as renameSync2, statSync as statSync3 } from "fs";
5199
5264
  import { join as join5 } from "path";
5265
+ function runLogDirName(runLogKey) {
5266
+ const safe = runLogKey.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "");
5267
+ if (!safe) throw new Error(`\u043A\u043B\u044E\u0447 \u043F\u0440\u043E\u0433\u043E\u043D\u0430 \u043D\u0435 \u0433\u043E\u0434\u0438\u0442\u0441\u044F \u0432 \u0438\u043C\u044F \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0430 run-log'\u043E\u0432: ${JSON.stringify(runLogKey)}`);
5268
+ return /^\d+$/.test(safe) ? `k-${safe}` : safe;
5269
+ }
5270
+ function runLogDirPath(runLogsDir, runLogKey) {
5271
+ return join5(runLogsDir, runLogDirName(runLogKey));
5272
+ }
5273
+ function findRunLogDir(runLogsDir, keys) {
5274
+ for (const key of keys) {
5275
+ const dir = runLogDirPath(runLogsDir, key);
5276
+ if (existsSync3(dir)) return dir;
5277
+ }
5278
+ return null;
5279
+ }
5280
+ function runLogDirForWrite(runLogsDir, keys) {
5281
+ const canonical = keys[0];
5282
+ if (!canonical) return null;
5283
+ return findRunLogDir(runLogsDir, keys) ?? runLogDirPath(runLogsDir, canonical);
5284
+ }
5285
+
5286
+ // src/logTail.ts
5200
5287
  var TAIL_LINES_FALLBACK = 200;
5201
5288
  var TAIL_MAX_BYTES_FALLBACK = 64 * 1024;
5202
5289
 
@@ -5283,6 +5370,11 @@ var SwarmClient = class _SwarmClient {
5283
5370
  // Живёт в памяти: рестарт демона просто переспросит прод.
5284
5371
  depsProd = /* @__PURE__ */ new Map();
5285
5372
  depsAskedAt = /* @__PURE__ */ new Map();
5373
+ // TASWM-399: ссылка dependsOn → человеко-слаг блокера, как его назвал прод. В колонке
5374
+ // лежит cuid прогона или задачи, а в лог демона, в событие прогона и в аномалию должен
5375
+ // уходить «TASWM-392», а не «cmsqqpphd011y2xtbckkut6bj» — иначе оператору всё равно
5376
+ // нечего искать. Резолв делает прод (см. resolveDepRefs), здесь только запоминаем.
5377
+ depSlug = /* @__PURE__ */ new Map();
5286
5378
  static DEPS_TTL_MS = 6e4;
5287
5379
  // TASWM-327: прод старее демона и не знает аргумент `level` у swarm_notify. Выясняется
5288
5380
  // ровно один раз (первой отвергнутой нотификацией) и держится всю жизнь процесса — иначе
@@ -5342,25 +5434,25 @@ var SwarmClient = class _SwarmClient {
5342
5434
  this.repoFullNames = registry.map((e) => e.repoFullName);
5343
5435
  this.repoPathByFullName = new Map(registry.map((e) => [e.repoFullName, normalizeRepo(e.path)]));
5344
5436
  this.host = opts.host ?? null;
5345
- this.repoExists = opts.repoExists ?? existsSync4;
5437
+ this.repoExists = opts.repoExists ?? existsSync5;
5346
5438
  this.questionSyncMs = opts.questionSyncMs ?? 3e3;
5347
5439
  this.executorId = opts.executorId ?? EXECUTOR.id;
5348
5440
  this.outboxPath = opts.outboxPath;
5349
5441
  this.mapPath = `${opts.outboxPath}.prodmap.json`;
5350
5442
  this.stormPath = `${opts.outboxPath}.storm.jsonl`;
5351
- mkdirSync4(dirname(this.outboxPath), { recursive: true });
5443
+ mkdirSync5(dirname(this.outboxPath), { recursive: true });
5352
5444
  this.recover();
5353
5445
  }
5354
5446
  // ── рестарт: поднять outbox (незаписанные операции) и карту runKey→prodId ─────
5355
5447
  recover() {
5356
- if (existsSync4(this.mapPath)) {
5448
+ if (existsSync5(this.mapPath)) {
5357
5449
  try {
5358
5450
  const obj = JSON.parse(readFileSync3(this.mapPath, "utf8"));
5359
5451
  for (const [k, v] of Object.entries(obj)) this.prodIdByKey.set(k, v);
5360
5452
  } catch {
5361
5453
  }
5362
5454
  }
5363
- if (existsSync4(this.outboxPath)) {
5455
+ if (existsSync5(this.outboxPath)) {
5364
5456
  const txt = readFileSync3(this.outboxPath, "utf8");
5365
5457
  for (const line of txt.split("\n")) {
5366
5458
  const s = line.trim();
@@ -5381,7 +5473,7 @@ var SwarmClient = class _SwarmClient {
5381
5473
  // eventsLog ПЕРВЫМИ (до hydrate), в порядке файла (= хронологический); id
5382
5474
  // переприсваиваем локально (id событий — только порядок, не корреляция с продом).
5383
5475
  replayGlobalEvents() {
5384
- if (!existsSync4(this.stormPath)) return;
5476
+ if (!existsSync5(this.stormPath)) return;
5385
5477
  let txt;
5386
5478
  try {
5387
5479
  txt = readFileSync3(this.stormPath, "utf8");
@@ -5646,6 +5738,24 @@ var SwarmClient = class _SwarmClient {
5646
5738
  const viaProd = [...this.runs.values()].find((r) => r.runKey === `p:${prodId}` || this.prodIdByKey.get(r.runKey) === prodId);
5647
5739
  return viaProd ? this.mat(viaProd) : null;
5648
5740
  }
5741
+ /**
5742
+ * TASWM-419: адреса каталога run-log'ов прогона. КАНОНИЧЕСКИЙ — прод-вида `p:<cuid>`, как
5743
+ * только cuid известен: он один и тот же во всех жизнях демона, а локальный uuid прогона
5744
+ * после рестарта уже не воскресает (гидрированный прогон приезжает под прод-ключом).
5745
+ * Дальше — псевдонимы: uuid, под которым каталог мог завестись в окно между спавном и
5746
+ * первым флашем в прод. По ним каталог ищется, чтобы запуски одного прогона не расползлись
5747
+ * по двум каталогам (иначе cost недосчитал бы часть, а хвост показал бы не тот запуск).
5748
+ * Номер прогона в адресе не участвует вовсе — он и есть источник «лога чужого прогона».
5749
+ */
5750
+ runLogKeys(id) {
5751
+ const key = this.runs.get(id)?.runKey;
5752
+ if (!key) return [];
5753
+ const prodId = key.startsWith("p:") ? key.slice(2) : this.prodIdByKey.get(key);
5754
+ if (!prodId) return [key];
5755
+ const canonical = `p:${prodId}`;
5756
+ const aliases = [key, ...[...this.prodIdByKey.entries()].filter(([, v]) => v === prodId).map(([k]) => k)];
5757
+ return [canonical, ...new Set(aliases.filter((k) => k !== canonical))];
5758
+ }
5649
5759
  listRuns(status) {
5650
5760
  const all = [...this.runs.values()].filter((r) => status ? r.status === status : true);
5651
5761
  all.sort((a, b) => b.id - a.id);
@@ -5762,16 +5872,25 @@ var SwarmClient = class _SwarmClient {
5762
5872
  /**
5763
5873
  * TASWM-361: вердикт по каждой зависимости — см. докблок SwarmStore.depVerdicts.
5764
5874
  *
5765
- * АСИММЕТРИЯ ЗЕРКАЛА И ПРОДА, и почему она здесь намеренная. Зеркало — ЧАСТИЧНАЯ выборка
5766
- * (только прогоны этой машины, только окно гидрации), и по частичной выборке два вывода
5767
- * стоят разного:
5768
- * • «есть смерженный прогон слага» — вывод от НАЛИЧИЯ: чего зеркало не видит, того он не
5769
- * портит, поэтому успех подтверждаем и локально (так было до TASWM-361);
5770
- * • «живых прогонов слага не осталось» — вывод от ОТСУТСТВИЯ: невидимый зеркалу прогон
5771
- * на соседней машине как раз и может быть живым. Поэтому «исчерпана неуспешно»
5772
- * принимаем ТОЛЬКО из ответа прода (refreshDeps) — он видит всех.
5773
- * Прод недоступен → вердикта нет → прогон честно ждёт следующего окна: лишняя минута
5774
- * очереди дешевле дрона, поднятого поверх работающего блокера.
5875
+ * ИСТОЧНИК ПРАВДЫ — ПРОД (TASWM-399), зеркало здесь только быстрый путь. Зеркало —
5876
+ * ЧАСТИЧНАЯ выборка (прогоны этой машины, окно гидрации), и по частичной выборке два
5877
+ * вывода стоят разного:
5878
+ * • «есть смерженный прогон слага» — вывод от НАЛИЧИЯ: чего зеркало не видит, того он
5879
+ * не портит, поэтому успех подтверждаем и локально (так было до TASWM-361);
5880
+ * • всё остальное — вывод от ОТСУТСТВИЯ, и его принимаем ТОЛЬКО из ответа прода
5881
+ * (refreshDeps): невидимый зеркалу прогон на соседней машине как раз и может быть
5882
+ * живым, а закрытый с прода — давно смерженным.
5883
+ *
5884
+ * ССЫЛКА — НЕ ОБЯЗАТЕЛЬНО СЛАГ. В `dependsOn` лежит чаще всего cuid ПРОГОНА (так пишет
5885
+ * overmind), реже cuid задачи, и только дев-спавн `--after` пишет человеко-слаг. Быстрый
5886
+ * путь по зеркалу сравнивает `task_slug`, то есть срабатывает лишь на третьей форме —
5887
+ * это нормально: остальные две резолвит прод. А вот считать «раз в зеркале не нашёл —
5888
+ * значит не готово» было корнем TASWM-399.
5889
+ *
5890
+ * Прода ещё не спрашивали (первый тик) или он моргнул → `pending`: прогон честно ждёт
5891
+ * следующего окна. Лишняя минута очереди дешевле дрона, поднятого поверх работающего
5892
+ * блокера. `unknown` (блокера НЕ ЗНАЕТ И ПРОД) ставит только ответ прода — местным
5893
+ * незнанием такой вывод не делается.
5775
5894
  */
5776
5895
  depVerdicts(depends_on) {
5777
5896
  if (!depends_on || depends_on.length === 0) return [];
@@ -5781,57 +5900,89 @@ var SwarmClient = class _SwarmClient {
5781
5900
  doneInMirror.add(r.task_slug);
5782
5901
  }
5783
5902
  }
5784
- return depends_on.map((slug) => {
5785
- if (doneInMirror.has(slug)) {
5786
- return { slug, outcome: "done", met: true, status: "done", name: null, stopReason: null };
5903
+ return depends_on.map((ref) => {
5904
+ if (doneInMirror.has(ref)) {
5905
+ return { slug: ref, outcome: "done", met: true, status: "done", name: null, stopReason: null };
5906
+ }
5907
+ const fromProd = this.depsProd.get(ref);
5908
+ if (fromProd) {
5909
+ if (fromProd.outcome === "unknown" && this.knowsDepRef(ref)) {
5910
+ return { slug: this.depSlug.get(ref) ?? ref, outcome: "pending", met: false, status: null, name: null, stopReason: null };
5911
+ }
5912
+ return { slug: this.depSlug.get(ref) ?? ref, ...fromProd };
5787
5913
  }
5788
- const fromProd = this.depsProd.get(slug);
5789
- if (fromProd) return { slug, ...fromProd };
5790
- return { slug, outcome: "pending", met: false, status: null, name: null, stopReason: null };
5914
+ return { slug: ref, outcome: "pending", met: false, status: null, name: null, stopReason: null };
5791
5915
  });
5792
5916
  }
5793
5917
  /**
5794
- * TASWM-300: спросить ПРОД про зависимости — см. докблок SwarmStore.refreshDeps.
5918
+ * Знает ли ЗЕРКАЛО хоть один прогон по ссылке `dependsOn` (TASWM-399). Ссылка бывает
5919
+ * слагом, cuid'ом задачи и cuid'ом прогона — последний сверяем с прод-id, как это
5920
+ * делают runByProdId/refreshRun. Служебные сабраны (ревьюер/резолвер/верификатор) не
5921
+ * в счёт: их существование не означает, что жив сам блокер.
5922
+ */
5923
+ knowsDepRef(ref) {
5924
+ for (const r of this.runs.values()) {
5925
+ if (r.resolves_run_id != null || r.reviews_run_id != null || r.verifies_run_id != null) continue;
5926
+ if (r.task_slug === ref) return true;
5927
+ if (r.runKey === `p:${ref}` || this.prodIdByKey.get(r.runKey) === ref) return true;
5928
+ }
5929
+ return false;
5930
+ }
5931
+ /**
5932
+ * TASWM-300 → TASWM-399: спросить ПРОД про зависимости — см. докблок SwarmStore.refreshDeps.
5933
+ *
5934
+ * Почему зеркала мало. В нём лежат только прогоны ЭТОЙ машины (гейт isMine, hydrateRun) и
5935
+ * только те, что попали в окно гидрации (`swarm_list_runs limit`, сортировка по updatedAt
5936
+ * desc — сутки работы роя). Зависимость, закрытая на соседней машине, обнулившаяся при
5937
+ * рестарте демона или просто позавчерашняя, в зеркало не попадала — и `dependsOn` держал
5938
+ * прогон в очереди вечно.
5795
5939
  *
5796
- * Почему зеркала было мало. В нём лежат только прогоны ЭТОЙ машины (гейт isMine,
5797
- * hydrateRun) и только те, что попали в окно гидрации (`swarm_list_runs limit`, сортировка
5798
- * по updatedAt desc — сутки работы роя). Зависимость, закрытая на соседней машине или
5799
- * просто позавчера, в зеркало не попадала — и `--after` держал прогон в очереди вечно
5800
- * (04.08: снимали руками).
5940
+ * ЧЕМ СПРАШИВАЕМ И ПОЧЕМУ НЕ `swarm_list_runs` (корень TASWM-399). До этой задачи здесь
5941
+ * шёл `swarm_list_runs { taskId: <ref> }` — то есть ссылка трактовалась как ЗАДАЧА. Но в
5942
+ * `dependsOn` чаще всего лежит cuid ПРОГОНА: `resolveTaskRef` его задачей не признаёт,
5943
+ * ответ приходит `isError`, вердикт не появляется НИКОГДА, и прогон стоит молча при
5944
+ * свободной машине (12–13.08 — пять остановок роя за сутки). `swarm_deps_status` понимает
5945
+ * все три формы ссылки и отвечает по единому правилу роя (`judgeDep` на прод-стороне).
5801
5946
  *
5802
5947
  * Кеш. «Смержено» МОНОТОННО: прогон, ставший done, назад не откатывается, — поэтому
5803
5948
  * положительный ответ помним навсегда и больше не спрашиваем. Остальные вердикты (включая
5804
- * «исчерпана неуспешно», TASWM-361 — блокер могут переиграть новым прогоном) переспрашиваем
5805
- * не чаще DEPS_TTL_MS: тик идёт раз в ~4с, а ждущих зависимость прогонов может быть
5806
- * много — без TTL это был бы поход в прод на каждый слаг каждый тик.
5949
+ * «исчерпана неуспешно» и «блокера не знает никто») переспрашиваем не чаще DEPS_TTL_MS:
5950
+ * оператор вправе переиграть блокер новым прогоном, а тик идёт раз в ~4 с — без TTL это
5951
+ * был бы поход в прод на каждую ссылку каждый тик.
5952
+ *
5953
+ * Одним вызовом на все просроченные ссылки, а не по вызову на ссылку: очередь ждущих
5954
+ * бывает в десятки прогонов, и N запросов на тик — это тот же самый поход в прод, только
5955
+ * дороже.
5807
5956
  */
5808
- async refreshDeps(slugs, nowMs = Date.now()) {
5809
- for (const slug of new Set(slugs)) {
5810
- if (!slug || this.depsProd.get(slug)?.outcome === "done") continue;
5811
- const asked = this.depsAskedAt.get(slug);
5812
- if (asked !== void 0 && nowMs - asked < _SwarmClient.DEPS_TTL_MS) continue;
5813
- this.depsAskedAt.set(slug, nowMs);
5814
- let res;
5815
- try {
5816
- res = await this.call("swarm_list_runs", { taskId: slug });
5817
- } catch {
5818
- this.depsAskedAt.delete(slug);
5819
- continue;
5820
- }
5821
- if (res?.isError) continue;
5822
- const runs = res.structuredContent?.runs ?? [];
5823
- const facts = runs.slice().sort((a, b) => String(a.createdAt ?? "").localeCompare(String(b.createdAt ?? ""))).map((r) => ({
5824
- status: String(r.status ?? ""),
5825
- // Служебный сабран под тем же слагом (резолвер конфликта / ревьюер): его финал —
5826
- // не финал задачи. Резолвер вычёркивался и до TASWM-361, ревьюер добавлен вместе
5827
- // с расширением критерия — иначе остановленный ревьюер закрывал бы зависимость.
5828
- service: Boolean(r.resolvesRunId) || Boolean(r.reviewsRunId) || Boolean(r.verifiesRunId),
5829
- // В проде имя дрона — колонка droneName (см. hydrateRun); `name` терпим как
5830
- // запасной вариант ради фикстур и старых ответов.
5831
- name: r.droneName ?? r.name ?? null,
5832
- stopReason: r.stopReason ?? null
5833
- }));
5834
- this.depsProd.set(slug, judgeDep(facts));
5957
+ async refreshDeps(refs, nowMs = Date.now()) {
5958
+ const ask = [...new Set(refs)].filter((ref) => {
5959
+ if (!ref || this.depsProd.get(ref)?.outcome === "done") return false;
5960
+ const asked = this.depsAskedAt.get(ref);
5961
+ return asked === void 0 || nowMs - asked >= _SwarmClient.DEPS_TTL_MS;
5962
+ });
5963
+ if (!ask.length) return;
5964
+ for (const ref of ask) this.depsAskedAt.set(ref, nowMs);
5965
+ let res;
5966
+ try {
5967
+ res = await this.call("swarm_deps_status", { refs: ask });
5968
+ } catch {
5969
+ for (const ref of ask) this.depsAskedAt.delete(ref);
5970
+ return;
5971
+ }
5972
+ if (res?.isError) return;
5973
+ const deps = res.structuredContent?.deps ?? [];
5974
+ for (const d of deps) {
5975
+ const ref = typeof d.ref === "string" ? d.ref : null;
5976
+ if (!ref) continue;
5977
+ const outcome = String(d.outcome ?? "pending");
5978
+ this.depsProd.set(ref, {
5979
+ outcome,
5980
+ met: Boolean(d.met),
5981
+ status: d.status ?? null,
5982
+ name: d.name ?? null,
5983
+ stopReason: d.stopReason ?? null
5984
+ });
5985
+ if (typeof d.slug === "string" && d.slug) this.depSlug.set(ref, d.slug);
5835
5986
  }
5836
5987
  }
5837
5988
  /**
@@ -6150,7 +6301,7 @@ var SwarmClient = class _SwarmClient {
6150
6301
  * транзиентным сбоем ОС, который этот PR должен переживать, а не превращать в мор.
6151
6302
  */
6152
6303
  reconcileRuntime(opts) {
6153
- const wtExists = opts.worktreeExists ?? existsSync4;
6304
+ const wtExists = opts.worktreeExists ?? existsSync5;
6154
6305
  const pidByWorktree = /* @__PURE__ */ new Map();
6155
6306
  try {
6156
6307
  for (const p of opts.listProcesses()) if (p.worktree) pidByWorktree.set(p.worktree, p.pid);
@@ -6639,6 +6790,12 @@ var SwarmClient = class _SwarmClient {
6639
6790
  ...typeof h.paused === "boolean" ? { paused: h.paused } : {},
6640
6791
  ...typeof h.pausedAt === "string" ? { pausedAt: h.pausedAt } : {},
6641
6792
  ...typeof h.pauseReason === "string" ? { pauseReason: h.pauseReason } : {},
6793
+ // TASWM-424: «доступ отозван» — состояние ровно того же рода, что пауза, и
6794
+ // формулировка у него ОДНА на все поверхности (HOST_ACCESS_REVOKED_TEXT):
6795
+ // `hostLines` зовёт тот же `hostStateText`, что и парк машин в /swarm. Без
6796
+ // этого поля `swarm hosts` показывал отвергнутую машину просто молчащей —
6797
+ // то самое различие, ради которого флаг и заводили (TASWM-324).
6798
+ ...typeof h.accessRevoked === "boolean" ? { accessRevoked: h.accessRevoked } : {},
6642
6799
  // Показаний нет (старый прод, машина со старым демоном) — ключа не заводим вовсе,
6643
6800
  // ровно как с паузой выше: «не знаем» и так значит «не знаем».
6644
6801
  ...resources ? { resources } : {}
@@ -6724,9 +6881,18 @@ var SwarmClient = class _SwarmClient {
6724
6881
  * ВСЕГДА (в т.ч. `storm:false`), как паузу: это состояние, а не команда, и молчание тут
6725
6882
  * означало бы «прод помнит вчерашний шторм», когда рой давно работает.
6726
6883
  *
6884
+ * TASWM-402: тем же пульсом уезжают КОРНИ WORKTREE'ЕВ этой машины (`worktreeRoots` —
6885
+ * `owner/repo` → `<worktreesDir>/<repo-basename>`). Это НЕ рантайм и правило «pid и путь
6886
+ * дрона в прод не уходят» не трогает: корень — статичная конфигурация машины, того же
6887
+ * рода, что реестр репо и ёмкость, он не зависит ни от прогона, ни от живости процесса и
6888
+ * не протухает между пульсами. Прод склеивает `<корень>/<слаг>` сам — и отвечает человеку
6889
+ * на вопрос «куда идти руками», когда команда машине не доиграла (а в этом случае, по
6890
+ * определению, спросить рантайм у молчащей машины уже нельзя). Не передали корень (не
6891
+ * знаем своего каталога) → ключа нет, прод прошлое не трогает — как с capacity/resources.
6892
+ *
6727
6893
  * Прод недоступен / ответ без поручений → пустые списки (демон работает дальше).
6728
6894
  */
6729
- async heartbeat(daemonVersion, pause, capacity, resources, storm, signIn) {
6895
+ async heartbeat(daemonVersion, pause, capacity, resources, storm, worktreesDir, signIn) {
6730
6896
  try {
6731
6897
  const args = this.repoFullNames.length ? { repoFullNames: this.repoFullNames } : {};
6732
6898
  if (daemonVersion) args.daemonVersion = daemonVersion;
@@ -6744,6 +6910,11 @@ var SwarmClient = class _SwarmClient {
6744
6910
  args.storm = storm.active;
6745
6911
  if (storm.reason) args.stormReason = storm.reason;
6746
6912
  }
6913
+ if (worktreesDir) {
6914
+ const roots = {};
6915
+ for (const [full, path] of this.repoPathByFullName) roots[full] = droneWorktreeRoot(worktreesDir, path);
6916
+ if (Object.keys(roots).length) args.worktreeRoots = roots;
6917
+ }
6747
6918
  if (this.host) {
6748
6919
  args.hostId = this.host.hostId;
6749
6920
  args.hostName = this.host.hostName;
@@ -6892,10 +7063,20 @@ var SwarmClient = class _SwarmClient {
6892
7063
  * именно путь. Не нашли в реестре (репо не наш / ручной `--repo <path>` спавн, где
6893
7064
  * repo — ВСЕГДА уже путь, старый формат) → возвращаем как есть без изменений
6894
7065
  * (обратная совместимость: раньше pr.repo ВСЕГДА был локальным путём).
7066
+ *
7067
+ * РЕГИСТР НЕ РАЗЛИЧАЕМ (TASWM-422). Ключи карты приведены к нижнему регистру
7068
+ * (parseRepoFullName — «GitHub сам не различает case в owner/repo»), а прод присылает
7069
+ * `run.repo` тем регистром, каким репозиторий записан в проекте. 17.08 это разошлось:
7070
+ * машина объявляла `ilyasmurov/ansamblist`, задача пришла с `ilyasmurov/Ansamblist`,
7071
+ * `Map.get` промахнулся — и вместо пути в прогон уехало полное имя репозитория. Дальше
7072
+ * git-операции шли в несуществующий каталог, прогон умирал за 5 секунд, а задача
7073
+ * возвращалась в очередь и бралась снова: пять павших прогонов за пять минут.
7074
+ * Возвращаем ИСХОДНУЮ строку, а не приведённую: промах бывает и на локальном пути
7075
+ * (ручной спавн), а путь регистр различает — вернув lowercase, мы сломали бы его.
6895
7076
  */
6896
7077
  resolveRepo(raw) {
6897
7078
  if (!raw) return "";
6898
- return this.repoPathByFullName.get(raw) ?? raw;
7079
+ return this.repoPathByFullName.get(raw) ?? this.repoPathByFullName.get(raw.toLowerCase()) ?? raw;
6899
7080
  }
6900
7081
  /**
6901
7082
  * TASWM-281: МОЯ ли это работа. Три исхода:
@@ -7493,20 +7674,20 @@ function tailFile(path, lines) {
7493
7674
  }
7494
7675
 
7495
7676
  // src/logRotate.ts
7496
- import { copyFileSync, renameSync as renameSync2, statSync as statSync4, fstatSync, truncateSync, unlinkSync, existsSync as existsSync5 } from "fs";
7677
+ import { copyFileSync, renameSync as renameSync3, statSync as statSync5, fstatSync, truncateSync, unlinkSync, existsSync as existsSync6 } from "fs";
7497
7678
  var realIo = {
7498
7679
  size: (path) => {
7499
7680
  try {
7500
- return statSync4(path).size;
7681
+ return statSync5(path).size;
7501
7682
  } catch {
7502
7683
  return null;
7503
7684
  }
7504
7685
  },
7505
7686
  copy: copyFileSync,
7506
- rename: renameSync2,
7687
+ rename: renameSync3,
7507
7688
  truncate: (path) => truncateSync(path, 0),
7508
7689
  unlink: unlinkSync,
7509
- exists: existsSync5
7690
+ exists: existsSync6
7510
7691
  };
7511
7692
  var STRAY_GENERATIONS = 8;
7512
7693
  function rotateDaemonLog(path, opts, io = realIo) {
@@ -7764,18 +7945,18 @@ init_repoRegistry();
7764
7945
 
7765
7946
  // src/hostIdentity.ts
7766
7947
  import { hostname } from "os";
7767
- import { existsSync as existsSync6, readFileSync as readFileSync5, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs";
7768
- import { join as join6 } from "path";
7948
+ import { existsSync as existsSync7, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync4 } from "fs";
7949
+ import { join as join7 } from "path";
7769
7950
  import { randomUUID as randomUUID2 } from "crypto";
7770
7951
  function hostIdentityPath(home) {
7771
- return join6(home, "host.json");
7952
+ return join7(home, "host.json");
7772
7953
  }
7773
7954
  function loadHostIdentity(deps) {
7774
- const exists = deps.exists ?? existsSync6;
7955
+ const exists = deps.exists ?? existsSync7;
7775
7956
  const readFile2 = deps.readFile ?? ((p) => readFileSync5(p, "utf8"));
7776
7957
  const writeFile3 = deps.writeFile ?? ((p, data) => {
7777
7958
  writeFileSync4(`${p}.tmp`, data);
7778
- renameSync3(`${p}.tmp`, p);
7959
+ renameSync4(`${p}.tmp`, p);
7779
7960
  });
7780
7961
  const host = deps.hostname ?? hostname;
7781
7962
  const newId = deps.newId ?? randomUUID2;
@@ -7889,8 +8070,8 @@ import { basename as basename2 } from "path";
7889
8070
  import { serve as serve2 } from "@hono/node-server";
7890
8071
 
7891
8072
  // src/diff.ts
7892
- import { statSync as statSync5 } from "fs";
7893
- import { join as join7 } from "path";
8073
+ import { statSync as statSync6 } from "fs";
8074
+ import { join as join8 } from "path";
7894
8075
  import { $ as $3 } from "execa";
7895
8076
  var MAX_FILES = 200;
7896
8077
  var MAX_LINES_PER_FILE = 1500;
@@ -8033,7 +8214,7 @@ async function resolveBase(wt, baseBranch) {
8033
8214
  }
8034
8215
  function mtime(path) {
8035
8216
  try {
8036
- return statSync5(path).mtimeMs;
8217
+ return statSync6(path).mtimeMs;
8037
8218
  } catch {
8038
8219
  return 0;
8039
8220
  }
@@ -8058,7 +8239,7 @@ async function collectRunDiff(worktree, opts = {}) {
8058
8239
  files.push(pf);
8059
8240
  }
8060
8241
  }
8061
- files.sort((a, b) => mtime(join7(worktree, b.path)) - mtime(join7(worktree, a.path)));
8242
+ files.sort((a, b) => mtime(join8(worktree, b.path)) - mtime(join8(worktree, a.path)));
8062
8243
  const truncatedFiles = files.length > MAX_FILES;
8063
8244
  if (truncatedFiles) files = files.slice(0, MAX_FILES);
8064
8245
  const added = files.reduce((s, f) => s + f.added, 0);
@@ -8211,13 +8392,13 @@ function deniedSinceLine(nowMs) {
8211
8392
  init_config();
8212
8393
  import { createHash, randomBytes as randomBytes2 } from "crypto";
8213
8394
  import { readFileSync as readFileSync8, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3 } from "fs";
8214
- import { join as join9 } from "path";
8395
+ import { join as join10 } from "path";
8215
8396
  var REGISTER = "/api/oauth/register";
8216
8397
  var AUTHORIZE = "/api/oauth/authorize";
8217
8398
  var TOKEN = "/api/oauth/token";
8218
8399
  var SCOPE = "openid profile email taskless:full";
8219
- var STORE_PATH2 = join9(SWARM_HOME, "oauth.json");
8220
- var CLIENT_PATH = join9(SWARM_HOME, "oauth-client.json");
8400
+ var STORE_PATH2 = join10(SWARM_HOME, "oauth.json");
8401
+ var CLIENT_PATH = join10(SWARM_HOME, "oauth-client.json");
8221
8402
  function makePkce() {
8222
8403
  const verifier = randomBytes2(48).toString("base64url");
8223
8404
  const challenge = createHash("sha256").update(verifier).digest("base64url");
@@ -9944,15 +10125,15 @@ import { cancelRun, respawnRun, runDaemon, wakeRun, OVERMIND_ENTRY, listDronePro
9944
10125
 
9945
10126
  // src/launcher.ts
9946
10127
  import { spawn as spawn2 } from "child_process";
9947
- import { closeSync as closeSync3, existsSync as existsSync7, mkdirSync as mkdirSync7, openSync as openSync3, readdirSync as readdirSync6 } from "fs";
10128
+ import { closeSync as closeSync3, existsSync as existsSync8, mkdirSync as mkdirSync8, openSync as openSync3, readdirSync as readdirSync7 } from "fs";
9948
10129
  import { createRequire } from "module";
9949
10130
  import { homedir as homedir3 } from "os";
9950
- import { dirname as dirname3, join as join11 } from "path";
10131
+ import { dirname as dirname3, join as join12 } from "path";
9951
10132
  import { fileURLToPath } from "url";
9952
10133
 
9953
10134
  // src/droneHome.ts
9954
- import { lstatSync, mkdirSync as mkdirSync6, readdirSync as readdirSync5, rmSync, statSync as statSync6, symlinkSync as symlinkSync2 } from "fs";
9955
- import { join as join10, relative, sep } from "path";
10135
+ import { lstatSync, mkdirSync as mkdirSync7, readdirSync as readdirSync6, rmSync, statSync as statSync7, symlinkSync as symlinkSync2 } from "fs";
10136
+ import { join as join11, relative, sep } from "path";
9956
10137
  var DRONE_HOME_DENY_DEFAULT = [".taskless-swarm", ".bash_history", ".zsh_history"];
9957
10138
  function droneHomeDeny(env = process.env) {
9958
10139
  const extra = (env.SWARM_DRONE_HOME_DENY || "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -9970,29 +10151,29 @@ function buildDroneHome(opts) {
9970
10151
  const { realHome, droneHome } = opts;
9971
10152
  const deny = opts.deny ?? droneHomeDeny();
9972
10153
  const platform = opts.platform ?? process.platform;
9973
- mkdirSync6(droneHome, { recursive: true, mode: 448 });
9974
- const wanted = new Set(mirroredEntries(readdirSync5(realHome), deny, platform));
10154
+ mkdirSync7(droneHome, { recursive: true, mode: 448 });
10155
+ const wanted = new Set(mirroredEntries(readdirSync6(realHome), deny, platform));
9975
10156
  const rel = relative(realHome, droneHome);
9976
10157
  if (rel && !rel.startsWith("..") && !rel.includes(sep)) wanted.delete(rel);
9977
- for (const existing of readdirSync5(droneHome)) {
10158
+ for (const existing of readdirSync6(droneHome)) {
9978
10159
  if (wanted.has(existing)) continue;
9979
- const p = join10(droneHome, existing);
10160
+ const p = join11(droneHome, existing);
9980
10161
  try {
9981
10162
  if (lstatSync(p).isSymbolicLink()) rmSync(p);
9982
10163
  } catch {
9983
10164
  }
9984
10165
  }
9985
10166
  for (const name of wanted) {
9986
- const link = join10(droneHome, name);
10167
+ const link = join11(droneHome, name);
9987
10168
  try {
9988
10169
  lstatSync(link);
9989
10170
  continue;
9990
10171
  } catch {
9991
10172
  }
9992
- const target = join10(realHome, name);
10173
+ const target = join11(realHome, name);
9993
10174
  let isDir;
9994
10175
  try {
9995
- isDir = statSync6(target).isDirectory();
10176
+ isDir = statSync7(target).isDirectory();
9996
10177
  } catch {
9997
10178
  continue;
9998
10179
  }
@@ -10010,7 +10191,7 @@ var REAL_PATH_ENV_PINS = [
10010
10191
  { env: "YARN_GLOBAL_FOLDER", segments: [".yarn", "berry"] }
10011
10192
  ];
10012
10193
  function realPathEnvPins(realHome) {
10013
- return Object.fromEntries(REAL_PATH_ENV_PINS.map(({ env, segments }) => [env, join10(realHome, ...segments)]));
10194
+ return Object.fromEntries(REAL_PATH_ENV_PINS.map(({ env, segments }) => [env, join11(realHome, ...segments)]));
10014
10195
  }
10015
10196
  function dronePrivatePaths(realHome, swarmHome) {
10016
10197
  return [
@@ -10122,7 +10303,7 @@ function droneEnv(worktree, base3 = process.env, isolation, droneCap = null) {
10122
10303
  const env = { ...base3 };
10123
10304
  const isolate = base3.SWARM_TURBO_ISOLATE !== "0";
10124
10305
  if (isolate && !base3.TURBO_CACHE_DIR) {
10125
- env.TURBO_CACHE_DIR = join11(worktree, ".turbo", "cache");
10306
+ env.TURBO_CACHE_DIR = join12(worktree, ".turbo", "cache");
10126
10307
  }
10127
10308
  Object.assign(env, droneTestEnv(droneCap, base3));
10128
10309
  for (const key of DAEMON_STATE_ENV) delete env[key];
@@ -10150,20 +10331,21 @@ var DAEMON_STATE_ENV = [
10150
10331
  "TASKLESS_TOKEN"
10151
10332
  ];
10152
10333
  function bundledRoleDir(kind) {
10153
- return join11(dirname3(fileURLToPath(import.meta.url)), "..", "roles", kind);
10334
+ return join12(dirname3(fileURLToPath(import.meta.url)), "..", "roles", kind);
10154
10335
  }
10155
10336
  function roleDir(worktree, kind) {
10156
- return join11(worktree, ".claude", "roles", kind);
10337
+ return join12(worktree, ".claude", "roles", kind);
10157
10338
  }
10158
10339
  function roleDirs(worktree, kind) {
10159
- return [bundledRoleDir(kind), roleDir(worktree, kind)].filter((dir) => existsSync7(dir));
10340
+ return [bundledRoleDir(kind), roleDir(worktree, kind)].filter((dir) => existsSync8(dir));
10160
10341
  }
10161
- function nextRunLogPath(runLogsDir, runId) {
10162
- const dir = join11(runLogsDir, String(runId));
10163
- mkdirSync7(dir, { recursive: true });
10342
+ function nextRunLogPath(runLogsDir, runLogKeys) {
10343
+ const dir = runLogDirForWrite(runLogsDir, runLogKeys);
10344
+ if (!dir) throw new Error("\u043D\u0435\u0447\u0435\u043C \u0430\u0434\u0440\u0435\u0441\u043E\u0432\u0430\u0442\u044C \u043A\u0430\u0442\u0430\u043B\u043E\u0433 run-log'\u043E\u0432: \u0443 \u043F\u0440\u043E\u0433\u043E\u043D\u0430 \u043D\u0435\u0442 \u0441\u0442\u0430\u0431\u0438\u043B\u044C\u043D\u043E\u0433\u043E \u043A\u043B\u044E\u0447\u0430 (SwarmStore.runLogKeys)");
10345
+ mkdirSync8(dir, { recursive: true });
10164
10346
  let files = [];
10165
10347
  try {
10166
- files = readdirSync6(dir);
10348
+ files = readdirSync7(dir);
10167
10349
  } catch {
10168
10350
  }
10169
10351
  let next = 0;
@@ -10171,11 +10353,11 @@ function nextRunLogPath(runLogsDir, runId) {
10171
10353
  const m = /^(\d+)\.json/.exec(f);
10172
10354
  if (m) next = Math.max(next, Number(m[1]) + 1);
10173
10355
  }
10174
- return join11(dir, `${next}.json`);
10356
+ return join12(dir, `${next}.json`);
10175
10357
  }
10176
- function openNextRunLog(runLogsDir, runId) {
10358
+ function openNextRunLog(runLogsDir, runLogKeys) {
10177
10359
  for (let attempt = 0; attempt < 50; attempt++) {
10178
- const path = nextRunLogPath(runLogsDir, runId);
10360
+ const path = nextRunLogPath(runLogsDir, runLogKeys);
10179
10361
  let out;
10180
10362
  try {
10181
10363
  out = openSync3(path, "wx");
@@ -10191,7 +10373,7 @@ function openNextRunLog(runLogsDir, runId) {
10191
10373
  }
10192
10374
  return { out, err };
10193
10375
  }
10194
- throw new Error(`\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0439\u0442\u0438 \u0441\u0432\u043E\u0431\u043E\u0434\u043D\u044B\u0439 run-log \u0444\u0430\u0439\u043B \u0434\u043B\u044F \u043F\u0440\u043E\u0433\u043E\u043D\u0430 #${runId} (\u0433\u043E\u043D\u043A\u0430?)`);
10376
+ throw new Error(`\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043D\u0430\u0439\u0442\u0438 \u0441\u0432\u043E\u0431\u043E\u0434\u043D\u044B\u0439 run-log \u0444\u0430\u0439\u043B \u043F\u0440\u043E\u0433\u043E\u043D\u0430 (${runLogKeys[0]}) \u2014 \u0433\u043E\u043D\u043A\u0430?`);
10195
10377
  }
10196
10378
  function makeLauncher(cfg) {
10197
10379
  return ({ worktree, run, prompt, continueSession }) => {
@@ -10254,9 +10436,9 @@ function makeLauncher(cfg) {
10254
10436
  }
10255
10437
  const realHome = cfg.droneIsolation?.realHome ?? homedir3();
10256
10438
  const home = cfg.droneIsolation ? buildDroneHome({ realHome, droneHome: cfg.droneIsolation.home }) : void 0;
10257
- if (cfg.droneIsolation && process.env.TASKLESS_TOKEN && !existsSync7(join11(cfg.droneIsolation.swarmHome, "token"))) {
10439
+ if (cfg.droneIsolation && process.env.TASKLESS_TOKEN && !existsSync8(join12(cfg.droneIsolation.swarmHome, "token"))) {
10258
10440
  console.error(
10259
- `[launcher] TASKLESS_TOKEN \u0437\u0430\u0434\u0430\u043D \u0442\u043E\u043B\u044C\u043A\u043E \u0432 env \u0434\u0435\u043C\u043E\u043D\u0430 \u2014 \u0434\u0440\u043E\u043D\u0443 \u043E\u043D \u043D\u0435 \u043D\u0430\u0441\u043B\u0435\u0434\u0443\u0435\u0442\u0441\u044F (TASWM-289), \u0430 \u0444\u0430\u0439\u043B\u0430 ${join11(cfg.droneIsolation.swarmHome, "token")} \u043D\u0435\u0442. read_task \u0434\u0440\u043E\u043D\u0430 \u0431\u0443\u0434\u0435\u0442 slug-only. \u041F\u043E\u0447\u0438\u043D\u0438: \`swarm login\` (oauth.json) \u0438\u043B\u0438 \u043F\u043E\u043B\u043E\u0436\u0438 \u0442\u043E\u043A\u0435\u043D \u0432 \u044D\u0442\u043E\u0442 \u0444\u0430\u0439\u043B.`
10441
+ `[launcher] TASKLESS_TOKEN \u0437\u0430\u0434\u0430\u043D \u0442\u043E\u043B\u044C\u043A\u043E \u0432 env \u0434\u0435\u043C\u043E\u043D\u0430 \u2014 \u0434\u0440\u043E\u043D\u0443 \u043E\u043D \u043D\u0435 \u043D\u0430\u0441\u043B\u0435\u0434\u0443\u0435\u0442\u0441\u044F (TASWM-289), \u0430 \u0444\u0430\u0439\u043B\u0430 ${join12(cfg.droneIsolation.swarmHome, "token")} \u043D\u0435\u0442. read_task \u0434\u0440\u043E\u043D\u0430 \u0431\u0443\u0434\u0435\u0442 slug-only. \u041F\u043E\u0447\u0438\u043D\u0438: \`swarm login\` (oauth.json) \u0438\u043B\u0438 \u043F\u043E\u043B\u043E\u0436\u0438 \u0442\u043E\u043A\u0435\u043D \u0432 \u044D\u0442\u043E\u0442 \u0444\u0430\u0439\u043B.`
10260
10442
  );
10261
10443
  }
10262
10444
  const executor = cfg.executor ?? resolveExecutor();
@@ -10283,7 +10465,13 @@ function makeLauncher(cfg) {
10283
10465
  let outFd;
10284
10466
  let errFd;
10285
10467
  if (cfg.runLogsDir) {
10286
- const fds = openNextRunLog(cfg.runLogsDir, run.id);
10468
+ const logKeys = cfg.runLogKeys?.(run) ?? [];
10469
+ if (logKeys.length === 0) {
10470
+ throw new Error(
10471
+ `\u043F\u0440\u043E\u0433\u043E\u043D\u0443 #${run.id} (${run.task_slug}) \u043D\u0435\u0447\u0435\u043C \u0430\u0434\u0440\u0435\u0441\u043E\u0432\u0430\u0442\u044C \u043A\u0430\u0442\u0430\u043B\u043E\u0433 run-log'\u043E\u0432: \u0443 \u0441\u0442\u043E\u0440\u0430 \u043D\u0435\u0442 \u0441\u0442\u0430\u0431\u0438\u043B\u044C\u043D\u043E\u0433\u043E \u043A\u043B\u044E\u0447\u0430 \u043F\u0440\u043E\u0433\u043E\u043D\u0430. \u0414\u0440\u043E\u043D \u043D\u0435 \u0441\u043F\u0430\u0432\u043D\u0435\u043D \u2014 \u0438\u043D\u0430\u0447\u0435 \u0435\u0433\u043E \u043B\u043E\u0433 \u043B\u0451\u0433 \u0431\u044B \u0432 \u043A\u0430\u0442\u0430\u043B\u043E\u0433, \u043A\u043E\u0442\u043E\u0440\u044B\u0439 \u0447\u0438\u0442\u0430\u0435\u0442\u0441\u044F \u043A\u0430\u043A \u0447\u0443\u0436\u043E\u0439 (TASWM-419).`
10472
+ );
10473
+ }
10474
+ const fds = openNextRunLog(cfg.runLogsDir, logKeys);
10287
10475
  outFd = fds.out;
10288
10476
  errFd = fds.err ?? void 0;
10289
10477
  stdio = ["ignore", outFd, errFd ?? "ignore"];
@@ -10311,14 +10499,14 @@ function makeLauncher(cfg) {
10311
10499
  init_droneChannel();
10312
10500
 
10313
10501
  // src/git.ts
10314
- import { existsSync as existsSync8, readdirSync as readdirSync7, statSync as statSync7 } from "fs";
10502
+ import { existsSync as existsSync9, readdirSync as readdirSync8, statSync as statSync8 } from "fs";
10315
10503
  import { appendFile, chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
10316
- import { isAbsolute, join as join12, sep as sep2 } from "path";
10504
+ import { isAbsolute, join as join13, sep as sep2 } from "path";
10317
10505
  import { $ as $5 } from "execa";
10318
10506
  init_swarmFiles();
10319
10507
  function isLinkedWorktree(dir) {
10320
10508
  try {
10321
- return statSync7(join12(dir, ".git")).isFile();
10509
+ return statSync8(join13(dir, ".git")).isFile();
10322
10510
  } catch {
10323
10511
  return false;
10324
10512
  }
@@ -10330,7 +10518,7 @@ async function detachWorktree(repo, worktree) {
10330
10518
  }
10331
10519
  }
10332
10520
  async function ensureWorktreeDirGone(worktree) {
10333
- if (!existsSync8(worktree)) return false;
10521
+ if (!existsSync9(worktree)) return false;
10334
10522
  if (!isLinkedWorktree(worktree)) {
10335
10523
  console.error(
10336
10524
  `[git] ${worktree} \u2014 \u043D\u0435 \u043F\u0440\u0438\u0441\u0442\u0451\u0433\u043D\u0443\u0442\u044B\u0439 worktree (.git \u043D\u0435 \u0444\u0430\u0439\u043B): \u043A\u0430\u0442\u0430\u043B\u043E\u0433 \u041D\u0415 \u0442\u0440\u043E\u0433\u0430\u044E. \u0415\u0441\u043B\u0438 \u044D\u0442\u043E \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043B\u044C\u043D\u043E worktree \u0434\u0440\u043E\u043D\u0430 \u2014 \u0443\u0431\u0435\u0440\u0438\u0442\u0435 \u0435\u0433\u043E \u0440\u0443\u043A\u0430\u043C\u0438; \u0435\u0441\u043B\u0438 \u044D\u0442\u043E \u043E\u0441\u043D\u043E\u0432\u043D\u043E\u0439 \u0447\u0435\u043A\u0430\u0443\u0442, \u0437\u043D\u0430\u0447\u0438\u0442 \u0432\u0435\u0442\u043A\u0430 \u0441\u043B\u0430\u0433\u0430 \u0447\u0435\u043A\u043D\u0443\u0442\u0430 \u0432 \u043D\u0451\u043C (git checkout <slug>) \u2014 \u043F\u0435\u0440\u0435\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0435\u0433\u043E \u043D\u0430 \u0434\u0440\u0443\u0433\u0443\u044E \u0432\u0435\u0442\u043A\u0443.`
@@ -10345,7 +10533,7 @@ async function ensureWorktreeDirGone(worktree) {
10345
10533
  );
10346
10534
  return true;
10347
10535
  }
10348
- if (existsSync8(worktree)) {
10536
+ if (existsSync9(worktree)) {
10349
10537
  console.error(`[git] \u043A\u0430\u0442\u0430\u043B\u043E\u0433 worktree ${worktree} \u043F\u0435\u0440\u0435\u0436\u0438\u043B rm \u2014 \u0443\u0431\u0435\u0440\u0438\u0442\u0435 \u0440\u0443\u043A\u0430\u043C\u0438, \u0438\u043D\u0430\u0447\u0435 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u0439 \u0441\u043F\u0430\u0432\u043D \u0441\u043B\u0430\u0433\u0430 \u0432\u0441\u0442\u0430\u043D\u0435\u0442.`);
10350
10538
  }
10351
10539
  return true;
@@ -10373,7 +10561,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
10373
10561
 
10374
10562
  // src/attachments.ts
10375
10563
  import { mkdir as mkdir2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
10376
- import { join as join13 } from "path";
10564
+ import { join as join14 } from "path";
10377
10565
  init_swarmFiles();
10378
10566
  var ATTACHMENTS_DIR = `${SWARM_TASK_DIR}/attachments`;
10379
10567
  var ATTACH_MAX_TOTAL_BYTES = 24 * 1024 * 1024;
@@ -10385,7 +10573,7 @@ var KNOWN_EXT = /* @__PURE__ */ new Set([...IMAGE_EXT, ...DOC_EXT, ...TEXT_EXT])
10385
10573
 
10386
10574
  // src/shotUpload.ts
10387
10575
  import { readFile as fsReadFile } from "fs/promises";
10388
- import { basename as basename3, isAbsolute as isAbsolute2, join as join14 } from "path";
10576
+ import { basename as basename3, isAbsolute as isAbsolute2, join as join15 } from "path";
10389
10577
  var SHOT_MAX_FILE_BYTES = 10 * 1024 * 1024;
10390
10578
 
10391
10579
  // src/taskless.ts
@@ -10396,8 +10584,8 @@ init_linkFailure();
10396
10584
  // src/workspace.ts
10397
10585
  init_config();
10398
10586
  import { readFileSync as readFileSync9, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4 } from "fs";
10399
- import { join as join15 } from "path";
10400
- var WORKSPACE_PATH = join15(SWARM_HOME, "workspace.json");
10587
+ import { join as join16 } from "path";
10588
+ var WORKSPACE_PATH = join16(SWARM_HOME, "workspace.json");
10401
10589
  function loadWorkspace() {
10402
10590
  try {
10403
10591
  const w = JSON.parse(readFileSync9(WORKSPACE_PATH, "utf8"));
@@ -11288,7 +11476,7 @@ async function runDaemonLogs(argv) {
11288
11476
  console.error(`\u043B\u043E\u0433 \u041F\u0420\u041E\u0413\u041E\u041D\u0410 \u2014 \u044D\u0442\u043E \xABswarm log ${stray}\xBB (\u0431\u0435\u0437 s). \xABswarm logs\xBB \u043F\u043E\u043A\u0430\u0437\u044B\u0432\u0430\u0435\u0442 \u043B\u043E\u0433 \u0434\u0435\u043C\u043E\u043D\u0430; \u0447\u0438\u0441\u043B\u043E \u0441\u0442\u0440\u043E\u043A \u2014 \xABswarm logs -n 200\xBB.`);
11289
11477
  return 1;
11290
11478
  }
11291
- if (!existsSync15(DAEMON_LOG_PATH)) {
11479
+ if (!existsSync16(DAEMON_LOG_PATH)) {
11292
11480
  console.error(`\u043B\u043E\u0433\u0430 \u0434\u0435\u043C\u043E\u043D\u0430 \u043D\u0435\u0442: ${DAEMON_LOG_PATH}`);
11293
11481
  console.error("\u043E\u043D \u043F\u0438\u0448\u0435\u0442\u0441\u044F \u043F\u0440\u0438 \u0444\u043E\u043D\u043E\u0432\u043E\u043C \u0437\u0430\u043F\u0443\u0441\u043A\u0435 (swarm start). \u0414\u0435\u043C\u043E\u043D \u043F\u043E\u0434\u043D\u044F\u0442 \u0432 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u0435 (--foreground) \u2014 \u0432\u044B\u0432\u043E\u0434 \u0438\u0434\u0451\u0442 \u0442\u0443\u0434\u0430.");
11294
11482
  return 1;
@@ -11296,12 +11484,12 @@ async function runDaemonLogs(argv) {
11296
11484
  const head = tailFile(DAEMON_LOG_PATH, lines);
11297
11485
  if (head) console.log(head);
11298
11486
  if (!follow) return 0;
11299
- let pos = statSync9(DAEMON_LOG_PATH).size;
11487
+ let pos = statSync10(DAEMON_LOG_PATH).size;
11300
11488
  for (; ; ) {
11301
11489
  await sleep2(500);
11302
11490
  let size;
11303
11491
  try {
11304
- size = statSync9(DAEMON_LOG_PATH).size;
11492
+ size = statSync10(DAEMON_LOG_PATH).size;
11305
11493
  } catch {
11306
11494
  continue;
11307
11495
  }
@@ -11560,10 +11748,10 @@ function effectiveWorktreesDir() {
11560
11748
  legacyDir: WORKTREES_LEGACY_DIR,
11561
11749
  targetDir: WORKTREES_DIR,
11562
11750
  envOverride: process.env.SWARM_WORKTREES || "",
11563
- exists: (d) => existsSync15(d),
11751
+ exists: (d) => existsSync16(d),
11564
11752
  isNonEmptyDir: (d) => {
11565
11753
  try {
11566
- return readdirSync9(d).length > 0;
11754
+ return readdirSync10(d).length > 0;
11567
11755
  } catch {
11568
11756
  return false;
11569
11757
  }
@@ -11600,7 +11788,7 @@ async function runScoutCommand(repo) {
11600
11788
  });
11601
11789
  const client = new SwarmClient({ call, outboxPath: SWARM_OUTBOX, defaultRepo: repo, autoFlush: false });
11602
11790
  const name = pickName([]);
11603
- const outFile = join23(SWARM_HOME, `scout-${repoFullName.replace(/[^\w.-]+/g, "-")}.json`);
11791
+ const outFile = join24(SWARM_HOME, `scout-${repoFullName.replace(/[^\w.-]+/g, "-")}.json`);
11604
11792
  const bin = resolveExecutor().resolveBin();
11605
11793
  const code = await runRepoScout({
11606
11794
  repoFullName,
@@ -11620,7 +11808,7 @@ async function runScoutCommand(repo) {
11620
11808
  });
11621
11809
  child.on("exit", (c) => resolve4(c ?? 1));
11622
11810
  }),
11623
- readOut: () => existsSync15(outFile) ? readFileSync17(outFile, "utf8") : null,
11811
+ readOut: () => existsSync16(outFile) ? readFileSync17(outFile, "utf8") : null,
11624
11812
  clearOut: () => rmSync2(outFile, { force: true }),
11625
11813
  report: (report) => client.reportRepoScout(repoFullName, report, name),
11626
11814
  log: (line) => console.log(line)
@@ -11696,7 +11884,7 @@ async function runZones(db2, repo, args) {
11696
11884
  }
11697
11885
  if (model.spine.length) {
11698
11886
  console.log(`
11699
- \u043E\u0431\u0449\u0438\u0439 \u0445\u0440\u0435\u0431\u0435\u0442 (${model.spine.length} \u0444\u0430\u0439\u043B\u043E\u0432) \u2014 \u0437\u0430\u0434\u0430\u0447\u0438 \u043F\u0440\u043E \u043D\u0435\u0433\u043E \u0438\u0434\u0443\u0442 \u043F\u043E\u0441\u043B\u0435\u0434\u043E\u0432\u0430\u0442\u0435\u043B\u044C\u043D\u043E \u043D\u0435\u0437\u0430\u0432\u0438\u0441\u0438\u043C\u043E \u043E\u0442 \u0437\u043E\u043D\u044B:`);
11887
+ \u043E\u0431\u0449\u0438\u0439 \u0445\u0440\u0435\u0431\u0435\u0442 (${model.spine.length} \u0444\u0430\u0439\u043B\u043E\u0432) \u2014 \u0434\u0432\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 \u0432 \u041E\u0414\u041D\u041E\u041C \u0442\u0430\u043A\u043E\u043C \u0444\u0430\u0439\u043B\u0435 \u0438\u0434\u0443\u0442 \u043F\u043E\u0441\u043B\u0435\u0434\u043E\u0432\u0430\u0442\u0435\u043B\u044C\u043D\u043E \u043D\u0435\u0437\u0430\u0432\u0438\u0441\u0438\u043C\u043E \u043E\u0442 \u0437\u043E\u043D\u044B:`);
11700
11888
  for (const f of model.spine.slice(0, 15)) console.log(` ${f}`);
11701
11889
  }
11702
11890
  console.log("\n\u0441\u0432\u0435\u0440\u0438\u0442\u044C \u0441 \u0440\u0443\u0447\u043D\u043E\u0439 \u0440\u0430\u0437\u043C\u0435\u0442\u043A\u043E\u0439: swarm zones check");
@@ -12017,6 +12205,8 @@ switch (cmd) {
12017
12205
  overmindEntry: OVERMIND_ENTRY,
12018
12206
  dbPath: DB_PATH,
12019
12207
  runLogsDir: RUN_LOGS_DIR,
12208
+ // TASWM-419: каталог лога — по стабильному ключу прогона (тот же адрес, что у демона).
12209
+ runLogKeys: (r) => db.runLogKeys(r.id),
12020
12210
  // Тот же исполнитель, что у демона (TASWM-349): `swarm wake` при мёртвом демоне не
12021
12211
  // должен внезапно поднимать дрона другим харнессом.
12022
12212
  executor: EXECUTOR,