Четыре сбоя, которые пришлось пережить ради автоматического запуска еженедельного задания в launchd
Разработчик собрал скрипт для еженедельного распределения AI-навыков между проектами на macOS — и столкнулся с четырьмя неочевидными сбоями, каждый из которых проявлялся только при запуске через launchd.
Задача, которая выглядит как простое копирование файлов, превратилась в цепочку из четырёх отладочных сессий — каждая из которых заняла неделю, потому что сбой проявлялся только в воскресном автоматическом запуске.
Суть проблемы: навыки AI не мигрируют между проектами
В экосистеме Claude Code существует механизм «навыков» — файлов в директории ~/.claude/skills/auto/, где AI-ассистент автоматически записывает обнаруженные во время работы паттерны: критерии завершения задач, рабочие команды верификации, найденные обходные пути. При следующем запросе в том же проекте эти навыки подхватываются и ускоряют работу.
Архитектурная проблема в том, что накопление навыков происходит в глобальной директории, а использование — в локальных папках проектов. Если создать новый репозиторий или открыть старый проект после перерыва, навыков там не окажется. Без ручного копирования или явного указания «используй тот навык» — весь накопленный контекст теряется.
Это не теоретическая проблема. При масштабировании параллельных проектов — а автор упоминает, что иногда приходится создавать два-три новых репозитория в неделю — ручное обслуживание среды начинает отъедать заметное время. Не столько длительность копирования, сколько альтернативная стоимость: «если бы навык уже был на месте, задача заняла бы три минуты вместо двадцати».
Архитектура решения
Автор построил bash-скрипт, который каждое воскресенье в 06:10 запускается через macOS launchd и распределяет накопленные навыки по всем проектам на машине. Скрипт выполняет шесть шагов:
- Проверка сетевой доступности — HTTP-запрос к npmjs.org с 8-секундным таймаутом. Если оффлайн — завершается с кодом 0 (без ошибки, чтобы launchd не интерпретировал это как повод для повторной попытки).
- Поиск проектов — два параллельных
find: один ищет директории.git, другой — файлы манифестов (package.json,pyproject.toml,requirements.txt,go.mod,Cargo.toml,pubspec.yaml). Результаты объединяются, дубликаты удаляются черезsort -u. Глубина поиска ограничена двумя уровнями от~и~/dev. - Фильтрация исключений — защита системных директорий (
Library/,Applications/,Documents/), чужих OSS-репозиториев, кэшей и конфигураций самого Claude Code. - Распределение — запуск
npx -y autoskills --yesв каждой найденной директории. - Дописывание
.gitignore— паттерны.agents/,.claude/skills/,skills-lock.jsonдобавляются в гитигнор, чтобы личные навыки случайно не попали в публичный репозиторий. - Логирование — все действия записываются в единый лог-файл с метками времени.
Концептуальный подход автор формулирует так: не увеличивать объём ручной работы, а поднять базовое качество среды. Звучит разумно — но реализация оказалась куда менее тривиальной, чем архитектурная схема.
Четыре сбоя, каждый из которых стоил недели отладки
Сбой №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) проще перечислить «точно не проекты», чем пытаться определить «точно проекты».
- Явные пути и фиксированные версии — компромисс в пользу надёжности. Динамическое разрешение путей и версий элегантнее, но в контексте неинтерактивного запуска каждый шаг динамики — потенциальная точка отказа.
Скрипт и конфигурация, описанные в оригинальной статье, решают узкую, но репрезентативную задачу: синхронизацию личного контекста разработчика между проектами. Подход работает, но требует нетривиальной подготовки окружения — и каждая из четырёх ошибок, о которых рассказывает автор, является скорее не багом, а следствием несовпадения между «как работает интерактивный терминал» и «как работает системный планировщик заданий». Это разница, которую слишком часто недооценивают.