@agfpd/iapeer 0.2.46 → 0.2.48

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.46",
3
+ "version": "0.2.48",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -223,17 +223,28 @@ export const codexAdapter: RuntimeAdapter = {
223
223
  },
224
224
 
225
225
  /**
226
- * Ready for the first message iff the codex TUI input surface is rendered and
227
- * no startup screen is still up (watcher.codexInputReady, 263-269):
228
- * - pane includes 'OpenAI Codex' — the TUI splash/header is present.
226
+ * Ready for the first message iff the codex TUI composer is rendered and no
227
+ * startup screen is still up:
229
228
  * - pane does NOT include 'Press enter to continue' — the update screen (and
230
- * other "press enter" startup prompts) is gone.
229
+ * other "press enter" startup prompts) is gone (belt-and-suspenders: the
230
+ * boot loop answers known dialogs via bootDialogKeys BEFORE asking this).
231
231
  * - some line, trimStart, startsWith '› ' — the rendered input arrow at the
232
- * start of the input row. (Codex rotates its top-of-pane tip line, so the
233
- * input arrow is the stable ready invariant — not any single tip.)
232
+ * start of the composer row. (Codex rotates its top-of-pane tip line, so
233
+ * the input arrow is the stable ready invariant — not any single tip.)
234
+ *
235
+ * DELIBERATELY no 'OpenAI Codex' splash check (the watcher.codexInputReady
236
+ * heritage requirement, removed 12.06): `resume --last` replays the prior
237
+ * transcript ABOVE the composer, and a history taller than the pane scrolls
238
+ * the splash box off the top of capture-pane — the predicate then stays false
239
+ * for the entire boot deadline while the composer sits ready, failing the wake
240
+ * `never-became-ready` and LOSING the triggering message (live incidents
241
+ * 11.06 19:21Z + 12.06 12:07Z, codex-linus). A replayed history message also
242
+ * renders with a leading '› ', so this can fire a frame before the composer
243
+ * activates — benign: the paste/Enter queue in the pty and land in the
244
+ * composer, and the ready-gate still verifies a real model turn (activity
245
+ * mtime advance) before declaring READY.
234
246
  */
235
247
  isInputReady(pane: string): boolean {
236
- if (!pane.includes('OpenAI Codex')) return false
237
248
  if (pane.includes('Press enter to continue')) return false
238
249
  return pane.split(/\r?\n/).some(line => line.trimStart().startsWith('› '))
239
250
  },
