17.08.2026 456 материалов

Четыре сбоя, которые пришлось пережить ради автоматического запуска еженедельного задания в launchd

Разработчик собрал скрипт для еженедельного распределения AI-навыков между проектами на macOS — и столкнулся с четырьмя неочевидными сбоями, каждый из которых проявлялся только при запуске через launchd.

Четыре сбоя, которые пришлось пережить ради автоматического запуска еженедельного задания в launchd

Задача, которая выглядит как простое копирование файлов, превратилась в цепочку из четырёх отладочных сессий — каждая из которых заняла неделю, потому что сбой проявлялся только в воскресном автоматическом запуске.

Суть проблемы: навыки AI не мигрируют между проектами

В экосистеме Claude Code существует механизм «навыков» — файлов в директории ~/.claude/skills/auto/, где AI-ассистент автоматически записывает обнаруженные во время работы паттерны: критерии завершения задач, рабочие команды верификации, найденные обходные пути. При следующем запросе в том же проекте эти навыки подхватываются и ускоряют работу.

Архитектурная проблема в том, что накопление навыков происходит в глобальной директории, а использование — в локальных папках проектов. Если создать новый репозиторий или открыть старый проект после перерыва, навыков там не окажется. Без ручного копирования или явного указания «используй тот навык» — весь накопленный контекст теряется.

Это не теоретическая проблема. При масштабировании параллельных проектов — а автор упоминает, что иногда приходится создавать два-три новых репозитория в неделю — ручное обслуживание среды начинает отъедать заметное время. Не столько длительность копирования, сколько альтернативная стоимость: «если бы навык уже был на месте, задача заняла бы три минуты вместо двадцати».

Архитектура решения

Автор построил bash-скрипт, который каждое воскресенье в 06:10 запускается через macOS launchd и распределяет накопленные навыки по всем проектам на машине. Скрипт выполняет шесть шагов:

  1. Проверка сетевой доступности — HTTP-запрос к npmjs.org с 8-секундным таймаутом. Если оффлайн — завершается с кодом 0 (без ошибки, чтобы launchd не интерпретировал это как повод для повторной попытки).
  2. Поиск проектов — два параллельных find: один ищет директории .git, другой — файлы манифестов (package.json, pyproject.toml, requirements.txt, go.mod, Cargo.toml, pubspec.yaml). Результаты объединяются, дубликаты удаляются через sort -u. Глубина поиска ограничена двумя уровнями от ~ и ~/dev.
  3. Фильтрация исключений — защита системных директорий (Library/, Applications/, Documents/), чужих OSS-репозиториев, кэшей и конфигураций самого Claude Code.
  4. Распределение — запуск npx -y autoskills --yes в каждой найденной директории.
  5. Дописывание .gitignore — паттерны .agents/, .claude/skills/, skills-lock.json добавляются в гитигнор, чтобы личные навыки случайно не попали в публичный репозиторий.
  6. Логирование — все действия записываются в единый лог-файл с метками времени.

Концептуальный подход автор формулирует так: не увеличивать объём ручной работы, а поднять базовое качество среды. Звучит разумно — но реализация оказалась куда менее тривиальной, чем архитектурная схема.

Четыре сбоя, каждый из которых стоил недели отладки

Сбой №1: launchd не находит npx

При первой загрузке plist-файла скрипт запускался, но лог оставался пустым. Проверка через launchctl list показала код выхода 127 — «команда не найдена».

Причина: launchd выполняет задания в собственном окружении, не читая .zshrc и не инициализируя nvm. Путь к Node.js, управляемому через nvm, просто отсутствовал в системном PATH. Скрипт shell-а запускался, но вызываемый из него npx — нет.

Решение: явная прописка полного пути к nvm-управляемому Node.js в EnvironmentVariables plist-файла. Автор зафиксировал конкретную версию (v24.13.0), отказавшись от динамического определения. Это добавляет накладные расходы при смене версии Node, но делает поведение предсказуемым. Динамическое разрешение через .nvm/alias/default потребовало бы shell-подстановок внутри plist, которые launchd не поддерживает.

Сбой №2: перезапись .gitignore при dry-run

На ранних этапах разработки запуск с флагом --dry-run привёл к модификации .gitignore в нескольких проектах. Флаг передавался в npx, но блок записи в gitignore его не проверял — условием было только n > 0 (количество установленных навыков).

Решение: обёртка [ -z "$DRY" ] вокруг всех операций с побочными эффектами. Вывод — флаг --dry-run должен быть единым для всей системы, а не частично передаваемым в отдельные команды. Частичная семантика флагов — классическая ловушка скриптов средней сложности.

Сбой №3: пустой массив убивает скрипт при set -u

После добавления set -uo pipefail скрипт стал молча завершаться с кодом 1 — лог был абсолютно пустым, даже строка старта не появлялась.

Причина: в bash 3.2 (системный /bin/bash на macOS) раскрытие пустого массива ${CANDIDATES[@]} при активном set -u вызывает ошибку «unbound variable». Bash падает раньше, чем успевает вызвать функцию логирования, поэтому в файле не остаётся ничего. Запуск с bash -x для трассировки показал точную строку.

