@agfpd/iapeer 0.4.132 → 0.4.133

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.
@@ -212,16 +212,23 @@ process was SIGKILLed before any answer, and the fresh next session never saw it
212
212
  session is gone or was replaced become orphans and are redelivered through the
213
213
  normal route as their original sender, prefixed `[redelivered by iapeer: …]`
214
214
  (wake-on-miss and the crash-loop guard apply). If redelivery fails, the sender
215
- receives an explicit notice from `iapeer`. Logged as `ev=inflight-orphaned`
215
+ receives an explicit notice from the core identity `iapeer-system`. Logged as `ev=inflight-orphaned`
216
216
  (lifecycle.log) and `ev=inflight-redelivered|inflight-undelivered`
217
217
  (delivery.log).
218
218
  - A false «not processed» costs a visible duplicate, never a silent loss.
219
219
  - Interrupted turns. «Processed» (a model response began) is not «finished»: a
220
220
  turn killed mid-tool may already have had side effects, so it is never
221
221
  redelivered. The ledger keeps an entry until its turn finishes (claude: an
222
- assistant record with a terminal `stop_reason`; codex: `task_complete`;
223
- opencode: a completed assistant message for it with a finish other than
224
- `tool-calls`); a finished later message covers earlier ones. If the session
222
+ assistant record with a terminal `stop_reason`, or claude's own interrupt
223
+ marker `[Request interrupted by user…]`; codex: `task_complete` or
224
+ `turn_aborted`; opencode: a completed assistant message for it with a finish
225
+ other than `tool-calls`); a finished later message covers earlier ones. An
226
+ operator interrupt is a finished turn, not a death (18.10: a turn the owner had
227
+ stopped was reported «INTERRUPTED» when the session later died for another
228
+ reason). A session that ends ITSELF (`self-fresh`) leaves a `<identity>.self-ended`
229
+ marker with its hosting token; the daemon's sweep settles that session's started
230
+ entries silently instead of notifying their senders (the peer declared the work
231
+ done); not-yet-started entries are redelivered to the fresh session as usual. If the session
225
232
  dies with the turn unfinished, the sender gets an explicit notice from iapeer
226
233
  («the turn was INTERRUPTED and its result is not guaranteed»), logged as
227
234
  `ev=inflight-interrupted`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.132",
3
+ "version": "0.4.133",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -3006,6 +3006,16 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
3006
3006
  // see a dead session with no .new-eager → a plain reaped-gone (lazy fresh on the
3007
3007
  // next message), not the eager relaunch — degrade gracefully, never lose the mark.
3008
3008
  setNewEager(cfg, identity)
3009
+ // The peer ends ITS OWN turn: tell the daemon's OC07 sweep this session ended itself, so the
3010
+ // entries it had started are settled — not reported to their senders as INTERRUPTED.
3011
+ try {
3012
+ const { hostSessionToken } = await import('../launch/ptyHost.ts')
3013
+ const { markSelfEnded } = await import('../lifecycle/inflight.ts')
3014
+ const token = hostSessionToken(identity, env)
3015
+ if (token) markSelfEnded(cfg.stateDir, identity, token)
3016
+ } catch {
3017
+ /* best-effort: without it the sender merely gets a false INTERRUPTED notice */
3018
+ }
3009
3019
  out(`self-fresh: marked ${identity} for eager fresh re-launch; self-killing session\n`)
3010
3020
  const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
3011
3021
  killSession(sock, identity, env)
@@ -81,7 +81,7 @@ import { startGoalWatch } from './goalwatch.ts'
81
81
  import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
82
82
  import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
83
83
  import { randomBytes } from 'crypto'
