@agfpd/iapeer 0.4.105 → 0.4.107
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/docs/09-runtimes.md +17 -2
- package/docs/12-cli-reference.md +2 -1
- package/docs/14-configuration-and-logs.md +10 -0
- package/docs/ru/09-/321/200/320/260/320/275/321/202/320/260/320/271/320/274/321/213.md +17 -2
- package/docs/ru/12-cli-reference.md +2 -1
- package/docs/ru/14-/320/272/320/276/320/275/321/204/320/270/320/263/321/203/321/200/320/260/321/206/320/270/321/217-/320/270-/320/266/321/203/321/200/320/275/320/260/320/273/321/213.md +10 -0
- package/package.json +1 -1
- package/src/cli/index.ts +136 -20
- package/src/init/index.ts +2 -2
- package/src/launch/cmdlog.ts +190 -1
- package/src/launch/launchd.ts +34 -3
- package/src/lifecycle/index.ts +20 -1
package/docs/09-runtimes.md
CHANGED
|
@@ -56,7 +56,20 @@ iapeer add-runtime codex --peer assistant # for one peer
|
|
|
56
56
|
iapeer add-runtime codex --all # for all peers
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
Adds
|
|
59
|
+
Adds a runtime to existing peers: appends it to `runtimes`, scaffolds the runtime space, and does the full Codex setup (pre-trusting the folder, the native-memory lever, attaching memory, MCP). The command **doesn't touch** `default_runtime` — this is extending options, not changing routing. Idempotent. A peer whose default is infrastructure is skipped when the runtime being added is an *agent* one: giving a router peer an agent runtime is an operator decision via `create`, not a bulk operation.
|
|
60
|
+
|
|
61
|
+
An **infrastructure** runtime can also be added this way, but only to one named peer:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
iapeer add-runtime voicetalk --peer arthur # attach a second always-on channel
|
|
65
|
+
iapeer connect voicetalk arthur # the same thing, said the way `connect` taught you
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This is the operator path for the multi-channel scheme above — giving an *existing* personality another always-on presence. It resolves the peer's folder from the registry, installs the per-runtime plist, runs the runtime package's own `selfConfig` hook (which owns `interfaces.<runtime>`), and loads the job. `--all` is refused for an infrastructure runtime: every channel is a launchd job, and fanning them across the fleet is never what an operator means.
|
|
69
|
+
|
|
70
|
+
`create` cannot do this. It resolves a peer by **location** (`~/.iapeer/peers/<name>` unless `--path` is given), so for a peer whose folder lives elsewhere it never reaches the existing record — it would scaffold a second, bogus folder instead.
|
|
71
|
+
|
|
72
|
+
Declaration and reality can diverge: `runtimes` only *declares* the channel, the plist *realizes* it. If the record declares the runtime but the plist is gone, the command re-provisions it rather than reporting "already" for a channel that does not exist.
|
|
60
73
|
|
|
61
74
|
### default-runtime — switch the primary
|
|
62
75
|
|
|
@@ -69,7 +82,7 @@ Switches the primary runtime — where routing goes and which runtime comes up f
|
|
|
69
82
|
|
|
70
83
|
The routing choice is strict: a send without an explicit `runtime` always targets `default_runtime`. A live session on another declared runtime does not capture that send; if the default session is asleep, the daemon wakes it. Passing `runtime` explicitly remains the way to target a non-default surface.
|
|
71
84
|
|
|
72
|
-
## Connecting
|
|
85
|
+
## Connecting a channel: connect
|
|
73
86
|
|
|
74
87
|
```bash
|
|
75
88
|
iapeer connect telegram assistant --token <bot-token>
|
|
@@ -79,6 +92,8 @@ Connects a Telegram bot to a peer in one pass: registers the bot, writes it into
|
|
|
79
92
|
|
|
80
93
|
The command is idempotent, and behavior depends on the token. **The same token** is a clean no-op: the bot's credentials file doesn't change, the live router already runs on these credentials, no restart. **A new or changed token** requires a router restart (credentials are read only at start) — a second's pause in delivery for all Telegram peers; inbound messages aren't lost (long-polling remembers the offset, Telegram holds messages for up to 24 hours). An invalid token is rejected at once, with the verbatim reason from Telegram.
|
|
81
94
|
|
|
95
|
+
`connect` is not Telegram-only. `iapeer connect <runtime> <peer>` attaches any presence channel: Telegram keeps its bespoke flow (it owes a bot token, a `@BotFather` round-trip and a credential store), while every other infrastructure runtime takes the generic bind — the same per-peer path as `add-runtime <runtime> --peer <peer>`. The runtime package must be installed on the host first (`install-runtime`), which the command checks before touching anything.
|
|
96
|
+
|
|
82
97
|
## Runtime packages
|
|
83
98
|
|
|
84
99
|
Infrastructure runtimes ship as separate npm packages (`*-runtime`). The core installs and updates them:
|
package/docs/12-cli-reference.md
CHANGED
|
@@ -49,7 +49,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
|
|
|
49
49
|
| `attach <peer> [runtime]` | Bring up (if asleep), resume the conversation, open the session under the supervisor in the terminal (detach — `Ctrl-]`). |
|
|
50
50
|
| `interrupt <peer> [runtime]` | Interrupt the current turn (Escape), context intact. |
|
|
51
51
|
| `compact <peer> [runtime]` | Compact the conversation; a sleeper is resumed first after idle sleep. |
|
|
52
|
-
| `add-runtime <runtime> (--peer <p> \| --all)` | Add
|
|
52
|
+
| `add-runtime <runtime> (--peer <p> \| --all)` | Add a runtime to existing peers. Doesn't touch `default_runtime`. An infrastructure runtime attaches an always-on channel and is `--peer`-only (never an `--all` sweep). |
|
|
53
53
|
| `default-runtime <runtime> (--peer <p> \| --all)` | Switch the primary runtime (routing / first bring-up). Refuses if the runtime isn't declared. |
|
|
54
54
|
|
|
55
55
|
`stop`/`start`/`new` refuse to touch peers under a foreign launchd plist — the core doesn't intervene in what it doesn't manage.
|
|
@@ -69,6 +69,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
|
|
|
69
69
|
|---|---|
|
|
70
70
|
| `<runtime>` (e.g. `iapeer claude`) | Launch the current folder's peer on this runtime — always as a fresh session. |
|
|
71
71
|
| `connect telegram <peer> [--token <t>]` | Connect a Telegram bot to a peer: register the bot → write into the profile → restart the router. Asks only for the token. |
|
|
72
|
+
| `connect <runtime> <peer>` | Attach any other presence channel (web, voicetalk, …) to an existing peer: per-runtime plist → the package's `selfConfig` hook → load. Same path as `add-runtime <runtime> --peer <peer>`. |
|
|
72
73
|
|
|
73
74
|
## Extensions and memory
|
|
74
75
|
|
|
@@ -63,6 +63,8 @@ The daemon's supervisory tick is fixed — once a minute, not configurable.
|
|
|
63
63
|
| `IAPEER_DELIVERY_LOG_KEEP` | `5` | How many `delivery.log` rotations to keep. |
|
|
64
64
|
| `IAPEER_PANELOG_MAX_BYTES` | `16 MiB` | The per-peer pane-log (`logs/lifecycle/<runtime>-<peer>.log`, the raw terminal byte stream) size at which rotation happens. |
|
|
65
65
|
| `IAPEER_PANELOG_KEEP` | `2` | How many rotated pane-log copies to keep (`<log>.1` newest … `.N`). |
|
|
66
|
+
| `IAPEER_PANELOG_DIR_BUDGET` | `512 MiB` | The retention ceiling on the WHOLE `logs/lifecycle/` directory — the bound that does not grow with the fleet. |
|
|
67
|
+
| `IAPEER_PANELOG_STALE_DAYS` | `14` | Age after which a pane-log artifact (base or copy) is reclaimed — this is what frees the logs of dead/removed peers. |
|
|
66
68
|
| `IAPEER_SUPERVISE_LOG_VERBOSE` | off | A verbose log of supervisory ticks (by default only decisions are written, not routine "doing nothing"). |
|
|
67
69
|
| `IAPEER_DAEMON_LOG` | off | An extended daemon log. |
|
|
68
70
|
|
|
@@ -84,6 +86,14 @@ The daemon keeps three durable logs in `~/.iapeer/logs/iapeer/`:
|
|
|
84
86
|
|
|
85
87
|
Per-peer **pane-logs** live separately under `~/.iapeer/logs/lifecycle/<runtime>-<peer>.log` — the raw terminal (pty) byte stream the supervisor appends for a session's whole life (occupancy/typing detection, the boot ready-gate, and `tail`-based monitors read them). They are rotated on the supervise tick by **copytruncate** (`IAPEER_PANELOG_MAX_BYTES` × `IAPEER_PANELOG_KEEP`, default 16 MiB × 2): the base file keeps its path and inode (its last 8 MiB tail is retained in place, so the live writer and every reader are undisturbed) while the preceding window is promoted to `<log>.1` … `.N`. Total per peer is bounded by roughly `MAX_BYTES × (KEEP + 1)`.
|
|
86
88
|
|
|
89
|
+
That per-file bound alone is a *per-peer* one, so the directory total was still a product of the fleet size (peers × runtimes) and nothing ever reclaimed the artifacts of a peer that had died or been removed. The same supervise tick therefore also runs a **retention** pass over the directory, reclaiming in cheapest-loss-first order:
|
|
90
|
+
|
|
91
|
+
1. **Age** — every artifact (base or copy) untouched for longer than `IAPEER_PANELOG_STALE_DAYS` (14 d) is deleted. Nothing live reads a base that cold — idle-reap ends a warm session in minutes, and only a *current* session's base feeds the ready-gate seed and the mtime-occupancy signal — so this is what finally frees dead, removed, and renamed peers. The supervisor recreates the file on the next wake.
|
|
92
|
+
2. **Budget / copies** — while the directory exceeds `IAPEER_PANELOG_DIR_BUDGET` (512 MiB), rotated copies are deleted oldest-first. They are history nobody reads live, so they are the correct first casualty.
|
|
93
|
+
3. **Budget / backstop** — if the copies run out and the directory is still over budget, the largest bases are shrunk in place to the 8 MiB reader-floor, inode *and mtime* preserved (a janitor write must never masquerade as peer activity in the occupancy contract).
|
|
94
|
+
|
|
95
|
+
The floor of step 3 is `live-identities × 8 MiB`, so the budget is authoritative up to roughly 64 concurrently warm identities at the defaults; past that the reader-floor deliberately outranks the budget (truncating below the seed window would starve occupancy/ready-gate detection) and the pass records `outcome=over-budget-after-gc` in `lifecycle.log`, where raising `IAPEER_PANELOG_DIR_BUDGET` is the remedy. Every pass that reclaims anything leaves an `action=panelog-gc` line with the before/after megabytes. The whole pass is idempotent and best-effort by construction: it runs on each tick and never fails one.
|
|
96
|
+
|
|
87
97
|
### Postmortem diagnostics
|
|
88
98
|
|
|
89
99
|
The cause of a session's death (exit code / signal) the supervisor records at the moment of death to `exits.log` — what an ordinary "is the process alive" check misses. It's the material for answering "why did the agent die".
|
|
@@ -56,7 +56,20 @@ iapeer add-runtime codex --peer assistant # одному пиру
|
|
|
56
56
|
iapeer add-runtime codex --all # всем пирам
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
Добавляет
|
|
59
|
+
Добавляет рантайм существующим пирам: дописывает его в `runtimes`, разворачивает пространство рантайма и проводит полную настройку для Codex (предварительное доверие к папке, рычаг родной памяти, подключение памяти, MCP). `default_runtime` команда **не трогает** — это расширение возможностей, не смена маршрутизации. Идемпотентна. Пир с инфраструктурным дефолтом пропускается, когда добавляется *агентский* рантайм: дать роутер-пиру агентский рантайм — решение оператора через `create`, а не массовая операция.
|
|
60
|
+
|
|
61
|
+
**Инфраструктурный** рантайм тоже добавляется этой командой, но только одному названному пиру:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
iapeer add-runtime voicetalk --peer arthur # прицепить второй always-on канал
|
|
65
|
+
iapeer connect voicetalk arthur # то же самое в привычной по connect форме
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Это операторский путь для описанной выше мультиканальной схемы — дать *существующей* личности ещё одно постоянное присутствие. Команда резолвит папку пира из реестра, ставит пер-рантаймовый плист, вызывает собственный хук `selfConfig` рантайм-пакета (он владеет `interfaces.<рантайм>`) и загружает job. `--all` для инфраструктурного рантайма отвергается: каждый канал — это launchd-job, и разворачивать их по всему флоту оператор не имеет в виду никогда.
|
|
69
|
+
|
|
70
|
+
`create` так не умеет. Он резолвит пира по **расположению** (`~/.iapeer/peers/<имя>`, если не передан `--path`), поэтому до записи пира, чья папка лежит в другом месте, не дотягивается вовсе — вместо этого создал бы вторую, ложную папку.
|
|
71
|
+
|
|
72
|
+
Объявление и реальность расходятся: `runtimes` только *объявляет* канал, а *реализует* его плист. Если запись рантайм объявляет, а плиста нет — команда переставляет канал, а не рапортует «already» о канале, которого не существует.
|
|
60
73
|
|
|
61
74
|
### default-runtime — переключить основной
|
|
62
75
|
|
|
@@ -69,7 +82,7 @@ iapeer default-runtime codex --all # все пиры
|
|
|
69
82
|
|
|
70
83
|
Выбор для маршрутизации строгий: отправка без явного `runtime` всегда целится в `default_runtime`. Живая сессия на другом объявленном рантайме не перехватывает такую отправку; если дефолтная сессия спит, демон будит её. Явный `runtime` по-прежнему выбирает конкретную недефолтную поверхность.
|
|
71
84
|
|
|
72
|
-
## Подключение
|
|
85
|
+
## Подключение канала: connect
|
|
73
86
|
|
|
74
87
|
```bash
|
|
75
88
|
iapeer connect telegram assistant --token <токен-бота>
|
|
@@ -79,6 +92,8 @@ iapeer connect telegram assistant --token <токен-бота>
|
|
|
79
92
|
|
|
80
93
|
Команда идемпотентна, и поведение зависит от токена. **Тот же токен** — чистый no-op: файл учётных данных бота не меняется, живой роутер уже работает на этих данных, перезапуска нет. **Новый или изменённый токен** требует перезапуска роутера (учётные данные читаются только при старте) — это секундная пауза в доставке для всех Telegram-пиров; входящие при этом не теряются (long-polling помнит смещение, Telegram держит сообщения до 24 часов). Неверный токен отсекается сразу, с дословной причиной от Telegram.
|
|
81
94
|
|
|
95
|
+
`connect` не только про Telegram. `iapeer connect <рантайм> <пир>` цепляет любой presence-канал: у Telegram остаётся его особый поток (нужны токен, поход к `@BotFather` и хранилище учётных данных), а любой другой инфраструктурный рантайм идёт по генеричной привязке — тот же пер-пировый путь, что и `add-runtime <рантайм> --peer <пир>`. Рантайм-пакет должен быть предварительно установлен на хосте (`install-runtime`); команда проверяет это до того, как что-либо тронуть.
|
|
96
|
+
|
|
82
97
|
## Рантайм-пакеты
|
|
83
98
|
|
|
84
99
|
Инфраструктурные рантаймы поставляются отдельными npm-пакетами (`*-runtime`). Ставит и обновляет их ядро:
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
| `attach <пир> [рантайм]` | Поднять (если спит), продолжить диалог, открыть сессию под супервизором в терминале (отсоединиться — `Ctrl-]`). |
|
|
50
50
|
| `interrupt <пир> [рантайм]` | Прервать текущий ход (Escape), контекст цел. |
|
|
51
51
|
| `compact <пир> [рантайм]` | Сжать диалог; спящего после усыпления сначала продолжить. |
|
|
52
|
-
| `add-runtime <рантайм> (--peer <p> \| --all)` | Добавить
|
|
52
|
+
| `add-runtime <рантайм> (--peer <p> \| --all)` | Добавить рантайм существующим пирам. `default_runtime` не трогает. Инфраструктурный рантайм цепляет always-on канал и работает только с `--peer` (никогда не сметает `--all`). |
|
|
53
53
|
| `default-runtime <рантайм> (--peer <p> \| --all)` | Переключить основной рантайм (маршрутизация/первый подъём). Откажет, если рантайм не объявлен. |
|
|
54
54
|
|
|
55
55
|
`stop`/`start`/`new` отказываются трогать пиров под чужим launchd-плистом — ядро не вмешивается в то, чем управляет не оно.
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
|---|---|
|
|
70
70
|
| `<рантайм>` (например `iapeer claude`) | Запустить пир текущей папки в этом рантайме — всегда свежей сессией. |
|
|
71
71
|
| `connect telegram <пир> [--token <t>]` | Подключить Telegram-бота к пиру: регистрация бота → запись в профиль → перезапуск роутера. Спрашивает только токен. |
|
|
72
|
+
| `connect <рантайм> <пир>` | Прицепить любой другой presence-канал (web, voicetalk, …) существующему пиру: пер-рантаймовый плист → хук `selfConfig` пакета → загрузка. Тот же путь, что `add-runtime <рантайм> --peer <пир>`. |
|
|
72
73
|
|
|
73
74
|
## Расширения и память
|
|
74
75
|
|
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
| `IAPEER_DELIVERY_LOG_KEEP` | `5` | Сколько ротаций `delivery.log` хранить. |
|
|
64
64
|
| `IAPEER_PANELOG_MAX_BYTES` | `16 МиБ` | Размер пер-пирового pane-лога (`logs/lifecycle/<runtime>-<peer>.log`, сырой байтовый поток терминала), при котором происходит ротация. |
|
|
65
65
|
| `IAPEER_PANELOG_KEEP` | `2` | Сколько ротированных копий pane-лога хранить (`<log>.1` — свежайшая … `.N`). |
|
|
66
|
+
| `IAPEER_PANELOG_DIR_BUDGET` | `512 МиБ` | Потолок хранения для ВСЕГО каталога `logs/lifecycle/` — граница, которая не растёт вместе с флотом. |
|
|
67
|
+
| `IAPEER_PANELOG_STALE_DAYS` | `14` | Возраст, после которого артефакт pane-лога (база или копия) освобождается; именно это убирает логи мёртвых и удалённых пиров. |
|
|
66
68
|
| `IAPEER_SUPERVISE_LOG_VERBOSE` | выкл. | Подробный лог надзорных тиков (по умолчанию пишутся только решения, не рутинные «ничего не делаю»). |
|
|
67
69
|
| `IAPEER_DAEMON_LOG` | выкл. | Расширенный лог демона. |
|
|
68
70
|
|
|
@@ -84,6 +86,14 @@
|
|
|
84
86
|
|
|
85
87
|
Пер-пировые **pane-логи** лежат отдельно в `~/.iapeer/logs/lifecycle/<runtime>-<peer>.log` — сырой байтовый поток терминала (pty), который супервизор дописывает всю жизнь сессии (его читают детект занятости/typing, boot ready-gate и `tail`-мониторы). Они ротируются на надзорном тике методом **copytruncate** (`IAPEER_PANELOG_MAX_BYTES` × `IAPEER_PANELOG_KEEP`, по умолчанию 16 МиБ × 2): базовый файл сохраняет путь и inode (его последние 8 МиБ остаются на месте, поэтому живой писатель и все читатели не задеты), а предшествующее окно повышается в `<log>.1` … `.N`. Суммарный объём на пира ограничен примерно `MAX_BYTES × (KEEP + 1)`.
|
|
86
88
|
|
|
89
|
+
Эта пер-файловая граница действует лишь *на одного пира*, поэтому суммарный вес каталога оставался произведением размера флота (пиры × рантаймы), а артефакты умершего или удалённого пира не освобождались никогда. Поэтому тот же надзорный тик выполняет над каталогом проход **хранения (retention)**, освобождая место в порядке «сначала наименее ценное»:
|
|
90
|
+
|
|
91
|
+
1. **Возраст** — любой артефакт (база или копия), не тронутый дольше `IAPEER_PANELOG_STALE_DAYS` (14 сут.), удаляется. Настолько холодную базу не читает ничто живое: idle-reap завершает тёплую сессию за минуты, а seed boot-гейта и сигнал занятости по mtime смотрят только на базу *текущей* сессии. Именно этот ярус освобождает мёртвых, удалённых и переименованных пиров; при следующем пробуждении супервизор создаёт файл заново.
|
|
92
|
+
2. **Бюджет / копии** — пока каталог превышает `IAPEER_PANELOG_DIR_BUDGET` (512 МиБ), ротированные копии удаляются начиная с самых старых. Это история, которую никто не читает живьём, — верная первая жертва.
|
|
93
|
+
3. **Бюджет / страховка** — если копии кончились, а каталог всё ещё сверх бюджета, крупнейшие базы усекаются на месте до читательского пола в 8 МиБ с сохранением inode *и mtime* (запись уборщика не должна выдавать себя за активность пира в контракте занятости).
|
|
94
|
+
|
|
95
|
+
Пол яруса 3 равен `живые_идентичности × 8 МиБ`, поэтому на умолчаниях бюджет авторитетен примерно до 64 одновременно тёплых идентичностей; дальше читательский пол намеренно перевешивает бюджет (усечение ниже seed-окна лишило бы детект занятости и boot-гейт данных), проход пишет в `lifecycle.log` строку с `outcome=over-budget-after-gc`, и лечится это повышением `IAPEER_PANELOG_DIR_BUDGET`. Каждый проход, что-либо освободивший, оставляет строку `action=panelog-gc` с мегабайтами до и после. Проход идемпотентен и best-effort по построению: выполняется на каждом тике и никогда его не роняет.
|
|
96
|
+
|
|
87
97
|
### Посмертная диагностика
|
|
88
98
|
|
|
89
99
|
Причину смерти сессии (код выхода / сигнал) супервизор фиксирует в момент смерти в `exits.log` — это то, что обычная проверка «жив ли процесс» не ловит. Это материал для ответа на вопрос «почему агент умер».
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { existsSync, readFileSync, renameSync, rmSync } from 'fs'
|
|
|
15
15
|
import { fileURLToPath } from 'url'
|
|
16
16
|
import { dirname, join } from 'path'
|
|
17
17
|
import {
|
|
18
|
+
INFRA_RUNTIME_LIST,
|
|
18
19
|
isInfraRuntime,
|
|
19
20
|
isRuntime,
|
|
20
21
|
type Intelligence,
|
|
@@ -53,6 +54,7 @@ import { getAdapter } from '../launch/index.ts'
|
|
|
53
54
|
import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the supervisor client
|
|
54
55
|
import { runSupervisorClient } from '../supervisor/client.ts' // @xterm-free attach client (both reattach port-deps baked in)
|
|
55
56
|
import {
|
|
57
|
+
bootstrapFailed,
|
|
56
58
|
cycleDaemon,
|
|
57
59
|
existingAlwaysOnPlists,
|
|
58
60
|
launchctlBootstrap,
|
|
@@ -195,7 +197,7 @@ export function formatListTable(rows: PeerListing[]): string {
|
|
|
195
197
|
export interface StopStartOutcome {
|
|
196
198
|
personality: string
|
|
197
199
|
runtime: Runtime
|
|
198
|
-
action: 'stopped' | 'started' | 'bootout' | 'bootstrap' | 'refused-foreign-launchd'
|
|
200
|
+
action: 'stopped' | 'started' | 'bootout' | 'bootstrap' | 'refused-foreign-launchd' | 'no-plist'
|
|
199
201
|
reason?: string
|
|
200
202
|
}
|
|
201
203
|
|
|
@@ -320,6 +322,18 @@ export function startPeer(personality: string, runtime: string | undefined, opts
|
|
|
320
322
|
// every attempt is LOUD with the manual rescue recipe. (Also gains the
|
|
321
323
|
// sentinel fleet-guard + sandbox guard the raw spawn never had.)
|
|
322
324
|
const r = launchctlBootstrap(personality, plist, env)
|
|
325
|
+
// NO PLIST ≠ FOREIGN PLIST. The peer declares this infra runtime but no always-on
|
|
326
|
+
// plist was ever provisioned for the (personality, runtime) channel — there is
|
|
327
|
+
// nothing to bootstrap, and the honest answer is the verb that PROVISIONS it, not
|
|
328
|
+
// a fleet-guard refusal (which is what the missing file used to be misread as).
|
|
329
|
+
if (r.state === 'missing-plist') {
|
|
330
|
+
const reason =
|
|
331
|
+
`no always-on plist for the "${rt}" channel (${plist}) — nothing to start. ` +
|
|
332
|
+
`Provision the channel first: \`iapeer add-runtime ${rt} --peer ${personality}\``
|
|
333
|
+
out.push({ personality, runtime: rt, action: 'no-plist', reason })
|
|
334
|
+
appendLifecycleEvent(cfg.eventLogDir, { ev: 'started', identity, personality, runtime: rt, action: 'no-plist', reason }, { env })
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
323
337
|
const ok = r.state === 'loaded' || r.state === 'already-loaded' || r.state === 'skipped-sandbox'
|
|
324
338
|
const reason = ok
|
|
325
339
|
? undefined
|
|
@@ -380,16 +394,25 @@ export interface AddRuntimeOutcome {
|
|
|
380
394
|
|
|
381
395
|
/**
|
|
382
396
|
* add-runtime <runtime> (--peer <p> | --all) — give EXISTING peers an additional
|
|
383
|
-
*
|
|
384
|
-
*
|
|
397
|
+
* runtime in one command. Two shapes:
|
|
398
|
+
* • AGENTIC runtime (claude/codex) — the fleet-switch enabler (a claude-only peer
|
|
399
|
+
* has nothing to switch to); works per-peer or as an `--all` sweep.
|
|
400
|
+
* • INFRA/presence runtime (telegram/web/voicetalk/notifier) — attach ANOTHER
|
|
401
|
+
* always-on CHANNEL to an existing personality (`--peer` only, never a sweep).
|
|
402
|
+
* This is the operator path the multi-plist scheme (docs/09 §Plist scheme)
|
|
403
|
+
* always implied but nothing implemented: `create` resolves a peer by LOCATION
|
|
404
|
+
* (default `~/.iapeer/peers/<p>`), so for a peer whose cwd lives elsewhere it
|
|
405
|
+
* cannot reach the existing record at all — it would scaffold a second, bogus
|
|
406
|
+
* folder. add-runtime resolves the cwd from the REGISTRY, which is exactly the
|
|
407
|
+
* resolution this needs.
|
|
385
408
|
* Per target: initPeer({cwd, runtime}) — ensurePeerProfile MERGES runtimes (the
|
|
386
409
|
* default_runtime lever is deliberately untouched — capability ≠ routing flip; see
|
|
387
|
-
* `default-runtime`), scaffolds the runtime scope, and
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
410
|
+
* `default-runtime`), scaffolds the runtime scope, and for infra installs the
|
|
411
|
+
* per-runtime always-on plist, runs the runtime package's own selfConfig hook (which
|
|
412
|
+
* owns `interfaces.<runtime>`), then bootstraps it. The codex side runs its whole
|
|
413
|
+
* birth chain: cwd pre-trust, native-memory lever, memory provision (occasion=birth),
|
|
414
|
+
* host-wide MCP block + update-check-off. Idempotent by construction (merge +
|
|
415
|
+
* append-if-absent everywhere).
|
|
393
416
|
*/
|
|
394
417
|
export async function addRuntime(
|
|
395
418
|
runtime: string,
|
|
@@ -397,8 +420,16 @@ export async function addRuntime(
|
|
|
397
420
|
): Promise<AddRuntimeOutcome[]> {
|
|
398
421
|
const env = opts.env ?? process.env
|
|
399
422
|
if (!isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
|
|
400
|
-
|
|
401
|
-
|
|
423
|
+
// The real invariant behind the old blanket infra refusal is "an always-on channel is
|
|
424
|
+
// never provisioned by a SWEEP" — each plist is a launchd job, and fanning them across
|
|
425
|
+
// the whole fleet is never what an operator means. That is a guard on `--all`, NOT on
|
|
426
|
+
// the runtime class: attaching one channel to ONE named peer is precisely the operator
|
|
427
|
+
// path the multi-plist scheme exists for, and it had no verb at all.
|
|
428
|
+
if (isInfraRuntime(runtime) && opts.all === true) {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`"${runtime}" is an infra runtime — an always-on channel is provisioned per peer, never as an --all sweep; ` +
|
|
431
|
+
`target one peer: \`iapeer add-runtime ${runtime} --peer <peer>\``,
|
|
432
|
+
)
|
|
402
433
|
}
|
|
403
434
|
const index = readPeersIndex({ env })
|
|
404
435
|
const targets = opts.all === true ? index.peers : index.peers.filter(p => p.personality === opts.peer)
|
|
@@ -414,18 +445,36 @@ export async function addRuntime(
|
|
|
414
445
|
const { initPeer } = await import('../init/index.ts')
|
|
415
446
|
const out: AddRuntimeOutcome[] = []
|
|
416
447
|
for (const p of targets) {
|
|
417
|
-
|
|
448
|
+
// Skip an infra-DEFAULT peer only when the runtime being added is AGENTIC: giving a
|
|
449
|
+
// router peer (a human on telegram) an agent runtime is meaningless. Adding another
|
|
450
|
+
// CHANNEL to that same human is the whole point of this path, so it must not be
|
|
451
|
+
// caught by the same guard — the old check keyed on the peer alone and swallowed it.
|
|
452
|
+
if (isInfraRuntime(p.runtime) && !isInfraRuntime(runtime)) {
|
|
418
453
|
out.push({ personality: p.personality, action: 'skipped-infra-peer', detail: `default runtime "${p.runtime}" is infra` })
|
|
419
454
|
continue
|
|
420
455
|
}
|
|
421
|
-
|
|
456
|
+
// DECLARED ≠ REALIZED. For an infra channel the record's `runtimes` is only the
|
|
457
|
+
// DECLARATION; the always-on plist is what actually realizes it, and the two can
|
|
458
|
+
// diverge (a hand-built bind, a torn-down plist, a record restored from backup).
|
|
459
|
+
// Short-circuiting on the declaration alone would report "already" while the channel
|
|
460
|
+
// does not exist — so for infra, converge: a missing plist means there IS work to do.
|
|
461
|
+
// initPeer is idempotent, so the repair costs nothing when everything is in place.
|
|
462
|
+
const realized = isInfraRuntime(runtime)
|
|
463
|
+
? existsSync(resolveAlwaysOnTarget(p.personality, runtime as Runtime, env).path)
|
|
464
|
+
: true
|
|
465
|
+
if (p.runtimes.includes(runtime as Runtime) && realized) {
|
|
422
466
|
out.push({ personality: p.personality, action: 'already' })
|
|
423
467
|
continue
|
|
424
468
|
}
|
|
425
469
|
try {
|
|
426
470
|
const warns: string[] = []
|
|
471
|
+
const repair = p.runtimes.includes(runtime as Runtime)
|
|
427
472
|
await initPeer({ cwd: p.cwd, runtime: runtime as Runtime, env: cleanEnv, warn: m => warns.push(m) })
|
|
428
|
-
out.push({
|
|
473
|
+
out.push({
|
|
474
|
+
personality: p.personality,
|
|
475
|
+
action: 'added',
|
|
476
|
+
detail: [repair ? 'declared but no plist — channel re-provisioned' : undefined, ...warns].filter(Boolean).join(' | ') || undefined,
|
|
477
|
+
})
|
|
429
478
|
} catch (e) {
|
|
430
479
|
out.push({ personality: p.personality, action: 'failed', detail: e instanceof Error ? e.message : String(e) })
|
|
431
480
|
}
|
|
@@ -1037,6 +1086,52 @@ async function connectTelegramVerb(
|
|
|
1037
1086
|
return rs.state === 'restarted' ? 0 : 1
|
|
1038
1087
|
}
|
|
1039
1088
|
|
|
1089
|
+
/**
|
|
1090
|
+
* `connect <runtime> <peer>` for a NON-telegram presence runtime — attach one always-on
|
|
1091
|
+
* channel to an EXISTING personality. Thin surface over addRuntime's per-peer infra path
|
|
1092
|
+
* (registry-resolved cwd → initPeer → per-runtime plist + the package's selfConfig hook
|
|
1093
|
+
* that owns `interfaces.<runtime>` → bootstrap); this exists so the operator reaches it
|
|
1094
|
+
* by the same verb telegram taught them. Returns the process exit code.
|
|
1095
|
+
*/
|
|
1096
|
+
async function connectChannelVerb(
|
|
1097
|
+
runtime: string,
|
|
1098
|
+
peer: string,
|
|
1099
|
+
env: NodeJS.ProcessEnv,
|
|
1100
|
+
out: (s: string) => void,
|
|
1101
|
+
errOut: (s: string) => void,
|
|
1102
|
+
): Promise<number> {
|
|
1103
|
+
// A channel whose runtime package was never installed would provision a plist whose
|
|
1104
|
+
// launcher does not resolve — provisionPeer refuses, but late and obscurely. Say it here.
|
|
1105
|
+
const { isRuntimeInstalled } = await import('../init/index.ts')
|
|
1106
|
+
if (!isRuntimeInstalled(runtime as Runtime, env)) {
|
|
1107
|
+
errOut(`connect ${runtime} ${peer}: runtime "${runtime}" is not installed on this host — \`iapeer install-runtime ${runtime}\` first\n`)
|
|
1108
|
+
return 1
|
|
1109
|
+
}
|
|
1110
|
+
let outcomes: AddRuntimeOutcome[]
|
|
1111
|
+
try {
|
|
1112
|
+
outcomes = await addRuntime(runtime, { peer, env })
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
errOut(`connect ${runtime} ${peer}: ${e instanceof Error ? e.message : String(e)}\n`)
|
|
1115
|
+
return 1
|
|
1116
|
+
}
|
|
1117
|
+
const o = outcomes.find(x => x.personality === peer) ?? outcomes[0]
|
|
1118
|
+
if (!o || o.action === 'failed') {
|
|
1119
|
+
errOut(`connect ${runtime} ${peer}: ${o?.detail ?? 'failed'}\n`)
|
|
1120
|
+
return 1
|
|
1121
|
+
}
|
|
1122
|
+
if (o.action === 'skipped-infra-peer') {
|
|
1123
|
+
errOut(`connect ${runtime} ${peer}: ${o.detail ?? 'skipped'}\n`)
|
|
1124
|
+
return 1
|
|
1125
|
+
}
|
|
1126
|
+
const plist = resolveAlwaysOnTarget(peer, runtime as Runtime, env)
|
|
1127
|
+
out(
|
|
1128
|
+
o.action === 'already'
|
|
1129
|
+
? `"${peer}" already carries the "${runtime}" channel (${plist.label}) — nothing to do\n`
|
|
1130
|
+
: `connected "${runtime}" to "${peer}": plist ${plist.path} (${plist.label}) installed + loaded${o.detail ? `; ${o.detail}` : ''}\n`,
|
|
1131
|
+
)
|
|
1132
|
+
return 0
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1040
1135
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1041
1136
|
// rename — first-class peer-identity rename (parity with remove/create). Wraps
|
|
1042
1137
|
// renamePeer (registry + per-cwd profile, atomic, inside the lock) and KEEPS the
|
|
@@ -1381,6 +1476,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1381
1476
|
},
|
|
1382
1477
|
{ sig: '<runtime>', desc: "launch the cwd's peer FRESH; on a TTY drop into it (like attach), else detached" },
|
|
1383
1478
|
{ sig: 'connect telegram <peer> [--token <t>]', desc: 'attach a telegram bot to a peer (bot add → interface → router restart; asks only the token). Alias: `iapeer enable telegram <peer>`' },
|
|
1479
|
+
{ sig: 'connect <runtime> <peer>', desc: 'attach another always-on presence channel (web, voicetalk, …) to an EXISTING peer: per-runtime plist → the package self-config hook → load. Same as `add-runtime <runtime> --peer <peer>`' },
|
|
1384
1480
|
{ sig: 'enable <plugin> [peer] [--no-setup]', desc: 'install + enable an agfpd capability for a peer' },
|
|
1385
1481
|
{
|
|
1386
1482
|
sig: 'new <peer> [runtime]',
|
|
@@ -1883,7 +1979,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1883
1979
|
// В47 — a failed runtime self-config means initPeer deliberately did NOT bootstrap:
|
|
1884
1980
|
// the always-on peer is not running. Exit 0 here read as success to automation.
|
|
1885
1981
|
if (r.selfConfig?.state === 'failed') return 1
|
|
1886
|
-
return r.bootstrapped && (r.bootstrapped.state
|
|
1982
|
+
return r.bootstrapped && bootstrapFailed(r.bootstrapped.state) ? 1 : 0
|
|
1887
1983
|
}
|
|
1888
1984
|
case 'create': {
|
|
1889
1985
|
// cwd-INDEPENDENT: resolve a location (default ~/.iapeer/peers/<p> or --path),
|
|
@@ -1957,7 +2053,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1957
2053
|
// always-on peer is NOT running; `create x --runtime telegram` with a broken hook
|
|
1958
2054
|
// exited 0 and automation read a dead telegram router as provisioned.
|
|
1959
2055
|
if (r.selfConfig?.state === 'failed') return 1
|
|
1960
|
-
return r.bootstrapped && (r.bootstrapped.state
|
|
2056
|
+
return r.bootstrapped && bootstrapFailed(r.bootstrapped.state) ? 1 : 0
|
|
1961
2057
|
}
|
|
1962
2058
|
case 'list': {
|
|
1963
2059
|
// tty + no --json → the live Ink dashboard (Фаза 3: host header · live peer
|
|
@@ -2075,7 +2171,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2075
2171
|
// Fleet-switch enabler (codex-parity audit): add an agentic runtime to
|
|
2076
2172
|
// existing peer(s) — full codex birth chain per target, idempotent.
|
|
2077
2173
|
const rt = positionals[0]
|
|
2078
|
-
if (!rt) return argErr(errOut, 'add-runtime needs a runtime — usage: iapeer add-runtime <runtime> (--peer <p> | --all)')
|
|
2174
|
+
if (!rt) return argErr(errOut, 'add-runtime needs a runtime — usage: iapeer add-runtime <runtime> (--peer <p> | --all); an infra/presence runtime is --peer only')
|
|
2079
2175
|
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
2080
2176
|
if (flags.all !== true && !peerName) return argErr(errOut, 'add-runtime needs a target — pass --peer <p> or --all')
|
|
2081
2177
|
const outcomes = await addRuntime(rt, { peer: peerName, all: flags.all === true, env })
|
|
@@ -2123,7 +2219,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2123
2219
|
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
2124
2220
|
// В48 — a failed launchctl bootstrap (reason set) means the always-on peer is NOT
|
|
2125
2221
|
// started; exit 0 here let automation read a dead telegram/notifier as running.
|
|
2126
|
-
return outcomes.some(
|
|
2222
|
+
return outcomes.some(
|
|
2223
|
+
o => o.action === 'refused-foreign-launchd' || o.action === 'no-plist' || (o.action === 'bootstrap' && o.reason !== undefined),
|
|
2224
|
+
)
|
|
2225
|
+
? 1
|
|
2226
|
+
: 0
|
|
2127
2227
|
}
|
|
2128
2228
|
case 'refresh': {
|
|
2129
2229
|
// LAZY soft-reload (fleet doctrine refresh): arm `.fresh-next` so each agentic peer comes up FRESH
|
|
@@ -2651,8 +2751,24 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2651
2751
|
// `connect telegram <peer> [--token <t>]`. The human owes only the token;
|
|
2652
2752
|
// alias/bot-add/interface/router-restart are resolved by the system. The
|
|
2653
2753
|
// FIRST message from the human to the bot activates the chat (platform rule).
|
|
2654
|
-
|
|
2655
|
-
|
|
2754
|
+
// GENERALIZED (was telegram-only): `connect <runtime> <peer>` attaches ANY
|
|
2755
|
+
// presence channel. telegram keeps its bespoke flow (it owes a bot token, a
|
|
2756
|
+
// @BotFather round-trip and a credential store); every other infra runtime is
|
|
2757
|
+
// the generic bind — add-runtime's per-peer infra path. Same verb, same shape,
|
|
2758
|
+
// so "give this person a voicetalk channel" is discoverable where the operator
|
|
2759
|
+
// already looks.
|
|
2760
|
+
const channel = positionals[0]
|
|
2761
|
+
const channelPeer = positionals[1]
|
|
2762
|
+
if (!channel || !channelPeer) {
|
|
2763
|
+
return argErr(errOut, 'connect needs "<runtime> <peer>" — usage: iapeer connect telegram <peer> [--token <t>] | iapeer connect <runtime> <peer> (e.g. iapeer connect voicetalk arthur)')
|
|
2764
|
+
}
|
|
2765
|
+
if (channel === 'telegram') {
|
|
2766
|
+
return await connectTelegramVerb(channelPeer, typeof flags.token === 'string' ? flags.token : undefined, env, out, errOut)
|
|
2767
|
+
}
|
|
2768
|
+
if (!isRuntime(channel) || !isInfraRuntime(channel)) {
|
|
2769
|
+
return argErr(errOut, `connect: "${channel}" is not a presence runtime — connect attaches an always-on CHANNEL (${INFRA_RUNTIME_LIST.join(', ')}); for an agent runtime use \`iapeer add-runtime ${channel} --peer ${channelPeer}\``)
|
|
2770
|
+
}
|
|
2771
|
+
return await connectChannelVerb(channel, channelPeer, env, out, errOut)
|
|
2656
2772
|
}
|
|
2657
2773
|
case 'enable': {
|
|
2658
2774
|
// DISCOVERABILITY ALIAS: `enable telegram <peer>` → `connect telegram <peer>`
|
package/src/init/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from '../storage/index.ts'
|
|
31
31
|
import { provisionPeer, type ProvisionResult } from '../provision/index.ts'
|
|
32
32
|
import { claudeSettingsPath } from '../launch/nativeMemory.ts'
|
|
33
|
-
import { launchctlBootstrap, resolveAlwaysOnTarget, type BootstrapResult } from '../launch/launchd.ts'
|
|
33
|
+
import { bootstrapFailed, launchctlBootstrap, resolveAlwaysOnTarget, type BootstrapResult } from '../launch/launchd.ts'
|
|
34
34
|
import { runtimeSelfConfig, type SelfConfigResult } from '../runtime/index.ts'
|
|
35
35
|
import type { Intelligence } from '../core/constants.ts'
|
|
36
36
|
|
|
@@ -576,7 +576,7 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
|
|
|
576
576
|
resolveAlwaysOnTarget(provisioned.personality, provisioned.runtime, env).path,
|
|
577
577
|
env,
|
|
578
578
|
)
|
|
579
|
-
if (bootstrapped.state
|
|
579
|
+
if (bootstrapFailed(bootstrapped.state)) {
|
|
580
580
|
opts.warn?.(`bootstrap ${bootstrapped.state}: ${bootstrapped.detail ?? ''}`)
|
|
581
581
|
}
|
|
582
582
|
}
|
package/src/launch/cmdlog.ts
CHANGED
|
@@ -21,7 +21,24 @@
|
|
|
21
21
|
// swallows errors. The dropped head of an already-oversized legacy file is redraw noise; the
|
|
22
22
|
// last maxBytes is snapshotted, older bytes are discarded (never a giant copy of the bloat).
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// VOLUME RETENTION (gcPaneLogs, below) is the SECOND half of the bound. Per-file rotation caps one
|
|
25
|
+
// identity (16 MiB base + N×16 MiB copies), but the DIRECTORY total is then a product of the fleet
|
|
26
|
+
// size — it only ever grows with peers × runtimes, and nothing ever reclaimed the artifacts of a peer
|
|
27
|
+
// that died or was removed months ago. The janitor therefore also enforces an age retention and a
|
|
28
|
+
// hard directory BUDGET each tick, so the ceiling is a fixed number instead of a per-peer multiple.
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
openSync,
|
|
32
|
+
closeSync,
|
|
33
|
+
readSync,
|
|
34
|
+
readdirSync,
|
|
35
|
+
statSync,
|
|
36
|
+
unlinkSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
fstatSync,
|
|
39
|
+
renameSync,
|
|
40
|
+
utimesSync,
|
|
41
|
+
} from 'fs'
|
|
25
42
|
import { join } from 'path'
|
|
26
43
|
|
|
27
44
|
/** Rotate threshold (env IAPEER_PANELOG_MAX_BYTES) — a base pane-log over this is rotated.
|
|
@@ -36,6 +53,17 @@ export const PANELOG_COPIES = 2
|
|
|
36
53
|
* env-tunable: capping the base below the seed would starve occupancy/ready-gate detection. */
|
|
37
54
|
export const PANELOG_TAIL_BYTES = 8 * 1024 * 1024
|
|
38
55
|
|
|
56
|
+
/** Directory BUDGET (env IAPEER_PANELOG_DIR_BUDGET) — the observable ceiling on the WHOLE
|
|
57
|
+
* pane-log dir, enforced every tick by gcPaneLogs. Default 512 MiB. Sized off the live shape of
|
|
58
|
+
* this host: the artifacts that are load-bearing are the bases of CONCURRENTLY warm sessions
|
|
59
|
+
* (~12 × ≤16 MiB here), everything else is history. See gcPaneLogs for the honest boundary. */
|
|
60
|
+
export const PANELOG_DIR_BUDGET_BYTES = 512 * 1024 * 1024
|
|
61
|
+
/** Age retention (env IAPEER_PANELOG_STALE_DAYS) — any pane-log artifact untouched for longer is
|
|
62
|
+
* reclaimed. Default 14 d: FAR beyond any live signal (idle-reap kills a warm session in minutes,
|
|
63
|
+
* the ready-gate seed and the mtime-occupancy contract only ever read a CURRENT session's base),
|
|
64
|
+
* so a base this cold belongs to a peer that is dead, removed, or renamed. A wake recreates it. */
|
|
65
|
+
export const PANELOG_STALE_MS = 14 * 24 * 60 * 60 * 1000
|
|
66
|
+
|
|
39
67
|
/** Parse a positive integer env value, else the default (mirror of the daemon-log config helpers). */
|
|
40
68
|
function envPosInt(raw: string | undefined, dflt: number): number {
|
|
41
69
|
const n = raw !== undefined ? Number(raw) : NaN
|
|
@@ -135,3 +163,164 @@ export function capPaneLogs(
|
|
|
135
163
|
}
|
|
136
164
|
return rotated
|
|
137
165
|
}
|
|
166
|
+
|
|
167
|
+
// ─────────────────────────── volume retention (dir budget) ───────────────────────────
|
|
168
|
+
|
|
169
|
+
/** Resolve the retention knobs from env (defaults above). */
|
|
170
|
+
export function paneLogGcConfig(env: NodeJS.ProcessEnv = process.env): { budgetBytes: number; staleMs: number } {
|
|
171
|
+
const days = env.IAPEER_PANELOG_STALE_DAYS
|
|
172
|
+
const staleDays = days !== undefined && Number.isFinite(Number(days)) && Number(days) > 0 ? Number(days) : 14
|
|
173
|
+
return {
|
|
174
|
+
budgetBytes: envPosInt(env.IAPEER_PANELOG_DIR_BUDGET, PANELOG_DIR_BUDGET_BYTES),
|
|
175
|
+
staleMs: Math.round(staleDays * 24 * 60 * 60 * 1000),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One pane-log artifact on disk. `base` = `<identity>.log` (live-readable), else a rotated copy. */
|
|
180
|
+
type PaneLogArtifact = { path: string; size: number; mtimeMs: number; base: boolean }
|
|
181
|
+
|
|
182
|
+
/** Snapshot the dir's artifacts (bases + rotated copies). Unreadable entries are skipped. */
|
|
183
|
+
function scanPaneLogs(logDir: string): PaneLogArtifact[] {
|
|
184
|
+
let names: string[]
|
|
185
|
+
try {
|
|
186
|
+
names = readdirSync(logDir)
|
|
187
|
+
} catch {
|
|
188
|
+
return []
|
|
189
|
+
}
|
|
190
|
+
const out: PaneLogArtifact[] = []
|
|
191
|
+
for (const name of names) {
|
|
192
|
+
const base = name.endsWith('.log')
|
|
193
|
+
if (!base && !/\.log\.\d+$/.test(name)) continue // only OUR artifacts — never a foreign file
|
|
194
|
+
const path = join(logDir, name)
|
|
195
|
+
try {
|
|
196
|
+
const st = statSync(path)
|
|
197
|
+
if (st.isFile()) out.push({ path, size: st.size, mtimeMs: st.mtimeMs, base })
|
|
198
|
+
} catch {
|
|
199
|
+
/* vanished mid-scan → fine */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Truncate a base IN PLACE to its last tailBytes, PRESERVING its mtime. Same inode (writer's
|
|
206
|
+
* O_APPEND fd + В19 heal survive, exactly as rotateFile step 3), and the mtime restore keeps the
|
|
207
|
+
* pane-log OCCUPANCY contract honest: a janitor write must never masquerade as peer activity.
|
|
208
|
+
* Returns the bytes reclaimed (0 on no-op/error). */
|
|
209
|
+
function shrinkBase(path: string, tailBytes: number): number {
|
|
210
|
+
try {
|
|
211
|
+
const st = statSync(path)
|
|
212
|
+
if (st.size <= tailBytes) return 0
|
|
213
|
+
const tail = readTail(path, tailBytes)
|
|
214
|
+
if (tail === null) return 0
|
|
215
|
+
writeFileSync(path, tail, { mode: 0o600 })
|
|
216
|
+
try {
|
|
217
|
+
utimesSync(path, st.atime, st.mtime) // janitor ≠ activity
|
|
218
|
+
} catch {
|
|
219
|
+
/* best-effort */
|
|
220
|
+
}
|
|
221
|
+
return st.size - tail.length
|
|
222
|
+
} catch {
|
|
223
|
+
return 0
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** What one GC pass did — observability for the supervise-tick trace. */
|
|
228
|
+
export type PaneLogGcResult = {
|
|
229
|
+
bytesBefore: number
|
|
230
|
+
bytesAfter: number
|
|
231
|
+
/** Artifacts deleted by the AGE retention (dead/removed peers, ancient rotations). */
|
|
232
|
+
reapedStale: number
|
|
233
|
+
/** Rotated copies deleted by the BUDGET, oldest first. */
|
|
234
|
+
droppedCopies: number
|
|
235
|
+
/** Live bases shrunk to the reader-floor as the budget backstop. */
|
|
236
|
+
shrunkBases: number
|
|
237
|
+
/** True when even the backstop could not reach the budget — see the honest boundary below. */
|
|
238
|
+
overBudget: boolean
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* RETENTION pass over the pane-log dir — turns the per-file rotation bound into a DIRECTORY bound.
|
|
243
|
+
* Runs right after capPaneLogs on every supervise tick (idempotent, best-effort, no manual step),
|
|
244
|
+
* reclaiming in cheapest-loss-first order:
|
|
245
|
+
*
|
|
246
|
+
* 1. AGE — every artifact (base OR copy) untouched for > staleMs is deleted. This is what finally
|
|
247
|
+
* reclaims peers that died, were removed, or renamed: nothing live reads a base that cold, and
|
|
248
|
+
* the supervisor recreates it on the next wake.
|
|
249
|
+
* 2. BUDGET / copies — while the dir exceeds budgetBytes, delete rotated copies OLDEST FIRST.
|
|
250
|
+
* Copies are history nobody reads live, so they are the correct first casualty.
|
|
251
|
+
* 3. BUDGET / backstop — if copies ran out and the dir is STILL over, shrink the largest bases to
|
|
252
|
+
* the reader-floor (tailBytes, ≥ the ready-gate seed window) with mtime preserved.
|
|
253
|
+
*
|
|
254
|
+
* HONEST BOUNDARY: the floor of step 3 is `live-identities × tailBytes` (8 MiB each). The budget is
|
|
255
|
+
* therefore authoritative up to ~`budgetBytes / tailBytes` concurrently warm identities (~64 at the
|
|
256
|
+
* defaults); beyond that the reader-floor wins, `overBudget` comes back true, and the budget must be
|
|
257
|
+
* raised via IAPEER_PANELOG_DIR_BUDGET. Truncating below the seed window would starve
|
|
258
|
+
* occupancy/ready-gate detection, so the floor deliberately outranks the budget.
|
|
259
|
+
*/
|
|
260
|
+
export function gcPaneLogs(
|
|
261
|
+
logDir: string,
|
|
262
|
+
opts: { budgetBytes?: number; staleMs?: number; tailBytes?: number; nowMs?: number } = {},
|
|
263
|
+
): PaneLogGcResult {
|
|
264
|
+
const budgetBytes = opts.budgetBytes ?? PANELOG_DIR_BUDGET_BYTES
|
|
265
|
+
const staleMs = opts.staleMs ?? PANELOG_STALE_MS
|
|
266
|
+
const tailBytes = opts.tailBytes ?? PANELOG_TAIL_BYTES
|
|
267
|
+
const nowMs = opts.nowMs ?? Date.now()
|
|
268
|
+
|
|
269
|
+
let live = scanPaneLogs(logDir)
|
|
270
|
+
const bytesBefore = live.reduce((s, a) => s + a.size, 0)
|
|
271
|
+
const res: PaneLogGcResult = {
|
|
272
|
+
bytesBefore,
|
|
273
|
+
bytesAfter: bytesBefore,
|
|
274
|
+
reapedStale: 0,
|
|
275
|
+
droppedCopies: 0,
|
|
276
|
+
shrunkBases: 0,
|
|
277
|
+
overBudget: false,
|
|
278
|
+
}
|
|
279
|
+
if (live.length === 0) return res
|
|
280
|
+
|
|
281
|
+
let total = bytesBefore
|
|
282
|
+
const drop = (a: PaneLogArtifact): boolean => {
|
|
283
|
+
try {
|
|
284
|
+
unlinkSync(a.path)
|
|
285
|
+
total -= a.size
|
|
286
|
+
return true
|
|
287
|
+
} catch {
|
|
288
|
+
return false // per-file best-effort — retention never fails a supervise tick
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 1 — AGE retention.
|
|
293
|
+
const staleCutoff = nowMs - staleMs
|
|
294
|
+
const kept: PaneLogArtifact[] = []
|
|
295
|
+
for (const a of live) {
|
|
296
|
+
if (a.mtimeMs < staleCutoff && drop(a)) res.reapedStale++
|
|
297
|
+
else kept.push(a)
|
|
298
|
+
}
|
|
299
|
+
live = kept
|
|
300
|
+
|
|
301
|
+
// 2 — BUDGET: rotated copies, oldest first.
|
|
302
|
+
if (total > budgetBytes) {
|
|
303
|
+
const copies = live.filter((a) => !a.base).sort((x, y) => x.mtimeMs - y.mtimeMs)
|
|
304
|
+
for (const a of copies) {
|
|
305
|
+
if (total <= budgetBytes) break
|
|
306
|
+
if (drop(a)) res.droppedCopies++
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 3 — BUDGET backstop: shrink the largest bases to the reader-floor.
|
|
311
|
+
if (total > budgetBytes) {
|
|
312
|
+
const bases = live.filter((a) => a.base && a.size > tailBytes).sort((x, y) => y.size - x.size)
|
|
313
|
+
for (const a of bases) {
|
|
314
|
+
if (total <= budgetBytes) break
|
|
315
|
+
const freed = shrinkBase(a.path, tailBytes)
|
|
316
|
+
if (freed > 0) {
|
|
317
|
+
total -= freed
|
|
318
|
+
res.shrunkBases++
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
res.bytesAfter = total
|
|
324
|
+
res.overBudget = total > budgetBytes
|
|
325
|
+
return res
|
|
326
|
+
}
|
package/src/launch/launchd.ts
CHANGED
|
@@ -320,7 +320,8 @@ export type BootstrapState =
|
|
|
320
320
|
| 'loaded' // bootstrapped now (was not loaded)
|
|
321
321
|
| 'already-loaded' // service already in the gui domain → no-op (idempotent)
|
|
322
322
|
| 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch the real launchd
|
|
323
|
-
| 'refused-foreign' // the plist is not foundation-owned → never load someone else's
|
|
323
|
+
| 'refused-foreign' // the plist EXISTS but is not foundation-owned → never load someone else's
|
|
324
|
+
| 'missing-plist' // no plist at that path at all — nothing to load (NOT a foreign plist)
|
|
324
325
|
| 'failed' // launchctl bootstrap exited non-zero
|
|
325
326
|
|
|
326
327
|
export interface BootstrapResult {
|
|
@@ -329,6 +330,14 @@ export interface BootstrapResult {
|
|
|
329
330
|
detail?: string
|
|
330
331
|
}
|
|
331
332
|
|
|
333
|
+
/** Did a bootstrap attempt leave the job NOT running? The single definition every caller
|
|
334
|
+
* shares, so adding a state can never silently pass someone's hand-rolled failure check
|
|
335
|
+
* (`state === 'failed' || state === 'refused-foreign'` had to be edited in three places).
|
|
336
|
+
* `skipped-sandbox` is a deliberate no-op, not a failure. */
|
|
337
|
+
export function bootstrapFailed(state: BootstrapState): boolean {
|
|
338
|
+
return state === 'failed' || state === 'refused-foreign' || state === 'missing-plist'
|
|
339
|
+
}
|
|
340
|
+
|
|
332
341
|
/** Is `com.iapeer.<personality>` already loaded in the gui domain? (`launchctl print`
|
|
333
342
|
* exits 0 when the service exists.) Used to make bootstrap idempotent. */
|
|
334
343
|
function isLaunchdLoaded(label: string, uid: string): boolean {
|
|
@@ -632,6 +641,20 @@ export function launchctlBootstrap(
|
|
|
632
641
|
// (Deriving from `personality` alone would mis-label a per-runtime plist.)
|
|
633
642
|
const base = plistPath.slice(plistPath.lastIndexOf('/') + 1)
|
|
634
643
|
const label = base.endsWith('.plist') ? base.slice(0, -'.plist'.length) : launchdLabel(personality)
|
|
644
|
+
// ABSENT ≠ FOREIGN. isFoundationOwnedPlist reads the file and returns false on ANY
|
|
645
|
+
// read error, so a plist that simply does not exist used to be reported as "not
|
|
646
|
+
// foundation-owned (no sentinel) — refusing a foreign plist": a sentinel verdict on a
|
|
647
|
+
// file that was never there. That is a FALSE ownership accusation, and it sent the
|
|
648
|
+
// operator hunting for a foreign fleet plist instead of the real cause (no channel
|
|
649
|
+
// provisioned yet). Discriminate first — the caller turns this into an actionable
|
|
650
|
+
// "provision it with <verb>" instead of a fleet-guard refusal.
|
|
651
|
+
if (!existsSync(plistPath)) {
|
|
652
|
+
return {
|
|
653
|
+
state: 'missing-plist',
|
|
654
|
+
label,
|
|
655
|
+
detail: `${plistPath} does not exist — no always-on plist has been provisioned for peer "${personality}" on this channel`,
|
|
656
|
+
}
|
|
657
|
+
}
|
|
635
658
|
if (!isFoundationOwnedPlist(plistPath)) {
|
|
636
659
|
return {
|
|
637
660
|
state: 'refused-foreign',
|
|
@@ -799,8 +822,16 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
|
|
|
799
822
|
programArguments: [...(opts.entrypointArgv ?? defaultEntrypointArgv(env)), opts.personality, opts.runtime],
|
|
800
823
|
workingDirectory: opts.cwd,
|
|
801
824
|
environment,
|
|
802
|
-
|
|
803
|
-
|
|
825
|
+
// MULTI-INFRA log separation: the log dir is PERSONALITY-scoped, so with a second
|
|
826
|
+
// always-on channel both plists pointed at the SAME launchd-stdout/stderr.log and the
|
|
827
|
+
// two routers interleaved into one file (live on this host: arthur's telegram and web
|
|
828
|
+
// channels shared it) — unreadable, and a crash-loop in one channel looks like noise
|
|
829
|
+
// from the other. The per-runtime plist gets a per-runtime file. The LEGACY BASE plist
|
|
830
|
+
// keeps the unsuffixed names: it is the personality's original single channel, and
|
|
831
|
+
// renaming its log on an unrelated regeneration would orphan the operator's existing
|
|
832
|
+
// tails for no gain (same no-forced-migration rule resolveAlwaysOnTarget follows).
|
|
833
|
+
stdoutPath: join(logDir, target.scheme === 'per-runtime' ? `launchd-${opts.runtime}-stdout.log` : 'launchd-stdout.log'),
|
|
834
|
+
stderrPath: join(logDir, target.scheme === 'per-runtime' ? `launchd-${opts.runtime}-stderr.log` : 'launchd-stderr.log'),
|
|
804
835
|
throttleIntervalSecs: opts.throttleIntervalSecs,
|
|
805
836
|
}
|
|
806
837
|
mkdirSync(launchAgentsDir(env), { recursive: true })
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
29
29
|
import { renderEnvelopeForAgent } from '../codec/index.ts'
|
|
30
30
|
import { err, ok, type Result } from '../core/errors.ts'
|
|
31
|
-
import { capPaneLogs, paneLogRotateConfig } from '../launch/cmdlog.ts'
|
|
31
|
+
import { capPaneLogs, gcPaneLogs, paneLogGcConfig, paneLogRotateConfig } from '../launch/cmdlog.ts'
|
|
32
32
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
33
33
|
import { resolveMemoryWritableRoots } from '../status/index.ts'
|
|
34
34
|
import { approvalModeOf, readPeerProfile, resolveIdentity, type ApprovalMode } from '../identity/index.ts'
|
|
@@ -1524,12 +1524,31 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1524
1524
|
// to lifecycle ownership, so launchd-managed routers' pane-logs are rotated too.
|
|
1525
1525
|
const paneLogRc = paneLogRotateConfig(env)
|
|
1526
1526
|
capPaneLogs(cfg.logDir, paneLogRc.maxBytes, paneLogRc.copies)
|
|
1527
|
+
// Pane-log volume RETENTION (gcPaneLogs): rotation bounds ONE identity, but the DIRECTORY total
|
|
1528
|
+
// is that bound × the fleet — it only grew with peers × runtimes, and the artifacts of dead /
|
|
1529
|
+
// removed peers were never reclaimed. Enforce the age retention + the directory BUDGET right
|
|
1530
|
+
// after the rotation, on the same tick and the same best-effort contract (knobs:
|
|
1531
|
+
// IAPEER_PANELOG_DIR_BUDGET / IAPEER_PANELOG_STALE_DAYS, defaults 512 MiB / 14 d).
|
|
1532
|
+
const paneLogGc = gcPaneLogs(cfg.logDir, { ...paneLogGcConfig(env), nowMs })
|
|
1527
1533
|
// Durable decision trace (eventlog.ts): every reap/death/eager-fresh gets a line
|
|
1528
1534
|
// so a postmortem can answer "when & how did peer X's prior session end" even
|
|
1529
1535
|
// after the .idle-reaped / .deaths markers are consumed. alive / skipped-launchd
|
|
1530
1536
|
// are steady-state non-decisions → logged only under IAPEER_SUPERVISE_LOG_VERBOSE.
|
|
1531
1537
|
const trace = (fields: Record<string, string | number | undefined>): void =>
|
|
1532
1538
|
appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise', ...fields }, { env, nowMs })
|
|
1539
|
+
// Retention is silent in steady state (nothing to reclaim); a pass that actually reclaimed —
|
|
1540
|
+
// or that could NOT reach the budget even after the backstop — leaves a durable line.
|
|
1541
|
+
if (paneLogGc.reapedStale || paneLogGc.droppedCopies || paneLogGc.shrunkBases || paneLogGc.overBudget) {
|
|
1542
|
+
trace({
|
|
1543
|
+
action: 'panelog-gc',
|
|
1544
|
+
stale: paneLogGc.reapedStale,
|
|
1545
|
+
copies: paneLogGc.droppedCopies,
|
|
1546
|
+
shrunk: paneLogGc.shrunkBases,
|
|
1547
|
+
mb_before: Math.round(paneLogGc.bytesBefore / (1024 * 1024)),
|
|
1548
|
+
mb_after: Math.round(paneLogGc.bytesAfter / (1024 * 1024)),
|
|
1549
|
+
outcome: paneLogGc.overBudget ? 'over-budget-after-gc' : 'within-budget',
|
|
1550
|
+
})
|
|
1551
|
+
}
|
|
1533
1552
|
const out: SuperviseOutcome[] = []
|
|
1534
1553
|
const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace }
|
|
1535
1554
|
for (const s of readSessionStates(cfg)) {
|