@agfpd/iapeer 0.4.82 → 0.4.84

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.
@@ -61,6 +61,8 @@ The daemon's supervisory tick is fixed — once a minute, not configurable.
61
61
  | `IAPEER_LIFECYCLE_LOG_KEEP` | `5` | How many `lifecycle.log` rotations to keep. |
62
62
  | `IAPEER_DELIVERY_LOG_MAX_BYTES` | `5 MiB` | The `delivery.log` size for rotation. |
63
63
  | `IAPEER_DELIVERY_LOG_KEEP` | `5` | How many `delivery.log` rotations to keep. |
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
+ | `IAPEER_PANELOG_KEEP` | `2` | How many rotated pane-log copies to keep (`<log>.1` newest … `.N`). |
64
66
  | `IAPEER_SUPERVISE_LOG_VERBOSE` | off | A verbose log of supervisory ticks (by default only decisions are written, not routine "doing nothing"). |
65
67
  | `IAPEER_DAEMON_LOG` | off | An extended daemon log. |
66
68
 
@@ -80,6 +82,8 @@ The daemon keeps three durable logs in `~/.iapeer/logs/iapeer/`:
80
82
  - **`delivery.log`** — the outcome of each delivery: who, to whom, into which runtime, the result, sizes, topic. Metadata only — **the message body is not written**.
81
83
  - **`exits.log`** — session deaths with postmortem data: the supervisor records the cause (exit code / signal) at the moment a session dies.
82
84
 
85
+ 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
+
83
87
  ### Postmortem diagnostics
84
88
 
85
89
  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".
@@ -61,6 +61,8 @@
61
61
  | `IAPEER_LIFECYCLE_LOG_KEEP` | `5` | Сколько ротаций `lifecycle.log` хранить. |
62
62
  | `IAPEER_DELIVERY_LOG_MAX_BYTES` | `5 МиБ` | Размер `delivery.log` для ротации. |
63
63
  | `IAPEER_DELIVERY_LOG_KEEP` | `5` | Сколько ротаций `delivery.log` хранить. |
64
+ | `IAPEER_PANELOG_MAX_BYTES` | `16 МиБ` | Размер пер-пирового pane-лога (`logs/lifecycle/<runtime>-<peer>.log`, сырой байтовый поток терминала), при котором происходит ротация. |
65
+ | `IAPEER_PANELOG_KEEP` | `2` | Сколько ротированных копий pane-лога хранить (`<log>.1` — свежайшая … `.N`). |
64
66
  | `IAPEER_SUPERVISE_LOG_VERBOSE` | выкл. | Подробный лог надзорных тиков (по умолчанию пишутся только решения, не рутинные «ничего не делаю»). |
65
67
  | `IAPEER_DAEMON_LOG` | выкл. | Расширенный лог демона. |
66
68
 
@@ -80,6 +82,8 @@
80
82
  - **`delivery.log`** — исход каждой доставки: кто, кому, в какой рантайм, результат, размеры, тема. Только метаданные — **тело сообщения не пишется**.
81
83
  - **`exits.log`** — смерти сессий с посмертными данными: супервизор фиксирует причину (код выхода / сигнал) в момент смерти сессии.
82
84
 
85
+ Пер-пировые **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
+
83
87
  ### Посмертная диагностика
84
88
 
