@agfpd/iapeer 0.4.59 → 0.4.61
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 +5 -0
- package/src/lifecycle/index.ts +71 -3
- package/src/supervisor/daemon.ts +22 -2
package/package.json
CHANGED
package/src/daemon/main.ts
CHANGED
|
@@ -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
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -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
|
-
|
|
627
|
-
//
|
|
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);
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -450,17 +450,37 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
450
450
|
// and a busy child's tick cadence has quiet gaps — the stability loop returns in such a gap
|
|
451
451
|
// BEFORE the redraw arrives (proven by the repro byte log: the \x1b[NA redraw came as frame #2,
|
|
452
452
|
// after the snapshot), which re-creates the dup exactly.
|
|
453
|
-
const awaitSeqBump = async (timeoutMs: number): Promise<
|
|
453
|
+
const awaitSeqBump = async (timeoutMs: number): Promise<boolean> => {
|
|
454
454
|
const seq = writeSeq
|
|
455
455
|
const deadline = Date.now() + timeoutMs
|
|
456
456
|
while (writeSeq === seq && Date.now() < deadline) await Bun.sleep(15)
|
|
457
|
+
return writeSeq !== seq // true = the child actually emitted its SIGWINCH redraw (vs a silent child)
|
|
457
458
|
}
|
|
458
459
|
const firstAttach = async (s: { write(b: Buffer): number }, c: number, r: number): Promise<void> => {
|
|
459
460
|
await awaitModelParsedStable()
|
|
461
|
+
// ORPHAN-SCROLLBACK on a shrinking attach (Артур welcome-attach corruption, 04.07 — live repro):
|
|
462
|
+
// the model is hosted at HOST 220x50; a real terminal attaches NARROWER (e.g. 120x30). applyGeometry
|
|
463
|
+
// REFLOWS the model (220→120): every wide line rewraps TALLER, and with fewer rows the TOP of the
|
|
464
|
+
// pre-resize viewport SPILLS into scrollback. The child's SIGWINCH redraw repaints only the VIEWPORT
|
|
465
|
+
// (a relative-cursor TUI can't reach scrollback), so the spilled rows linger as stale, reflow-wrapped
|
|
466
|
+
// orphans ABOVE the clean repaint — the "two frames overlaid + left-clipped" screenshot. This is a
|
|
467
|
+
// DIFFERENT artifact from the 0.4.59 viewport-dup (that stacked inside the viewport; this is scrollback).
|
|
468
|
+
const preBaseY = xterm.buffer.active.baseY // committed scrollback history depth BEFORE the resize
|
|
460
469
|
if (c !== cols || r !== rows) {
|
|
461
470
|
applyGeometry(c, r) // model + child together — one honest SIGWINCH, BEFORE the snapshot
|
|
462
|
-
await awaitSeqBump(SIGWINCH_REDRAW_WAIT_MS) // the redraw wave ARRIVES (or the child has none)
|
|
471
|
+
const redrew = await awaitSeqBump(SIGWINCH_REDRAW_WAIT_MS) // the redraw wave ARRIVES (or the child has none)
|
|
463
472
|
await awaitModelParsedStable() // …then drain it to stability (bounded, В25 cap)
|
|
473
|
+
// Erase the orphan scrollback ONLY when it is PROVABLY 100% orphan: preBaseY===0 means there was NO
|
|
474
|
+
// committed history — the ENTIRE pre-resize screen was the child's dynamic tree, which the redraw has
|
|
475
|
+
// now fully repainted into the viewport, so every post-resize scrollback row is a redundant spilled
|
|
476
|
+
// copy. Erase it server-side (ESC[3J on the MODEL → serialize then carries only the clean viewport;
|
|
477
|
+
// no clear reaches the client's native scrollback). GUARDED on an actual redraw (a silent child keeps
|
|
478
|
+
// its only copy) and on new scrollback existing. preBaseY>0 (real scroll-up history) is left intact:
|
|
479
|
+
// the history/dynamic-tree seam lives inside the child's renderer and can't be derived here, so we
|
|
480
|
+
// never risk dropping real history — that narrower case is a separate follow-up.
|
|
481
|
+
if (redrew && preBaseY === 0 && xterm.buffer.active.baseY > 0) {
|
|
482
|
+
await new Promise<void>(res => xterm.write('\x1b[3J', () => res())) // eraseScrollback + parse barrier
|
|
483
|
+
}
|
|
464
484
|
}
|
|
465
485
|
sendSnapshot(s) // serialize the post-redraw model → send → lift gate (synchronous, no interleave)
|
|
466
486
|
}
|