84
- import { listInflightIdentities, listInterruptedIdentities, listOrphanIdentities, recordInflight, sweepInflight, takeInterrupted, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
84
+ import { clearSelfEnded, listInflightIdentities, listInterruptedIdentities, listOrphanIdentities, readSelfEnded, recordInflight, sweepInflight, takeInterrupted, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
85
85
  import { hostSessionAlive, hostSessionToken } from '../launch/ptyHost.ts'
86
86
 
87
87
  /** Default TCP loopback port for the always-on router. Real http MCP clients
@@ -321,13 +321,17 @@ export async function processInflight(
321
321
  }
322
322
  }
323
323
  for (const identity of listInflightIdentities(cfg.stateDir)) {
324
+ const selfEnded = readSelfEnded(cfg.stateDir, identity)
325
+ const current = hostSessionAlive(identity, env) ? hostSessionToken(identity, env) : null
324
326
  const n = sweepInflight(cfg.stateDir, identity, {
325
- sessionToken: id => (hostSessionAlive(id, env) ? hostSessionToken(id, env) : null),
327
+ sessionToken: () => current,
328
+ selfEndedToken: selfEnded,
326
329
  processed,
327
330
  completed: seam.completed ?? ((e: InflightEntry) => deliveryCompleted(e, env)),
328
331
  landed: seam.landed ?? ((e: InflightEntry) => deliveryLanded(e, env)),
329
332
  })
330
333
  if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
334
+ if (selfEnded && current !== selfEnded) clearSelfEnded(cfg.stateDir, identity) // that session is gone — settled
331
335
  }
332
336
  // A turn the session STARTED on a message but did not finish before dying: never redelivered (its side
333
337
  // effects may already have happened) — the sender is told explicitly that the result is not guaranteed.
@@ -1652,17 +1652,38 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1652
1652
  export async function processEagerRelaunches(
1653
1653
  cfg: LifecycleConfig,
1654
1654
  outcomes: SuperviseOutcome[],
1655
- deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult> } = {},
1655
+ deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult>; sessionAliveFn?: (identity: string) => boolean } = {},
1656
1656
  ): Promise<WakeResult[]> {
1657
1657
  const wake = deps.wakeFn ?? wakeOrSpawn
1658
+ const env = deps.env ?? process.env
1659
+ const alive = deps.sessionAliveFn ?? ((identity: string) => sessionAlive('', identity, env))
1658
1660
  const results: WakeResult[] = []
1659
- for (const o of outcomes) {
1660
- if (o.action !== 'needs-eager-fresh' || !o.personality) continue
1661
+ const eager = outcomes.filter(o => o.action === 'needs-eager-fresh' && !!o.personality)
1662
+ for (const o of eager) {
1661
1663
  clearNewEager(cfg, o.identity) // consume the eager mark — the relaunch is fresh by construction
1664
+ // WHICH endpoint comes back. Two invariants meet here:
1665
+ // • H5 fleet-switch (12.06): a peer whose ONLY session self-freshes after `default-runtime` was
1666
+ // flipped must come back on the NEW default → re-resolve (no runtime pinned).
1667
+ // • Parallel endpoints (18.10): a peer live on several runtimes at once whose NON-default
1668
+ // endpoint self-freshes must get THAT endpoint back — re-resolving woke the default (already
1669
+ // live → no-op) and the claude endpoint of oc-test-nemo lay down.
1670
+ // So: pin the dead endpoint's runtime when it is the default, or when the peer keeps another
1671
+ // session (live now, or relaunched in this same batch); otherwise it was the peer's only
1672
+ // session → re-resolve to the registry default.
1673
+ let runtime: string | undefined
1674
+ try {
1675
+ const rec = findPeer(readPeersIndex({ env }), o.personality!)
1676
+ const others = (rec?.runtimes ?? []).filter(rt => rt !== o.runtime)
1677
+ const otherAlive = others.some(rt => alive(`${rt}-${o.personality}`))
1678
+ const otherInBatch = eager.some(x => x !== o && x.personality === o.personality)
1679
+ if (o.runtime && (o.runtime === rec?.runtime || otherAlive || otherInBatch)) runtime = o.runtime
1680
+ } catch {
1681
+ runtime = undefined // registry unreadable → the H5 default path
1682
+ }
1662
1683
  try {
1663
1684
  results.push(
1664
1685
  await wake(
1665
- { personality: o.personality, task: '', resume: false },
1686
+ { personality: o.personality!, ...(runtime ? { runtime } : {}), task: '', resume: false },
1666
1687
  { cfg, env: deps.env },
1667
1688
  ),
1668
1689
  )
@@ -87,6 +87,9 @@ export function readInflight(stateDir: string, identity: string): InflightEntry[
87
87
  }
88
88
 
89
89
  export interface InflightSweepDeps {
90
+ /** Hosting token of a session that ENDED ITSELF (self-fresh) — its started entries are settled, not
91
+ * reported interrupted. Absent → none. */
92
+ selfEndedToken?: string | null
90
93
  /** Current hosting token of the identity's session (null = no live session). */
91
94
  sessionToken: (identity: string) => string | null
92
95
  /** Did the receiving session answer this entry? */
@@ -138,6 +141,7 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
138
141
  // Answered but its turn did not finish: live → still working; gone → INTERRUPTED (side effects
139
142
  // may have happened — never redelivered, the sender is told).
140
143
  if (current !== null && current === e.sessionToken) keep.push(e)
144
+ else if (deps.selfEndedToken && e.sessionToken === deps.selfEndedToken) continue // the peer ended it itself
141
145
  else interrupted.push(e)
142
146
  continue
143
147
  }
@@ -207,3 +211,29 @@ export function purgeInflight(stateDir: string, identity: string): void {
207
211
  rmSync(orphansPath(stateDir, identity), { force: true })
208
212
  rmSync(interruptedPath(stateDir, identity), { force: true })
209
213
  }
214
+
215
+
216
+ const selfEndedPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.self-ended`)
217
+
218
+ /**
219
+ * self-fresh: the peer's OWN turn declares its work done and ends its session. The CLI only drops this
220
+ * marker (the hosting token of the session that chose to end); the DAEMON's sweep — the single writer
221
+ * of the ledger — then closes that session's started entries instead of reporting them INTERRUPTED
222
+ * (false: the peer itself ended; live parity run 18.10). Not-yet-started entries are untouched and are
223
+ * redelivered to the fresh session as usual. No read-modify-write of the ledger outside the daemon.
224
+ */
225
+ export function markSelfEnded(stateDir: string, identity: string, sessionToken: string): void {
226
+ writeFileSync(selfEndedPath(stateDir, identity), `${sessionToken}\n`, { mode: 0o600 })
227
+ }
228
+
229
+ export function readSelfEnded(stateDir: string, identity: string): string | null {
230
+ try {
231
+ return readFileSync(selfEndedPath(stateDir, identity), 'utf8').trim() || null
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
237
+ export function clearSelfEnded(stateDir: string, identity: string): void {
238
+ rmSync(selfEndedPath(stateDir, identity), { force: true })
239
+ }
@@ -913,6 +913,13 @@ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv
913
913
  * opencode — an assistant message for that user message with `time.completed` and a finish other
914
914
  * than `tool-calls`.
915
915
  */
916
+ /** claude's own record of an interrupted turn (user line whose text is the interrupt marker). */
917
+ function isInterruptMarkerLine(o: { message?: unknown }): boolean {
918
+ const c = (o.message as { content?: unknown } | undefined)?.content
919
+ const texts = typeof c === 'string' ? [c] : Array.isArray(c) ? c.filter(b => b?.type === 'text').map(b => String(b.text ?? '')) : []
920
+ return texts.some(t => /^\[Request interrupted by user/.test(t.trim()))
921
+ }
922
+
916
923
  export function deliveryCompleted(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
917
924
  const needles = confirmNeedles(probe.rendered)
918
925
  if (!needles.length || needles.every(n => !n)) return false
@@ -968,6 +975,12 @@ export function deliveryCompleted(probe: ProcessedProbe, env: NodeJS.ProcessEnv
968
975
  }
969
976
  if (o.type === 'assistant' && ['end_turn', 'stop_sequence', 'max_tokens'].includes(o.message?.stop_reason ?? '')) return true
970
977
  if (o.type === 'event_msg' && o.payload?.type === 'task_complete') return true
978
+ // An operator INTERRUPT ends the turn too (claude: a `[Request interrupted by user…]` user line,
979
+ // codex: `turn_aborted`). Not counting it, a turn the owner had stopped was later reported to its
980
+ // sender as «INTERRUPTED by the session's death» once the session died for an unrelated reason
981
+ // (live parity run 18.10). OpenCode's abort already closes the assistant message, so it was right.
982
+ if (o.type === 'event_msg' && o.payload?.type === 'turn_aborted') return true
983
+ if (o.type === 'user' && isInterruptMarkerLine(o)) return true
971
984
  }
972
985
  }
973
986
  return false