85
89
  Причину смерти сессии (код выхода / сигнал) супервизор фиксирует в момент смерти в `exits.log` — это то, что обычная проверка «жив ли процесс» не ловит. Это материал для ответа на вопрос «почему агент умер».
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.82",
3
+ "version": "0.4.84",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -1,69 +1,137 @@
1
- // Pane-log volume cap — tail-keep the per-identity pane-logs (<logDir>/<identity>.log), the RAW child
2
- // pty byte stream the supervisor appends for a session's whole life. Unlike the lifecycle/delivery logs
3
- // (their own size-rotation under logs/iapeer), the pane-log had NO bound and grew to hundreds of MB per
4
- // warm peer on an always-on host; the supervise tick caps it here. Best-effort BY CONSTRUCTION: a cap
5
- // must never fail a supervise tick — every export swallows FS errors.
1
+ // Pane-log volume ROTATION — bound the per-identity pane-logs (<logDir>/<identity>.log),
2
+ // the RAW child pty byte stream the supervisor appends for a session's whole life. Unlike
3
+ // the daemon lifecycle/delivery/approvals logs (their own size+copies rotation under
4
+ // logs/iapeer via storage/rotatelog.ts), the pane-log had NO bound and grew to tens of MB
5
+ // per warm peer AND per always-on router on this host; the supervise tick rotates it here.
6
+ //
7
+ // MECHANISM — COPYTRUNCATE, not rename. The pane-log is written by a LONG-LIVED supervisor
8
+ // holding a persistent O_APPEND fd (a different process from this daemon-side janitor), and
9
+ // it is READ live by load-bearing consumers: telegram-runtime's occupancy signal keys on
10
+ // the file's MTIME at its fixed path (canon: the pane-log occupancy contract), the boot
11
+ // ready-gate seeds a headless xterm from the tail, doc's monitor tail -c's it. A rename
12
+ // would move the writer's fd onto the rotated inode and briefly vanish the base path —
13
+ // breaking mtime-occupancy. So rotation keeps the base INODE and PATH: it copies the base's
14
+ // last maxBytes out to <path>.1 (shifting older copies up), then TRUNCATES the base in place
15
+ // to its last tailBytes. Same inode ⇒ the supervisor's O_APPEND fd resumes at the new EOF
16
+ // (proven by the live truncation test), mtime stays fresh, and the В19 heal never sees an
17
+ // inode change (no spurious reopen). Readers keep reading the same path; the rotated copies
18
+ // (<path>.1..N) are history nobody reads live.
19
+ //
20
+ // Best-effort BY CONSTRUCTION: rotation must never fail a supervise tick — every FS op
21
+ // swallows errors. The dropped head of an already-oversized legacy file is redraw noise; the
22
+ // last maxBytes is snapshotted, older bytes are discarded (never a giant copy of the bloat).
6
23
 
7
- import { openSync, closeSync, readSync, readdirSync, statSync, writeFileSync, fstatSync } from 'fs'
24
+ import { openSync, closeSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync, fstatSync, renameSync } from 'fs'
8
25
  import { join } from 'path'
9
26
 
10
- /** Pane-log tail-keep cap (capPaneLogs): a per-identity pane-log over CAP is cut to
11
- * its last KEEP. KEEP_BYTES INVARIANT — must stay ≥ the pane-log reader's seed
12
- * window (readyGateModel SEED_BYTES = 4 MiB, the tail fed into the headless xterm
13
- * for composer-occupancy / viewport detection); capping below it would starve that
14
- * reader of the tail it reconstructs the live screen from. 8 MiB = 2× the seed. */
15
- export const PANELOG_CAP_BYTES = 32 * 1024 * 1024
16
- export const PANELOG_KEEP_BYTES = 8 * 1024 * 1024
27
+ /** Rotate threshold (env IAPEER_PANELOG_MAX_BYTES) — a base pane-log over this is rotated.
28
+ * Default 16 MiB (owner direction 12.07: order 10–20 MiB × 2–3 copies). */
29
+ export const PANELOG_MAX_BYTES = 16 * 1024 * 1024
30
+ /** Number of rotated copies kept (env IAPEER_PANELOG_KEEP) — <path>.1 (newest) … .N. Default 2. */
31
+ export const PANELOG_COPIES = 2
32
+ /** Base TAIL kept in place after a rotation. INVARIANT: ≥ the pane-log reader's seed window
33
+ * (readyGateModel SEED_BYTES = 4 MiB, the tail fed into the headless xterm for
34
+ * composer-occupancy / viewport). 8 MiB = 2× the seed. This is the reader-floor the base is
35
+ * truncated to — NOT the copy count (that is IAPEER_PANELOG_KEEP). Internal invariant, not
36
+ * env-tunable: capping the base below the seed would starve occupancy/ready-gate detection. */
37
+ export const PANELOG_TAIL_BYTES = 8 * 1024 * 1024
17
38
 
