@agfpd/iapeer 0.2.81 → 0.2.83
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 +1 -1
- package/src/daemon/main.ts +3 -3
- package/src/lifecycle/index.ts +80 -21
- package/src/transport/index.ts +2 -2
package/package.json
CHANGED
package/src/daemon/main.ts
CHANGED
|
@@ -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
|
-
|
|
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 */
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -376,27 +376,63 @@ function topicPath(cfg: LifecycleConfig, identity: string): string {
|
|
|
376
376
|
return join(cfg.stateDir, `${identity}.topic`)
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
|
|
380
|
-
|
|
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')
|
|
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
|
-
/**
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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:
|
|
559
|
-
//
|
|
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 topic set. 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
|
|
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
|
|
1057
|
-
// ring
|
|
1058
|
-
|
|
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
|
})
|
|
@@ -1114,11 +1160,19 @@ export interface SuperviseOutcome {
|
|
|
1114
1160
|
export interface SuperviseDeps {
|
|
1115
1161
|
env?: NodeJS.ProcessEnv
|
|
1116
1162
|
nowMs?: number
|
|
1163
|
+
/** Activity proxy seam (default: the runtime adapter's transcript/session mtime). Tests inject a
|
|
1164
|
+
* controlled value to exercise the idle-age floor without a real transcript on disk. */
|
|
1165
|
+
newestActivityMtime?: (runtime: string, cwd: string) => number | null
|
|
1166
|
+
/** Liveness seam (default: the real `sessionAlive` = pty-host OR tmux-fallback). Tests inject a
|
|
1167
|
+
* fixed verdict so the idle path is exercised hermetically — no live tmux/pty session needed. */
|
|
1168
|
+
sessionAlive?: (sock: string, identity: string) => boolean
|
|
1117
1169
|
}
|
|
1118
1170
|
|
|
1119
1171
|
export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
|
|
1120
1172
|
const env = deps.env ?? process.env
|
|
1121
1173
|
const nowMs = deps.nowMs ?? Date.now()
|
|
1174
|
+
const activityMtime = deps.newestActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).newestActivityMtime(c))
|
|
1175
|
+
const isAlive = deps.sessionAlive ?? sessionAlive
|
|
1122
1176
|
const verbose = superviseLogVerbose(env)
|
|
1123
1177
|
// tmux command-log volume cap (killer-hunt socket-command layer, launch/cmdlog.ts):
|
|
1124
1178
|
// a -v server log under a busy TUI grows ~7 KB/s, so each tick tail-keeps any log
|
|
@@ -1140,7 +1194,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1140
1194
|
continue
|
|
1141
1195
|
}
|
|
1142
1196
|
const sock = buildSocketPath(s.runtime, s.personality, cfg.sockDir)
|
|
1143
|
-
if (!
|
|
1197
|
+
if (!isAlive(sock, s.identity)) {
|
|
1144
1198
|
// A DELIBERATE stop is not a death: the stop verb parks clean
|
|
1145
1199
|
// (.stopped + .idle-reaped) and kills the session itself. Catch-up branch for
|
|
1146
1200
|
// a stop that raced this tick (or a pre-fix stop): drop the state quietly —
|
|
@@ -1189,14 +1243,19 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1189
1243
|
continue
|
|
1190
1244
|
}
|
|
1191
1245
|
// Idle accounting via the runtime adapter's activity proxy (claude transcript
|
|
1192
|
-
// / codex session mtime)
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1246
|
+
// / codex session mtime), FLOORED at wokeAt: a session cannot be idle longer than
|
|
1247
|
+
// it has been awake. A wake that produced no model turn (e.g. an attach-only resume)
|
|
1248
|
+
// leaves the proxy at its PRE-wake value; the old `?? wokeAt` floored only the NULL
|
|
1249
|
+
// case, so a stale-but-non-null proxy (older than this wake) made a freshly-woken
|
|
1250
|
+
// session look idle for its entire prior life → idle-reaped on the very next tick,
|
|
1251
|
+
// out from under an attaching operator. max(), not ??, is the fix — and it still
|
|
1252
|
+
// reaps a session that produced zero further turns (mt=wokeAt → reaped idleSecs
|
|
1253
|
+
// after the wake), and falls back to wokeAt when no adapter exists for the runtime.
|
|
1195
1254
|
let mt = s.wokeAt
|
|
1196
1255
|
try {
|
|
1197
|
-
mt =
|
|
1256
|
+
mt = Math.max(activityMtime(s.runtime, s.cwd) ?? 0, s.wokeAt)
|
|
1198
1257
|
} catch {
|
|
1199
|
-
/* no adapter for this runtime yet → wokeAt
|
|
1258
|
+
/* no adapter for this runtime yet → wokeAt baseline */
|
|
1200
1259
|
}
|
|
1201
1260
|
const ageSecs = Math.floor((nowMs - mt) / 1000)
|
|
1202
1261
|
// wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
|
package/src/transport/index.ts
CHANGED
|
@@ -494,7 +494,7 @@ export function deliverViaTmux(target: DeliveryTarget, envelope: string, cwd?: s
|
|
|
494
494
|
} while (monotonicMs() < graceDeadline)
|
|
495
495
|
return err(
|
|
496
496
|
`peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
|
|
497
|
-
`(no input-clear, no transcript advance within ${livenessGraceMs()}ms) —
|
|
497
|
+
`(no input-clear, no transcript advance within ${livenessGraceMs()}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
|
|
498
498
|
)
|
|
499
499
|
}
|
|
500
500
|
sleepSync(PASTE_SETTLE_MS)
|
|
@@ -579,7 +579,7 @@ async function deliverViaHost(
|
|
|
579
579
|
} while (monotonicMs() < graceDeadline)
|
|
580
580
|
return err(
|
|
581
581
|
`hosted peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
|
|
582
|
-
`(no transcript advance within ${graceMs}ms) —
|
|
582
|
+
`(no transcript advance within ${graceMs}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
|
|
583
583
|
)
|
|
584
584
|
}
|
|
585
585
|
|