Решение: проверка размера массива через ${#CANDIDATES[@]} перед циклом. Это выражение возвращает 0 для пустого массива без ошибки при set -u. После добавления guard'а даже пустой список кандидатов корректно логируется и завершается с exit 0.

Сбой №4: кэши в Library/ стали целями распределения

В логе одного из воскресных запусков появилась запись о проекте Caches, которого автор не ожидал. В ~/Library/Caches находился npm-кэш с файлом package.json, и discovery-механизм с find -maxdepth 2 от ~ поймал его как «проект». В кэше был сгенерирован skills-lock.json.

Решение: добавление Library/, Documents/, Applications/ и ряда других системных путей в denylist. Автор формулирует общую стратегию: лучше расширять список исключений, чем сужать область поиска. В домашних директориях macOS файлы манифестов проектов разбросаны в неожиданных местах, и чем шире сеть discovery — тем больше ложных срабатываний.

Технические решения, заслуживающие внимания

Помимо четырёх крупных сбоев, в скрипте и plist-файле есть несколько решений, которые отражают реальные ограничения macOS и bash.

Совместимость с bash 3.2. macOS поставляется с /bin/bash версии 3.2, в которой отсутствует mapfile (появился в 4.0). Даже если через Homebrew установлен bash 5, интерпретатор может определяться окружением launchd. Автор заменил mapfile на цикл while IFS= read -r с отключением разделения полей и игнорированием обратных слешей. Это позволяет корректно обрабатывать пути с пробелами и скобками.

Паттерн subshell cd. Все вызовы cd обёрнуты в подоболочки ($() для подстановки, () для условных проверок). Это гарантирует, что смена рабочей директории не «протекает» между итерациями цикла.

Двухстадийный grep для парсинга вывода. Формат вывода autoskills менялся между версиями: от 12 skills installed до Skills to install (12). Обработка только одного формата приводит к тому, что после обновления пакета все проекты записываются как «0 навыков (пропущено)» без каких-либо ошибок. Двухстадийный grep с head -1 защищает от обеих форм и от многострочных совпадений.

Флаги set -uo pipefail без -e. Флаг errexit (-e) намеренно опущён, потому что функция is_excluded возвращает return 0 («да, исключён»), что с точки зрения bash выглядит как успешное выполнение, а условные конструкции с return 0/1 при -e ведут себя непредсказуемо. Автор предпочитает явную обработку ошибок через || { log "..."; exit 1; }.

Скрытые ловушки launchd, о которых стоит знать

Помимо основных четырёх сбоев, автор перечисляет ряд тонкостей, которые могут стоить часы отладки:

  • Тильда ~ в plist не раскрывается. launchd интерпретирует ~ как буквальный символ. Нужны полные пути к домашней директории.
  • RunAtLoad = true запускает продакшн-выполнение немедленно — при первой загрузке plist, без возможности предварительной проверки.
  • Weekday = 0 означает воскресенье, а не понедельник. Нумерация дней launchd начинается с 0 (воскресенье).
  • Повторный launchctl load без unload не применяет изменения. Старая конфигурация остаётся в памяти.
  • LastExitStatus = 0 может означать не только успешное завершение, но и «задание ещё ни разу не запускалось» (начальное значение — 0).
  • ProcessType: Adaptive (по умолчанию) позволяет системе энергосбережения отложить или отменить запуск. Для еженедельного задания критично выставить Background.
  • npx -y и autoskills --yes пропускают разные подтверждения. Без обоих скрипт зависает на интерактивном запросе до следующего воскресенья.

Практические выводы

Весь опыт, извлечённый из четырёх сбоев и десятка мелких ловушек, сводится к нескольким операционным принципам:

  • Еженедельное задание — это не cron-таск, который можно отладить «на лету». Если что-то пошло не так, вы узнаете об этом через неделю. Архитектура должна предусматривать возможность ручного запуска с теми же аргументами (--dry-run) для сокращения цикла отладки.
  • Единый лог-файл критичен. Разделение stdout и stderr в разные файлы делает реконструкцию порядка событий невозможной. Хронологический лог с метками времени — единственный способ понять, что произошло.
  • Denylist надёжнее allowlist. В неструктурированном файловом пространстве (домашняя директория macOS) проще перечислить «точно не проекты», чем пытаться определить «точно проекты».
  • Явные пути и фиксированные версии — компромисс в пользу надёжности. Динамическое разрешение путей и версий элегантнее, но в контексте неинтерактивного запуска каждый шаг динамики — потенциальная точка отказа.

Скрипт и конфигурация, описанные в оригинальной статье, решают узкую, но репрезентативную задачу: синхронизацию личного контекста разработчика между проектами. Подход работает, но требует нетривиальной подготовки окружения — и каждая из четырёх ошибок, о которых рассказывает автор, является скорее не багом, а следствием несовпадения между «как работает интерактивный терминал» и «как работает системный планировщик заданий». Это разница, которую слишком часто недооценивают.