18
- /** Tail-keep ONE file: if it exceeds capBytes, rewrite it as its last keepBytes.
19
- * Safe against a live O_APPEND writer (the supervisor append-fd):
20
- * the next write lands at the new EOF (proven by live truncation test). Lines
21
- * appended between the tail-read and the rewrite are lost — acceptable mid-life
22
- * noise. Returns true iff the file was capped. Best-effort: swallows FS errors. */
23
- function tailKeepFile(path: string, capBytes: number, keepBytes: number): boolean {
39
+ /** Parse a positive integer env value, else the default (mirror of the daemon-log config helpers). */
40
+ function envPosInt(raw: string | undefined, dflt: number): number {
41
+ const n = raw !== undefined ? Number(raw) : NaN
42
+ return Number.isInteger(n) && n > 0 ? n : dflt
43
+ }
44
+
45
+ /** Resolve the pane-log rotation knobs from env (defaults above). maxBytes is floored just
46
+ * above the reader-tail so the base can always drop below the threshold after a rotation
47
+ * (else rotation would fire every tick). */
48
+ export function paneLogRotateConfig(env: NodeJS.ProcessEnv = process.env): { maxBytes: number; copies: number } {
49
+ const maxBytes = Math.max(envPosInt(env.IAPEER_PANELOG_MAX_BYTES, PANELOG_MAX_BYTES), PANELOG_TAIL_BYTES + 1024 * 1024)
50
+ return { maxBytes, copies: envPosInt(env.IAPEER_PANELOG_KEEP, PANELOG_COPIES) }
51
+ }
52
+
53
+ /** Read the last wantBytes of a file into a Buffer (or null on error / absent). */
54
+ function readTail(path: string, wantBytes: number): Buffer | null {
24
55
  try {
25
- if (statSync(path).size <= capBytes) return false
26
56
  const fd = openSync(path, 'r')
27
- let tail: Buffer
28
57
  try {
29
58
  const size = fstatSync(fd).size
30
- const start = Math.max(0, size - keepBytes)
31
- tail = Buffer.alloc(Math.min(keepBytes, size))
32
- readSync(fd, tail, 0, tail.length, start)
59
+ const start = Math.max(0, size - wantBytes)
60
+ const buf = Buffer.alloc(Math.min(wantBytes, size))
61
+ if (buf.length > 0) readSync(fd, buf, 0, buf.length, start)
62
+ return buf
33
63
  } finally {
34
64
  closeSync(fd)
35
65
  }
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ /** Rotate ONE pane-log file if it exceeds maxBytes (copytruncate — see file header):
72
+ * 1. snapshot the base's last maxBytes (bounded — never a giant copy of legacy bloat)
73
+ * 2. shift .(k)→.(k+1), dropping the oldest beyond `copies`; write the snapshot to .1
74
+ * 3. truncate the base IN PLACE to its last tailBytes (same inode: writer fd + mtime +
75
+ * reader seed all survive; no В19 reopen)
76
+ * Returns true iff rotated. Best-effort: swallows FS errors. */
77
+ function rotateFile(path: string, maxBytes: number, copies: number, tailBytes: number): boolean {
78
+ try {
79
+ if (statSync(path).size <= maxBytes) return false
80
+ // 1 — snapshot the tail we promote to .1, taken BEFORE any truncate (copytruncate race:
81
+ // bytes appended between here and the truncate are lost — accepted mid-life noise,
82
+ // same as the pre-rotation tail-keep).
83
+ const rotBuf = readTail(path, maxBytes)
84
+ if (rotBuf === null || rotBuf.length === 0) return false
85
+ // 2 — shift existing copies up, dropping the oldest; also sweep one orphan beyond the
86
+ // current copy count (handles a just-lowered IAPEER_PANELOG_KEEP).
87
+ try {
88
+ unlinkSync(`${path}.${copies + 1}`)
89
+ } catch {
90
+ /* absent → fine */
91
+ }
92
+ for (let k = copies - 1; k >= 1; k--) {
93
+ try {
94
+ renameSync(`${path}.${k}`, `${path}.${k + 1}`)
95
+ } catch {
96
+ /* absent → skip */
97
+ }
98
+ }
99
+ if (copies >= 1) writeFileSync(`${path}.1`, rotBuf, { mode: 0o600 })
100
+ // 3 — tail-keep the base in place (last tailBytes of the snapshot; same inode).
101
+ const tail = tailBytes >= rotBuf.length ? rotBuf : rotBuf.subarray(rotBuf.length - tailBytes)
36
102
  writeFileSync(path, tail, { mode: 0o600 })
37
103
  return true
38
104
  } catch {
39
- return false // per-file best-effort
105
+ return false // per-file best-effort — a rotation never fails a supervise tick
40
106
  }
41
107
  }
42
108
 
43
- /** Tail-keep cap over the per-identity PANE-LOGS (<logDir>/<identity>.log) — the RAW
44
- * TUI byte stream that pipe-pane (tmux) / the supervisor append for a session's whole
45
- * life. Unlike the lifecycle/delivery logs (their own size-rotation under logs/iapeer),
46
- * the pane-log had NO bound and grew to hundreds of MB per warm peer on an always-on
47
- * host. The supervise tick caps it here with the same tail-keep mechanism as the
48
- * cmd-logs. logDir (logs/lifecycle) holds ONLY pane-logs, so only top-level *.log is
49
- * touched — never the rotated logs elsewhere. KEEP_BYTES stays ≥ the reader seed
50
- * window (see PANELOG_KEEP_BYTES). Best-effort by construction. */
109
+ /**
110
+ * Rotate the per-identity PANE-LOGS in logDir (logs/lifecycle). Scans top-level `*.log`
111
+ * only — the rotated copies are `<identity>.log.1..N` (do NOT end in `.log`), so they are
112
+ * never re-processed, and no other log lives here. Called each supervise tick (NOT H4-gated:
113
+ * log janitoring is orthogonal to lifecycle ownership, so launchd-managed routers' pane-logs
114
+ * are rotated too). maxBytes/copies come from paneLogRotateConfig(env). Best-effort by
115
+ * construction. Returns the rotated paths.
116
+ */
51
117
  export function capPaneLogs(
52
118
  logDir: string,
53
- capBytes: number = PANELOG_CAP_BYTES,
54
- keepBytes: number = PANELOG_KEEP_BYTES,
119
+ maxBytes: number = PANELOG_MAX_BYTES,
120
+ copies: number = PANELOG_COPIES,
121
+ tailBytes: number = PANELOG_TAIL_BYTES,
55
122
  ): string[] {
56
- const capped: string[] = []
123
+ const cap = Math.max(maxBytes, tailBytes + 1024 * 1024) // base(tail) must be able to drop below cap
124
+ const rotated: string[] = []
57
125
  let names: string[]
58
126
  try {
59
127
  names = readdirSync(logDir)
60
128
  } catch {
61
- return capped
129
+ return rotated
62
130
  }
63
131
  for (const name of names) {
64
- if (!name.endsWith('.log')) continue
132
+ if (!name.endsWith('.log')) continue // skips the rotated <identity>.log.1/.2
65
133
  const path = join(logDir, name)
66
- if (tailKeepFile(path, capBytes, keepBytes)) capped.push(path)
134
+ if (rotateFile(path, cap, copies, tailBytes)) rotated.push(path)
67
135
  }
68
- return capped
136
+ return rotated
69
137
  }
@@ -215,13 +215,19 @@ export const launch: LaunchFn = async (
215
215
  // BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
216
216
  // surface via the pane-log model, then deliver over the socket + ready-gate on a model turn —
217
217
  // the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
218
- const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
219
218
  const isReady = await waitHostReady(
220
219
  { identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, approvalMode: spec.approvalMode, env },
221
220
  sleep,
222
221
  )
223
222
  if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
224
223
  if (firstMessage.trim().length === 0) return ready(identity) // bare bring-up — no message to deliver
224
+ // В18 — snap the ready-gate baseline AFTER boot, not before. newestActivityMtime is a raw
225
+ // transcript-file mtime that BOOT ITSELF advances: codex writes its rollout-jsonl on start,
226
+ // a claude-resume re-saves the transcript. Captured before waitHostReady, those boot writes
227
+ // alone push mtime past the baseline, so the gate below fires on the boot write — NOT on the
228
+ // model PROCESSING firstMessage — returning ready/taskDelivered while the message is unseen
229
+ // (silent loss). Snapped here — post-boot, pre-deliver — only the model's own turn advances it.
230
+ const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
225
231
  // INSTRUMENTATION (premature-ready diagnosis): record the session-start boundary and how much THIS
226
232
  // session had written into the shared pane-log by the time the ready-gate fired. On a recurrence,
227
233
  // paneLogBytes ≈ paneLogStartByte (ready seen though the new session wrote ~nothing) localizes a
@@ -26,7 +26,7 @@ import {
26
26
  } from '../core/constants.ts'
27
27
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
28
28
  import { err, ok, type Result } from '../core/errors.ts'
29
- import { capPaneLogs } from '../launch/cmdlog.ts'
29
+ import { capPaneLogs, paneLogRotateConfig } from '../launch/cmdlog.ts'
30
30
  import { resolveGlobalRoot } from '../storage/index.ts'
31
31
  import { resolveMemoryWritableRoots } from '../status/index.ts'
32
32
  import { approvalModeOf, readPeerProfile, resolveIdentity, type ApprovalMode } from '../identity/index.ts'
@@ -1491,14 +1491,16 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1491
1491
  const isAlive = deps.sessionAlive ?? ((sock: string, identity: string) => sessionAlive(sock, identity, env))
1492
1492
  const kill = deps.killSession ?? ((sock: string, identity: string) => killSession(sock, identity, env))
1493
1493
  const verbose = superviseLogVerbose(env)
1494
- // Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
1494
+ // Pane-log volume ROTATION (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
1495
1495
  // (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
1496
- // append for a session's whole life — it had NO bound and grew to hundreds of MB
1497
- // per warm peer on an always-on host. Tail-keep it each tick (KEEP ≥ the occupancy
1498
- // reader's 4 MiB seed window, so capping never starves composer detection).
1499
- // Not H4-gated: log janitoring is orthogonal to lifecycle ownership, so launchd-
1500
- // managed peers' pane-logs are capped too. Best-effort by construction.
1501
- capPaneLogs(cfg.logDir)
1496
+ // append for a session's whole life — it had NO copies-rotation and grew to tens of MB
1497
+ // per warm peer AND per always-on router on this host. Copytruncate-rotate it each tick
1498
+ // (base kept in place — reader seed + telegram mtime-occupancy intact; last maxBytes
1499
+ // snapshotted to <path>.1..N). Threshold/copies from IAPEER_PANELOG_MAX_BYTES /
1500
+ // IAPEER_PANELOG_KEEP (defaults 16 MiB × 2). Not H4-gated: log janitoring is orthogonal
1501
+ // to lifecycle ownership, so launchd-managed routers' pane-logs are rotated too.
1502
+ const paneLogRc = paneLogRotateConfig(env)
1503
+ capPaneLogs(cfg.logDir, paneLogRc.maxBytes, paneLogRc.copies)
1502
1504
  // Durable decision trace (eventlog.ts): every reap/death/eager-fresh gets a line
1503
1505
  // so a postmortem can answer "when & how did peer X's prior session end" even
1504
1506
  // after the .idle-reaped / .deaths markers are consumed. alive / skipped-launchd