@agfpd/iapeer 0.4.59 → 0.4.60

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.59",
3
+ "version": "0.4.60",
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",
@@ -39,6 +39,7 @@ import {
39
39
  addTopic,
40
40
  loadLifecycleConfig,
41
41
  processEagerRelaunches,
42
+ processOrphanEagerMarks,
42
43
  readTopic,
43
44
  resolveWakeRuntime,
44
45
  setEphemeralArmed,
@@ -373,6 +374,10 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
373
374
  tick: async () => {
374
375
  const outcomes = superviseTick(cfg, { env })
375
376
  await processEagerRelaunches(cfg, outcomes, { env })
377
+ // Orphaned-eager fallback: a .new-eager whose session died WITHOUT a .session
378
+ // on disk is invisible to superviseTick — relaunch it fresh here (H4-guarded;
379
+ // live incident 03.07: a self-fresh peer lay down until the owner attached).
380
+ await processOrphanEagerMarks(cfg, { env })
376
381
  // M3 drain scan — ONE mechanism for the whole serial-queue loop: feeds the
377
382
  // next task right after a reaped-ephemeral (same tick), drains on daemon
378
383
  // start (durable queue), and RETRIES a failed wake (the item stayed at the
@@ -622,9 +622,26 @@ export function resolveWakeMode(
622
622
  resolveResume: (cwd: string) => { ok: boolean; ref?: string; reason?: string },
623
623
  incomingTopic?: string,
624
624
  ): WakeMode {
625
- // 1. folder-launch → always fresh.
626
- if (argsResume === false) return { resume: false, cause: 'folder-launch' }
627
- // 2. attach → always resume, fail-loud if nothing to resume.
625
+ // 1. folder-launch → always fresh. Consume a standing .new-eager too — the mark's
626
+ // promise ("next bring-up is FRESH") is fulfilled by this launch; leaving it would
627
+ // leak an eager relaunch into a LATER, unrelated death.
628
+ if (argsResume === false) {
629
+ clearNewEager(cfg, identity)
630
+ return { resume: false, cause: 'folder-launch' }
631
+ }
632
+ // 1.5 — a standing .new-eager OUTRANKS EVERYTHING below, including attach-resume
633
+ // (live incident 03.07: a peer self-freshed, its eager relaunch never fired — the
634
+ // dead session carried no .session so the tick never saw it — and the owner's
635
+ // `iapeer list`→Enter attach then RESUMED the old context, defeating the self-fresh
636
+ // entirely). The mark is the owner's explicit "come up FRESH next"; ANY wake that
637
+ // finds it must honor it. Consume-on-fire + clear .idle-reaped (mirror .fresh-next
638
+ // hygiene: a stale clean-park marker must not make a later crash read as resume).
639
+ if (hasNewEager(cfg, identity)) {
640
+ clearNewEager(cfg, identity)
641
+ clearIdleReaped(cfg, identity)
642
+ return { resume: false, cause: 'self-fresh-eager' }
643
+ }
644
+ // 2. attach → resume, fail-loud if nothing to resume.
628
645
  if (argsResume === true) {
629
646
  const r = resolveResume(cwd)
630
647
  if (!r.ok) return { resume: false, failReason: r.reason ?? 'resume requested but nothing to resume', cause: 'attach-nothing-to-resume' }
@@ -1523,6 +1540,57 @@ export async function processEagerRelaunches(
1523
1540
  return results
1524
1541
  }
1525
1542
 
1543
+ /**
1544
+ * ORPHANED eager-mark fallback (live incident 03.07, iapeer-memory): the standard eager
1545
+ * path runs off superviseTick's `.session` walk — a peer whose session died WITHOUT a
1546
+ * `.session` on disk (an unsupervised live session: a lost/never-written state file —
1547
+ * the code even warns "live but UNSUPERVISED" on a state-write failure) is INVISIBLE to
1548
+ * the tick, so its `.new-eager` was never consumed and the peer lay DOWN until an
1549
+ * external stimulus (the owner's attach — which then also resumed the old context).
1550
+ * This scan makes the mark a DURABLE contract independent of `.session`: every daemon
1551
+ * tick, any `.new-eager` whose identity has NO live session AND NO `.session` (the
1552
+ * standard path would have handled that) is relaunched fresh here. Guards: H4
1553
+ * (launchd-managed → never touched), a LIVE session (self-fresh mark→kill window, or a
1554
+ * concurrent wake that already brought it up — the mark stays; the standard path or the
1555
+ * next tick consumes it), and the wake.lock inside wakeOrSpawn serializes against any
1556
+ * concurrent message-wake (which itself now consumes the mark via resolveWakeMode 1.5).
1557
+ */
1558
+ export async function processOrphanEagerMarks(
1559
+ cfg: LifecycleConfig,
1560
+ deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult> } = {},
1561
+ ): Promise<WakeResult[]> {
1562
+ const env = deps.env ?? process.env
1563
+ const wake = deps.wakeFn ?? wakeOrSpawn
1564
+ let entries: string[] = []
1565
+ try {
1566
+ entries = readdirSync(cfg.stateDir).filter(f => f.endsWith('.new-eager'))
1567
+ } catch {
1568
+ return [] // no state dir → no marks
1569
+ }
1570
+ const results: WakeResult[] = []
1571
+ for (const f of entries) {
1572
+ const identity = f.slice(0, -'.new-eager'.length)
1573
+ const addr = parseSessionName(identity)
1574
+ if (!addr) continue
1575
+ if (isLaunchdManaged(addr.personality, env)) continue // H4 — the daemon never touches launchd-owned peers
1576
+ if (existsSync(sessionStatePath(cfg, identity))) continue // standard eager path owns this death
1577
+ const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
1578
+ if (sessionAlive(sock, identity, env)) continue // mark→kill window or already re-launched — leave the mark
1579
+ appendLifecycleEvent(
1580
+ cfg.eventLogDir,
1581
+ { ev: 'supervise', identity, action: 'eager-orphan-fresh', reason: 'orphaned .new-eager (no .session, session dead)', outcome: 'eager-fresh' },
1582
+ { env },
1583
+ )
1584
+ clearNewEager(cfg, identity) // consume — the relaunch below is fresh by construction
1585
+ try {
1586
+ results.push(await wake({ personality: addr.personality, runtime: addr.runtime, task: '', resume: false }, { cfg, env }))
1587
+ } catch (e) {
1588
+ results.push({ status: 'FAILED', woke: false, reason: e instanceof Error ? e.message : String(e) })
1589
+ }
1590
+ }
1591
+ return results
1592
+ }
1593
+
1526
1594
  // ─────────────────────────────────────────────────────────────────────────────
1527
1595
  // wake_policy:"ephemeral" M3 — the serial-queue DRAIN. Deliveries to an ephemeral
1528
1596
  // target are always ENQUEUED (transport's injected ephemeral seam → queue.ts);