@agfpd/iapeer 0.4.58 → 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 +1 -1
- package/src/daemon/main.ts +5 -0
- package/src/launch/adapters/codex.ts +5 -1
- package/src/lifecycle/index.ts +71 -3
- package/src/supervisor/daemon.ts +36 -25
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
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
// Active 253-261) + the keys the boot loop sends (382-400),
|
|
22
22
|
// newestCodexSessionMtime + codexSessionCwd (200-234), and
|
|
23
23
|
// answerPermissionDialog's codex branch (302-306: Down then Enter) over
|
|
24
|
-
// dialogs.ts codexApprovalActive (39-45).
|
|
24
|
+
// dialogs.ts codexApprovalActive (39-45). NB the permission autopilot was
|
|
25
|
+
// NOT ported (no answerPermissionDialog in this codebase): the permission
|
|
26
|
+
// class is closed STRUCTURALLY instead — every peer runs
|
|
27
|
+
// --dangerously-bypass-approvals-and-sandbox (buildArgs below) and init
|
|
28
|
+
// bakes default_tools_approval_mode="approve", so the dialog never shows.
|
|
25
29
|
//
|
|
26
30
|
// The doctrine -c value is the bare `model_instructions_file=<f>` token: the
|
|
27
31
|
// TOML value-side quotes in codex-start.sh:224 exist only because that string is
|
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
|
@@ -95,6 +95,10 @@ const NAG_STUCK_MS = 10_000
|
|
|
95
95
|
// so without a budget firstAttach would never complete (frozen attach). On timeout we snapshot the
|
|
96
96
|
// current model; live-forward delivers the rest once the gate lifts.
|
|
97
97
|
const MODEL_STABLE_BUDGET_MS = 500
|
|
98
|
+
/** How long firstAttach waits for the child's SIGWINCH redraw wave to ARRIVE after the attach
|
|
99
|
+
* resize (first bytes; the В25-capped stability drain follows). Signal delivery + a full TUI
|
|
100
|
+
* redraw take tens of ms; a child that answers SIGWINCH with nothing just costs this timeout. */
|
|
101
|
+
const SIGWINCH_REDRAW_WAIT_MS = 300
|
|
98
102
|
|
|
99
103
|
// Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
|
|
100
104
|
interface PtyChild {
|
|
@@ -422,36 +426,43 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
422
426
|
}
|
|
423
427
|
writeGeometry(runDir, session, c, r) // record current child geometry → sidecar for warm-deliver / ready-gate readers
|
|
424
428
|
}
|
|
425
|
-
// FIRST attach for a client. Order
|
|
429
|
+
// FIRST attach for a client. Order for a clean, coloured, correctly-sized snapshot:
|
|
426
430
|
// 1. DRAIN to stability — pending child bytes were generated under the OLD geometry; parse them BEFORE a
|
|
427
431
|
// model resize (else they rewrap wrong), and capture bytes that land while the client is gated.
|
|
428
|
-
// 2. MODEL resize to the client geometry IF it differs —
|
|
429
|
-
// (xterm.js #3093)
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
432
|
+
// 2. MODEL + CHILD resize to the client geometry IF it differs — BOTH, BEFORE the snapshot. serialize()
|
|
433
|
+
// round-trips only at matching dims (xterm.js #3093); and the child's SIGWINCH must fire NOW so its
|
|
434
|
+
// full redraw is INGESTED BY THE MODEL (client still gated) instead of being live-forwarded on top
|
|
435
|
+
// of an already-sent snapshot. The prior order (child resized AFTER the snapshot) was the attach-dup
|
|
436
|
+
// root on a BUSY session (live incident, Артур 03.07): the post-snapshot SIGWINCH redraw arrived as
|
|
437
|
+
// live bytes whose relative cursor-ups no longer matched the client screen — the whole block painted
|
|
438
|
+
// AGAIN below the snapshot (stacked "Composing…" spinners) — and every busy tick in the window before
|
|
439
|
+
// the SIGWINCH was still OLD-width, wrapping into extra phantom rows. Reproduced: 2× spinner + 2×
|
|
440
|
+
// content copies in the client buffer; with this order: 1× each.
|
|
441
|
+
// 3. WAIT for the redraw wave — the same bounded stabilization as step 1 (В25 cap applies: a saturating
|
|
442
|
+
// stream snapshots at the deadline; ticks after the snapshot are already client-width, so they land
|
|
443
|
+
// cleanly via live-forward).
|
|
444
|
+
// 4. SNAPSHOT — serialize + send + lift gate, SYNCHRONOUS (no await between the last stability check and
|
|
445
|
+
// the send), so no live byte interleaves. A same-size reattach touches the child ZERO times → pure
|
|
446
|
+
// snapshot, no re-render (step 2 is a no-op).
|
|
447
|
+
// Wait for the FIRST child bytes after a resize (the SIGWINCH redraw wave arriving), or the
|
|
448
|
+
// timeout for a child that answers SIGWINCH with nothing (a plain pipe-like program). A bare
|
|
449
|
+
// stability check is NOT enough here: signal delivery + redraw generation take milliseconds,
|
|
450
|
+
// and a busy child's tick cadence has quiet gaps — the stability loop returns in such a gap
|
|
451
|
+
// BEFORE the redraw arrives (proven by the repro byte log: the \x1b[NA redraw came as frame #2,
|
|
452
|
+
// after the snapshot), which re-creates the dup exactly.
|
|
453
|
+
const awaitSeqBump = async (timeoutMs: number): Promise<void> => {
|
|
454
|
+
const seq = writeSeq
|
|
455
|
+
const deadline = Date.now() + timeoutMs
|
|
456
|
+
while (writeSeq === seq && Date.now() < deadline) await Bun.sleep(15)
|
|
457
|
+
}
|
|
434
458
|
const firstAttach = async (s: { write(b: Buffer): number }, c: number, r: number): Promise<void> => {
|
|
435
459
|
await awaitModelParsedStable()
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
} catch {
|
|
441
|
-
/* transient */
|
|
442
|
-
}
|
|
443
|
-
cols = c
|
|
444
|
-
rows = r
|
|
445
|
-
}
|
|
446
|
-
sendSnapshot(s) // serialize the model → send → lift gate (synchronous, no interleave)
|
|
447
|
-
if (changed) {
|
|
448
|
-
try {
|
|
449
|
-
child.terminal.resize(c, r) // honest one-SIGWINCH resize (post-snapshot, forwarded live), like native
|
|
450
|
-
} catch {
|
|
451
|
-
/* transient */
|
|
452
|
-
}
|
|
453
|
-
writeGeometry(runDir, session, c, r) // record the new current child geometry for the warm-deliver readers
|
|
460
|
+
if (c !== cols || r !== rows) {
|
|
461
|
+
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)
|
|
463
|
+
await awaitModelParsedStable() // …then drain it to stability (bounded, В25 cap)
|
|
454
464
|
}
|
|
465
|
+
sendSnapshot(s) // serialize the post-redraw model → send → lift gate (synchronous, no interleave)
|
|
455
466
|
}
|
|
456
467
|
// DETACH = NO RESIZE (emulator invariant). A detached hosted TUI keeps its LAST client geometry — it is
|
|
457
468
|
// NEVER shrunk back to serve. The old FINDING-2 revert shrank the child into a tiny viewport, making claude
|