@@ -114,6 +114,22 @@ function sleep(ms: number): Promise<void> {
114
114
  function fail(identity: string, reason: string): LaunchResult {
115
115
  return { status: 'FAILED', identity, process_address: identity, reason }
116
116
  }
117
+
118
+ /**
119
+ * The last two non-empty lines of a pane capture, flattened for embedding in a
120
+ * fail reason: quotes → ', whitespace runs collapsed, joined with ' ⏎ ', clipped
121
+ * to 160 chars. '' for an empty/blank capture (caller omits the clause). Exported
122
+ * for unit tests — the boot loop embeds this in `never-became-ready` so the
123
+ * delivery.log err field shows what the TUI was actually displaying.
124
+ */
125
+ export function paneTail(pane: string): string {
126
+ const lines = pane
127
+ .split(/\r?\n/)
128
+ .map(l => l.replace(/\s+/g, ' ').trim())
129
+ .filter(Boolean)
130
+ if (lines.length === 0) return ''
131
+ return lines.slice(-2).join(' ⏎ ').replace(/"/g, "'").slice(0, 160)
132
+ }
117
133
  function ready(identity: string): LaunchResult {
118
134
  return { status: 'READY', identity, process_address: identity }
119
135
  }
@@ -347,6 +363,7 @@ export const launch: LaunchFn = async (
347
363
  const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
348
364
  const bootIters = Math.max(1, Math.ceil(cfg.bootDeadlineSecs / 2))
349
365
  let delivered = false
366
+ let lastPane = '' // the final capture — surfaced in the never-became-ready reason
350
367
  for (let i = 0; i < bootIters && !delivered; i++) {
351
368
  await sleep(2000)
352
369
  if (!sessionAlive(sock, identity)) {
@@ -354,6 +371,7 @@ export const launch: LaunchFn = async (
354
371
  }
355
372
  const pane = capturePane(sock, identity)
356
373
  if (!pane) continue
374
+ lastPane = pane
357
375
  const dialogKeys = adapter.bootDialogKeys(pane)
358
376
  if (dialogKeys) {
359
377
  tmux(sock, 'send-keys', '-t', identity, ...dialogKeys)
@@ -390,7 +408,15 @@ export const launch: LaunchFn = async (
390
408
  }
391
409
  }
392
410
  if (!delivered) {
393
- return fail(identity, 'never-became-ready (stuck at a startup prompt or boot hang)')
411
+ // Surface WHAT the pane showed when the gate gave up — the 12.06 codex-resume
412
+ // incident (splash scrolled off, composer ready, predicate false for 240 s) was
413
+ // undiagnosable from the bare reason alone. Last two non-empty lines, quotes
414
+ // flattened (the reason lands inside a logfmt-quoted err field), clipped.
415
+ const tail = paneTail(lastPane)
416
+ return fail(
417
+ identity,
418
+ `never-became-ready (stuck at a startup prompt or boot hang)${tail ? `; pane tail: ${tail}` : ''}`,
419
+ )
394
420
  }
395
421
 
396
422
  // (7) READY-GATE — the activity proxy must strictly advance past baseline
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { exitCauseHook, exitLogPath, getAdapter, launch } from './index.ts'
2
+ import { exitCauseHook, exitLogPath, getAdapter, launch, paneTail } from './index.ts'
3
3
  import { claudeAdapter } from './adapters/claude.ts'
4
4
  import { codexAdapter } from './adapters/codex.ts'
5
5
  import { telegramAdapter } from './adapters/telegram.ts'
@@ -74,6 +74,64 @@ describe('codexAdapter.buildArgv', () => {
74
74
  })
75
75
  })
76
76
 
77
+ describe('codexAdapter.isInputReady', () => {
78
+ test('fresh boot: splash + composer → true', () => {
79
+ const pane = [
80
+ '╭───────────────────────────────╮',
81
+ '│ >_ OpenAI Codex (v0.138.0) │',
82
+ '╰───────────────────────────────╯',
83
+ '› Explain this codebase',
84
+ 'gpt-5.5 xhigh · ~/Peers/p',
85
+ ].join('\n')
86
+ expect(codexAdapter.isInputReady(pane)).toBe(true)
87
+ })
88
+
89
+ test('resume replay scrolled the splash off — composer alone is READY (12.06 incident)', () => {
90
+ // The live zombie pane shape: history tail + MCP noise + composer, NO
91
+ // 'OpenAI Codex' anywhere in the visible capture. The old predicate required
92
+ // the splash and spun false for the whole 240 s boot deadline.
93
+ const pane = [
94
+ ' <message><![CDATA[Компакт прошел?]]></message>',
95
+ ' </iap>',
96
+ '• Принял. Ответ пиру не слал: он явно написал, что больше ничего не нужно.',
97
+ '⚠ MCP startup incomplete (failed: iap)',
98
+ '› Explain this codebase',
99
+ 'gpt-5.5 xhigh · ~/Peers/p',
100
+ ].join('\n')
101
+ expect(pane.includes('OpenAI Codex')).toBe(false) // fixture honesty
102
+ expect(codexAdapter.isInputReady(pane)).toBe(true)
103
+ })
104
+
105
+ test('update screen up → false even with a composer-looking line', () => {
106
+ const pane = ['Update available!', 'Press enter to continue', '› something'].join('\n')
107
+ expect(codexAdapter.isInputReady(pane)).toBe(false)
108
+ })
109
+
110
+ test('no composer row yet (box only, model loading) → false', () => {
111
+ const pane = ['│ >_ OpenAI Codex (v0.138.0) │', '│ model: loading │'].join('\n')
112
+ expect(codexAdapter.isInputReady(pane)).toBe(false)
113
+ })
114
+
115
+ test('bare › without trailing space (a stray glyph) → false', () => {
116
+ expect(codexAdapter.isInputReady('›\nnothing else')).toBe(false)
117
+ })
118
+ })
119
+
120
+ describe('paneTail (never-became-ready observability)', () => {
121
+ test('last two non-empty lines, joined, quotes flattened', () => {
122
+ const pane = 'first\n\n middle line \n⚠ MCP "failed"\n› composer\n\n'
123
+ expect(paneTail(pane)).toBe("⚠ MCP 'failed' ⏎ › composer")
124
+ })
125
+ test('single line and empty captures', () => {
126
+ expect(paneTail('only')).toBe('only')
127
+ expect(paneTail('')).toBe('')
128
+ expect(paneTail('\n \n')).toBe('')
129
+ })
130
+ test('clips to 160 chars', () => {
131
+ expect(paneTail('x'.repeat(300)).length).toBe(160)
132
+ })
133
+ })
134
+
77
135
  describe('telegramAdapter (router — no TUI surface)', () => {
78
136
  test('router predicates are trivial', () => {
79
137
  expect(telegramAdapter.bootDialogKeys('anything')).toBeNull()
@@ -1251,20 +1251,29 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1251
1251
  * consumed BEFORE the relaunch so a relaunch failure does not loop on the same eager mark
1252
1252
  * (it then fresh-wakes lazily on its next message — branch 3a — never lost). NB: a /new'd
1253
1253
  * peer is expected to carry an initial_prompt; without one the first turn delivers nothing.
1254
+ *
1255
+ * Runtime is DELIBERATELY NOT pinned to the dead session's runtime: the relaunch passes
1256
+ * no runtime so resolveWakeRuntime falls through to the registry default (H5 — registry
1257
+ * is the only source of wake-runtime truth). Pinning broke the fleet-switch procedure
1258
+ * (`default-runtime <rt>` + `self-fresh`): the peer silently resurrected on the OLD
1259
+ * runtime, ignoring the just-flipped default (live incident 12.06, claude-iapeer). A
1260
+ * session deliberately living on a non-default runtime has explicit paths back (wake
1261
+ * with an explicit runtime); an owner /new returns the peer to its declared default.
1254
1262
  */
1255
1263
  export async function processEagerRelaunches(
1256
1264
  cfg: LifecycleConfig,
1257
1265
  outcomes: SuperviseOutcome[],
1258
- deps: WakeDeps = {},
1266
+ deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult> } = {},
1259
1267
  ): Promise<WakeResult[]> {
1268
+ const wake = deps.wakeFn ?? wakeOrSpawn
1260
1269
  const results: WakeResult[] = []
1261
1270
  for (const o of outcomes) {
1262
- if (o.action !== 'needs-eager-fresh' || !o.personality || !o.runtime) continue
1271
+ if (o.action !== 'needs-eager-fresh' || !o.personality) continue
1263
1272
  clearNewEager(cfg, o.identity) // consume the eager mark — the relaunch is fresh by construction
1264
1273
  try {
1265
1274
  results.push(
1266
- await wakeOrSpawn(
1267
- { personality: o.personality, runtime: o.runtime, task: '', resume: false },
1275
+ await wake(
1276
+ { personality: o.personality, task: '', resume: false },
1268
1277
  { cfg, env: deps.env },
1269
1278
  ),
1270
1279
  )
@@ -621,6 +621,38 @@ describe('superviseTick death-cause accounting (TARGET)', () => {
621
621
  }
622
622
  })
623
623
 
624
+ test('eager relaunch does NOT pin the dead session runtime — re-resolves from the registry (H5 fleet-switch)', async () => {
625
+ // Live incident 12.06: `default-runtime codex` + `self-fresh` resurrected the peer
626
+ // on the OLD runtime — the relaunch passed the dead session's runtime EXPLICITLY,
627
+ // so resolveWakeRuntime never consulted the just-flipped registry default.
628
+ const { env, cfg, root, laDir } = deadSessionEnv('rt')
629
+ try {
630
+ setNewEager(cfg, 'claude-rt')
631
+ const outcomes = superviseTick(cfg, { env })
632
+ const o = outcomes.find(x => x.identity === 'claude-rt')
633
+ expect(o?.action).toBe('needs-eager-fresh')
634
+ const seen: Array<{ personality: string; runtime?: string; resume?: boolean; task: string }> = []
635
+ const { processEagerRelaunches } = await import('./index.ts')
636
+ await processEagerRelaunches(cfg, outcomes, {
637
+ env,
638
+ wakeFn: async args => {
639
+ seen.push({ personality: args.personality, runtime: args.runtime, resume: args.resume, task: args.task })
640
+ return { status: 'READY', woke: true }
641
+ },
642
+ })
643
+ expect(seen.length).toBe(1)
644
+ expect(seen[0].personality).toBe('rt')
645
+ expect(seen[0].runtime).toBeUndefined() // ← the fix: wakeOrSpawn re-resolves from the registry
646
+ expect(seen[0].resume).toBe(false)
647
+ expect(seen[0].task).toBe('')
648
+ // the mark was consumed by the relaunch
649
+ expect(hasNewEager(cfg, 'claude-rt')).toBe(false)
650
+ } finally {
651
+ rmSync(root, { recursive: true, force: true })
652
+ rmSync(laDir, { recursive: true, force: true })
653
+ }
654
+ })
655
+
624
656
  test('a DEAD session with NO .new-eager → reaped-gone, NO .idle-reaped written (crash leaves no marker)', () => {
625
657
  const { env, cfg, root, laDir } = deadSessionEnv('w')
626
658
  try {