@agfpd/iapeer 0.4.42 → 0.4.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.42",
3
+ "version": "0.4.44",
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",
@@ -6,7 +6,7 @@
6
6
  // wipe description without an explicit new value). Path helpers moved to storage.
7
7
 
8
8
  import { basename, join, resolve } from 'path'
9
- import { existsSync, readFileSync, realpathSync } from 'fs'
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmdirSync, statSync } from 'fs'
10
10
  import {
11
11
  IAPEER_DIR,
12
12
  PEER_PROFILE_FILE,
@@ -251,10 +251,64 @@ export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null
251
251
  * The profile file (basename peer-profile.json) is allowed through
252
252
  * storage.writeFileAtomic; only peers-profiles.json is guarded there.
253
253
  */
254
+ const PROFILE_LOCK_WAIT_MS = 2000
255
+ const PROFILE_LOCK_STALE_MS = 10_000
256
+
257
+ /** В31 — serialize the read-merge-write of a peer's local profile so concurrent FOUNDATION writers
258
+ * (verify --fix, the `default-runtime` verb, ensurePeerProfile at re-provision) don't lose each other's
259
+ * owner sections via a read-modify-write race. A sync mkdir-lock: writePeerProfileAtomic is sync and off
260
+ * the delivery hot path (rare operator calls). Best-effort — a stale lock is reclaimed, and on timeout it
261
+ * proceeds WITHOUT the lock so it can never deadlock. CROSS-PACKAGE CAVEAT: telegram-runtime writes this
262
+ * file with its OWN code and does not take this lock; full safety needs it to adopt the same lock (a
263
+ * separate cross-package contract — this closes the foundation-vs-foundation window). */
264
+ function withProfileLockSync<T>(cwd: string, fn: () => T): T {
265
+ const lockDir = join(cwd, '.iapeer', '.peer-profile.lock')
266
+ const deadline = Date.now() + PROFILE_LOCK_WAIT_MS
267
+ let held = false
268
+ for (;;) {
269
+ try {
270
+ mkdirSync(lockDir)
271
+ held = true
272
+ break
273
+ } catch (e) {
274
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') break // fs issue → proceed unlocked (best-effort)
275
+ try {
276
+ if (Date.now() - statSync(lockDir).mtimeMs > PROFILE_LOCK_STALE_MS) {
277
+ rmdirSync(lockDir)
278
+ continue // reclaimed a crashed writer's stale lock → retry immediately
279
+ }
280
+ } catch {
281
+ /* the lock vanished between checks — retry */
282
+ }
283
+ if (Date.now() > deadline) break // give up waiting → proceed unlocked (never deadlock)
284
+ try {
285
+ ;(globalThis as { Bun?: { sleepSync(ms: number): void } }).Bun?.sleepSync(15)
286
+ } catch {
287
+ /* */
288
+ }
289
+ }
290
+ }
291
+ try {
292
+ return fn()
293
+ } finally {
294
+ if (held)
295
+ try {
296
+ rmdirSync(lockDir)
297
+ } catch {
298
+ /* */
299
+ }
300
+ }
301
+ }
302
+
254
303
  export function writePeerProfileAtomic(cwd: string, profile: PeerProfileWrite): void {
255
304
  ensureLocalIapScaffold(cwd)
256
305
  const path = peerProfilePath(cwd)
306
+ // В31 — the ENTIRE read-merge-write runs under the per-cwd lock so a concurrent foundation writer's
307
+ // owner sections are never clobbered by a stale-read merge.
308
+ withProfileLockSync(cwd, () => writePeerProfileMerged(path, profile))
309
+ }
257
310
 
311
+ function writePeerProfileMerged(path: string, profile: PeerProfileWrite): void {
258
312
  let existing: Record<string, unknown> = {}
259
313
  try {
260
314
  const parsed = JSON.parse(readFileSync(path, 'utf8'))
@@ -345,6 +345,55 @@ export const claudeAdapter: RuntimeAdapter = {
345
345
  return best.path ? lastTimestampedEntryMs(best.path) : null
346
346
  },
347
347
 
348
+ /**
349
+ * В58 — newest FILE mtime among the session's CHILD workflow/subagent transcripts, or null when there
350
+ * are none. Claude writes them under `~/.claude/projects/<slug>/<session-uuid>/subagents/**\/agent-*.jsonl`
351
+ * (a plain subagent) and `<session-uuid>/subagents/workflows/<wf>/agent-*.jsonl` (a workflow subagent),
352
+ * appending continuously while the child runs — so a fresh mtime here means the peer is actively
353
+ * driving a workflow even though it makes no turns of its OWN (lastTurnMtime is stale). superviseTick
354
+ * folds this into the idle proxy so a peer on a long (>1h) workflow is not idle-reaped mid-work. Bounded
355
+ * recursive walk; a super-fresh file (< 60s) short-circuits so the common active-workflow case is cheap.
356
+ */
357
+ childActivityMtime(cwd: string): number | null {
358
+ const root = transcriptDir(cwd)
359
+ let sessions: string[]
360
+ try {
361
+ sessions = readdirSync(root)
362
+ } catch {
363
+ return null
364
+ }
365
+ let newest = 0
366
+ const freshFloor = Date.now() - 60_000 // any file newer than this is "definitely active" → stop early
367
+ const walk = (dir: string, depth: number): boolean => {
368
+ if (depth > 4) return false // <session>/subagents/workflows/<wf>/agent-*.jsonl — bounded
369
+ let items: import('fs').Dirent[]
370
+ try {
371
+ items = readdirSync(dir, { withFileTypes: true })
372
+ } catch {
373
+ return false
374
+ }
375
+ for (const it of items) {
376
+ const p = join(dir, it.name)
377
+ if (it.isDirectory()) {
378
+ if (walk(p, depth + 1)) return true
379
+ } else if (it.isFile() && it.name.endsWith('.jsonl')) {
380
+ try {
381
+ const mt = statSync(p).mtimeMs
382
+ if (mt > newest) newest = mt
383
+ if (mt > freshFloor) return true // active work found — no need to scan the rest
384
+ } catch {
385
+ /* race — entry vanished */
386
+ }
387
+ }
388
+ }
389
+ return false
390
+ }
391
+ for (const s of sessions) {
392
+ if (walk(join(root, s, 'subagents'), 0)) break
393
+ }
394
+ return newest > 0 ? newest : null
395
+ },
396
+
348
397
  /**
349
398
  * Resume preflight (fail-loud — never a silent fresh fallback): resolve the
350
399
  * newest transcript uuid for cwd via the claude-slug dir scan (lifecycle.
@@ -314,6 +314,13 @@ export interface RuntimeAdapter {
314
314
  * superviseTick uses THIS for idle/quiet age. null for a router (no transcript). */
315
315
  lastTurnMtime(cwd: string): number | null
316
316
 
317
+ /** В58 — newest FILE mtime among the session's CHILD workflow/subagent transcripts (a running workflow
318
+ * writes them continuously). A peer that launched a long workflow (>1h) makes NO turns of its own, so
319
+ * lastTurnMtime is stale and the idle-reap would cut the busy session mid-work. superviseTick folds
320
+ * THIS into the idle proxy so live child work keeps the session alive. Optional — null / undefined for
321
+ * runtimes without a subagent layout (routers, codex). */
322
+ childActivityMtime?(cwd: string): number | null
323
+
317
324
  /** Resume preflight — validate a resume request fail-loud (never silent fresh).
318
325
  * Returns the resolved ref (claude uuid) or ok:false with a reason. */
319
326
  resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string }
@@ -1237,6 +1237,9 @@ export interface SuperviseDeps {
1237
1237
  * idle) and NOT the pane-log mtime (a statusline re-render ticks it at idle); both falsely keep an
1238
1238
  * idle session young. Tests inject a controlled value to exercise the idle/quiet age hermetically. */
1239
1239
  lastTurnMtime?: (runtime: string, cwd: string) => number | null
1240
+ /** В58 — child workflow/subagent activity seam (default: the adapter's childActivityMtime). Folded into
1241
+ * the idle proxy so a peer driving a long workflow (no turns of its own) is not reaped mid-work. */
1242
+ childActivityMtime?: (runtime: string, cwd: string) => number | null
1240
1243
  /** Liveness seam (default: the real `sessionAlive` = pty-host pid-file check). Tests inject a fixed
1241
1244
  * verdict so the idle path is exercised hermetically — no live pty session needed. */
1242
1245
  sessionAlive?: (sock: string, identity: string) => boolean
@@ -1251,6 +1254,7 @@ interface SuperviseCtx {
1251
1254
  env: NodeJS.ProcessEnv
1252
1255
  nowMs: number
1253
1256
  lastTurnMt: (runtime: string, cwd: string) => number | null
1257
+ childActivityMt: (runtime: string, cwd: string) => number | null
1254
1258
  isAlive: (sock: string, identity: string) => boolean
1255
1259
  kill: (sock: string, identity: string) => void
1256
1260
  verbose: boolean
@@ -1265,7 +1269,7 @@ interface SuperviseCtx {
1265
1269
  * returns exactly one outcome; the trace side-effects mirror the prior inline behaviour.
1266
1270
  */
1267
1271
  function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
1268
- const { cfg, env, nowMs, lastTurnMt, isAlive, kill, verbose, trace } = ctx
1272
+ const { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace } = ctx
1269
1273
  // H4 — FIRST, before any reap. A launchd-managed peer is read-only.
1270
1274
  if (isLaunchdManaged(s.personality, env)) {
1271
1275
  if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4' })
@@ -1356,11 +1360,18 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1356
1360
  const ephemeral = isEphemeralPeer(s.cwd)
1357
1361
  let lt = 0
1358
1362
  try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
1363
+ // В58 — fold in CHILD workflow/subagent activity. A peer driving a long (>1h) workflow makes NO turns
1364
+ // of its own, so lt is stale; without this the busy-with-workflow session is idle-reaped mid-work (live
1365
+ // incident: iapeer-memory cut mid-audit). A running workflow writes its subagent transcripts
1366
+ // continuously → their fresh mtime is genuine activity. Only RAISES the floor (never keeps a truly idle
1367
+ // session alive: a finished workflow's files are old).
1368
+ let childMt = 0
1369
+ try { childMt = childActivityMt(s.runtime, s.cwd) ?? 0 } catch { /* no subagent layout → ignored */ }
1359
1370
  // В7 — fold in the last CONFIRMED live delivery. A message delivered just as the session crosses
1360
1371
  // idleSecs (codex acks BEFORE it writes the turn record) would otherwise be reaped out from under the
1361
1372
  // just-delivered message before lt catches up — a silent loss behind ok:true. lastDelivered only ever
1362
1373
  // RAISES the floor (a delivery is activity), so it can never keep a genuinely idle session alive.
1363
- const mt = Math.max(lt, s.wokeAt, readLastDelivered(cfg, s.identity))
1374
+ const mt = Math.max(lt, s.wokeAt, childMt, readLastDelivered(cfg, s.identity))
1364
1375
  const ageSecs = Math.floor((nowMs - mt) / 1000)
1365
1376
  // wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
1366
1377
  // daemon routed its outbound reply) is reaped after a QUIET window, checked
@@ -1431,6 +1442,8 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1431
1442
  const env = deps.env ?? process.env
1432
1443
  const nowMs = deps.nowMs ?? Date.now()
1433
1444
  const lastTurnMt = deps.lastTurnMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).lastTurnMtime(c))
1445
+ const childActivityMt =
1446
+ deps.childActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).childActivityMtime?.(c) ?? null)
1434
1447
  // Default seams bind the injected env so a sandboxed tick never reads/kills the real fleet.
1435
1448
  const isAlive = deps.sessionAlive ?? ((sock: string, identity: string) => sessionAlive(sock, identity, env))
1436
1449
  const kill = deps.killSession ?? ((sock: string, identity: string) => killSession(sock, identity, env))
@@ -1450,7 +1463,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1450
1463
  const trace = (fields: Record<string, string | number | undefined>): void =>
1451
1464
  appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise', ...fields }, { env, nowMs })
1452
1465
  const out: SuperviseOutcome[] = []
1453
- const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, isAlive, kill, verbose, trace }
1466
+ const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace }
1454
1467
  for (const s of readSessionStates(cfg)) {
1455
1468
  // Per-peer ISOLATION: a throw while evaluating ONE peer must NOT abort the whole sweep — that is
1456
1469
  // the silent fleet-wide reap-outage class (one malformed peer-state and NO peer gets reaped, the
@@ -490,7 +490,13 @@ export async function upsertPeer(
490
490
 
491
491
  const record: PeerRecord = {
492
492
  personality: args.personality,
493
- runtime, // args.runtime wins — caller knows the current runtime
493
+ // В34 — default_runtime is an H1 field: it INHERITS from existing, it does NOT flip to args.runtime.
494
+ // A re-provision / `init --runtime codex` on an existing claude-default peer ADDS codex to runtimes
495
+ // (below) but must NOT silently make codex the default — that mis-wakes the peer on the wrong
496
+ // runtime until the next reindex heals it (which never runs when addRuntime returns 'already'). The
497
+ // default changes ONLY via the explicit `default-runtime` verb (writes the local profile +
498
+ // reindexFromLocals). A brand-new peer (no existing) naturally takes args.runtime as its default.
499
+ runtime: existing ? existing.runtime : runtime,
494
500
  runtimes,
495
501
  description,
496
502
  intelligence,