@agfpd/iapeer 0.2.81 → 0.2.82

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.2.81",
3
+ "version": "0.2.82",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -35,6 +35,7 @@ import {
35
35
  drainEphemeralQueue,
36
36
  enqueueEphemeralTask,
37
37
  isEphemeralPeer,
38
+ addTopic,
38
39
  loadLifecycleConfig,
39
40
  processEagerRelaunches,
40
41
  readTopic,
@@ -42,7 +43,6 @@ import {
42
43
  setEphemeralArmed,
43
44
  superviseTick,
44
45
  wakeOrSpawn,
45
- writeTopic,
46
46
  type LifecycleConfig,
47
47
  } from '../lifecycle/index.ts'
48
48
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
@@ -135,8 +135,8 @@ export function makeNoteLiveTopic(
135
135
  ): (identity: string, topic: string) => void {
136
136
  return (identity, topic) => {
137
137
  try {
138
- if (readTopic(cfg, identity) === topic) return
139
- writeTopic(cfg, identity, topic)
138
+ if (readTopic(cfg, identity) === topic) return // unchanged most-recent → no shift, skip the note
139
+ addTopic(cfg, identity, topic) // accumulate into the session's topic set (a new live thread)
140
140
  appendLifecycleEvent(cfg.eventLogDir, { ev: 'topic-note', identity, topic }, { env })
141
141
  } catch {
142
142
  /* the marker is a discriminator hint — never fail a delivered message */
@@ -376,27 +376,63 @@ function topicPath(cfg: LifecycleConfig, identity: string): string {
376
376
  return join(cfg.stateDir, `${identity}.topic`)
377
377
  }
378
378
 
379
- /** Read the stored topic of the current/last session (executor discriminator). '' if none. */
380
- export function readTopic(cfg: LifecycleConfig, identity: string): string {
379
+ // Per-session TOPIC SET (executor resume discriminator). The `.topic` file holds the
380
+ // topics this session lineage has touched — newline-separated, most-recent LAST, deduped
381
+ // and capped. A peer can carry several threads in ONE session, so resume matches the
382
+ // incoming topic against ANY topic in the set: a later ping on an earlier thread continues
383
+ // that session's context instead of starting fresh and redoing work. The set is RESET on a
384
+ // FRESH wake (new lineage) and ACCUMULATES on the wake topic + every live delivery topic.
385
+ const MAX_SESSION_TOPICS = 16
386
+
387
+ /** All topics the current session lineage has touched (most-recent last). [] if none. */
388
+ export function readTopics(cfg: LifecycleConfig, identity: string): string[] {
381
389
  try {
382
- return readFileSync(topicPath(cfg, identity), 'utf8').trim()
390
+ return readFileSync(topicPath(cfg, identity), 'utf8')
391
+ .split('\n')
392
+ .map(t => t.trim())
393
+ .filter(Boolean)
383
394
  } catch {
384
- return ''
395
+ return []
385
396
  }
386
397
  }
387
398
 
388
- /** Store the session's working topic (raw string, best-effort). Two writers: the
389
- * wake (boot topic, wakeOrSpawn) and live deliveries via the noteLiveTopic seam
390
- * (makeNoteLiveTopic) — so the marker follows the work, not just the boot. */
391
- export function writeTopic(cfg: LifecycleConfig, identity: string, topic: string): void {
399
+ /** The session's MOST-RECENT topic (last in the set), or '' — single-value back-compat read. */
400
+ export function readTopic(cfg: LifecycleConfig, identity: string): string {
401
+ const t = readTopics(cfg, identity)
402
+ return t.length ? t[t.length - 1]! : ''
403
+ }
404
+
405
+ /** Is `topic` (non-empty) one of the session lineage's topics? Drives resume-vs-fresh. */
406
+ export function hasTopic(cfg: LifecycleConfig, identity: string, topic: string): boolean {
407
+ const t = topic.trim()
408
+ return !!t && readTopics(cfg, identity).includes(t)
409
+ }
410
+
411
+ /** Add a topic to the session's set (most-recent-last, deduped, capped). Empty → no-op.
412
+ * Two callers: the wake (boot topic, wakeOrSpawn) and live deliveries via the noteLiveTopic
413
+ * seam (makeNoteLiveTopic) — so the set follows the work, not just the boot. Best-effort. */
414
+ export function addTopic(cfg: LifecycleConfig, identity: string, topic: string): void {
415
+ const t = topic.trim()
416
+ if (!t) return
392
417
  mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
393
418
  try {
394
- writeFileSync(topicPath(cfg, identity), topic, { mode: 0o600 })
419
+ const set = readTopics(cfg, identity).filter(x => x !== t)
420
+ set.push(t)
421
+ writeFileSync(topicPath(cfg, identity), set.slice(-MAX_SESSION_TOPICS).join('\n') + '\n', { mode: 0o600 })
395
422
  } catch {
396
423
  /* best-effort — topic is a discriminator hint, never blocks a wake */
397
424
  }
398
425
  }
399
426
 
427
+ /** Drop the session's topic set — a FRESH wake starts a new lineage with no carried topics. */
428
+ export function resetTopics(cfg: LifecycleConfig, identity: string): void {
429
+ try {
430
+ rmSync(topicPath(cfg, identity), { force: true })
431
+ } catch {
432
+ /* best-effort */
433
+ }
434
+ }
435
+
400
436
  /**
401
437
  * Purge EVERY identity-keyed lifecycle artifact of `<identity>` from stateDir:
402
438
  * the marker files (`.stopped` / `.idle-reaped` / `.deaths` / `.topic` /
@@ -555,10 +591,17 @@ export function resolveWakeMode(
555
591
  ? { resume: true, resumeRef: r.ref, cause: 'idle-reaped-human' }
556
592
  : { resume: false, cause: 'idle-reaped-human-no-resume' }
557
593
  }
558
- // executor: a NEW topic (non-empty and differing from the stored one) means new
559
- // work → FRESH; same topic, or no incoming topic → continue the work → RESUME.
594
+ // executor: RESUME is an INTENTIONAL "continue THIS work" signal — it happens ONLY when
595
+ // the incoming message carries a topic that matches the session's last topic. A DIFFERENT
596
+ // topic (new work) OR NO topic at all → FRESH. Rationale: a topicless ping after an idle
597
+ // gap is not a request to resume specific work; defaulting it to resume revived stale
598
+ // context (e.g. a 3-day-old session). The sender opts INTO continuity by passing the topic.
560
599
  const topic = incomingTopic?.trim() ?? ''
561
- if (topic && topic !== readTopic(cfg, identity)) return { resume: false, cause: 'idle-reaped-new-topic' }
600
+ if (!topic) return { resume: false, cause: 'idle-reaped-no-topic' }
601
+ // RESUME if the incoming topic matches ANY thread this session lineage worked on — not
602
+ // just the last. Different threads accumulate in one session; a later ping on an earlier
603
+ // thread continues that context. A topic the session never touched → new work → FRESH.
604
+ if (!hasTopic(cfg, identity, topic)) return { resume: false, cause: 'idle-reaped-new-topic' }
562
605
  const r = resolveResume(cwd)
563
606
  return r.ok
564
607
  ? { resume: true, resumeRef: r.ref, cause: 'idle-reaped-resume' }
@@ -1053,9 +1096,12 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1053
1096
  // reply. ONLY here on the actual launch path, NEVER on the live-session fast path
1054
1097
  // above (a live session may be legitimately armed mid-quiet-window).
1055
1098
  clearEphemeralArmed(cfg, identity)
1056
- // Establish the session's topic (executor discriminator) and reset the crash-loop
1057
- // ring — a successful wake means this is NOT a tight crash loop. Best-effort.
1058
- writeTopic(cfg, identity, args.topic?.trim() ?? '')
1099
+ // Establish/extend the session's topic SET (executor resume discriminator) and reset the
1100
+ // crash-loop ring. A FRESH wake starts a new lineage → drop the old topic set; a RESUME
1101
+ // keeps accumulating. The wake's own topic joins the set (no-op if empty). resolveWakeMode
1102
+ // already read the OLD set above, so mutating here is safe. Best-effort.
1103
+ if (!mode.resume) resetTopics(cfg, identity)
1104
+ addTopic(cfg, identity, args.topic?.trim() ?? '')
1059
1105
  trimDeaths(cfg, identity, cfg.crashLoopWindowSecs, Date.now())
1060
1106
  return { status: 'READY', woke: true, runtime, process_address: identity, taskDelivered: true }
1061
1107
  })