@agfpd/iapeer 0.2.25 → 0.2.26

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.25",
3
+ "version": "0.2.26",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -130,6 +130,17 @@ describe('remove (registry record via the locked writer)', () => {
130
130
  expect((await removePeerCli('twice', { env: e })).action).toBe('removed')
131
131
  expect((await removePeerCli('twice', { env: e })).action).toBe('absent')
132
132
  })
133
+ test('self-done arms the caller\'s own quiet-reap (non-waking silent finish); refuses without PEER_IDENTITY', async () => {
134
+ const e = env()
135
+ // no PEER_IDENTITY → self-call refusal
136
+ expect(await runCli(['self-done'], e)).toBe(1)
137
+ // with PEER_IDENTITY → marker set, exit 0, nobody contacted
138
+ const e2 = { ...e, PEER_IDENTITY: 'claude-silentworker' }
139
+ expect(await runCli(['self-done'], e2)).toBe(0)
140
+ const { hasEphemeralArmed } = await import('../lifecycle/index.ts')
141
+ expect(hasEphemeralArmed(loadLifecycleConfig(e2), 'claude-silentworker')).toBe(true)
142
+ })
143
+
133
144
  test('purges identity-keyed lifecycle state with the record — a namesake newborn must not inherit a dead peer\'s parking (boris 10.06)', async () => {
134
145
  await register('reborn')
135
146
  const e = env()
package/src/cli/index.ts CHANGED
@@ -30,11 +30,13 @@ import {
30
30
  clearStopped,
31
31
  folderLaunch,
32
32
  isLaunchdManaged,
33
+ isEphemeralPeer,
33
34
  isStopped,
34
35
  killSession,
35
36
  loadLifecycleConfig,
36
37
  purgeIdentityState,
37
38
  removeSessionState,
39
+ setEphemeralArmed,
38
40
  setIdleReaped,
39
41
  setNewEager,
40
42
  setStopped,
@@ -348,8 +350,9 @@ export async function sendMessage(
348
350
  // unawaited in-process wake would die with it; the daemon's supervise-tick
349
351
  // drain scan (≤60 s) picks the queue up — the EXISTING retry path for failed
350
352
  // kicks, not a new mechanism.
351
- const { makeEphemeralRouteDeps } = await import('../daemon/main.ts')
353
+ const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps } = await import('../daemon/main.ts')
352
354
  const cfg = loadLifecycleConfig(env)
355
+ const t0 = Date.now()
353
356
  const result = await routeSend(
354
357
  caller,
355
358
  {
@@ -361,7 +364,39 @@ export async function sendMessage(
361
364
  },
362
365
  { wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}) },
363
366
  )
367
+ // delivery.log sink — CLI-path parity (boris's observability gap 10.06: enqueues
368
+ // routed through the CLI left to=<peer> at ZERO for the day while real wakes
369
+ // happened; the daemon tool-path logs, this path was blind). Same fields, plus
370
+ // path=cli so the two entry points are distinguishable. Both branches logged.
371
+ const { appendDeliveryEvent } = await import('../daemon/deliverylog.ts')
372
+ appendDeliveryEvent(cfg.eventLogDir, {
373
+ ev: 'delivery',
374
+ path: 'cli',
375
+ caller: caller.address,
376
+ to: opts.target,
377
+ rt: opts.runtime,
378
+ ok: String(result.ok),
379
+ via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
380
+ woke: result.ok ? String(result.value.woke) : undefined,
381
+ queued: result.ok && result.value.queued ? 'true' : undefined,
382
+ qd: result.ok ? result.value.queueDepth : undefined,
383
+ ms: Date.now() - t0,
384
+ len: opts.message.length,
385
+ att: opts.attachments?.length || undefined,
386
+ topic: opts.topic,
387
+ err: result.ok ? undefined : result.error.message,
388
+ })
364
389
  if (!result.ok) throw new Error(result.error.message)
390
+ // M2 arm-on-outbound — CLI-path parity (live gap 10.06: an ephemeral worker's
391
+ // final reply sent through the CLI fallback — e.g. inside a daemon-restart
392
+ // window, four deploys that day — never armed, so the worker idled to the
393
+ // unarmed bound and stalled its FIFO). Same hook the daemon path uses; ONLY on
394
+ // an ok outcome, errors swallowed (arming is best-effort, never fails the send).
395
+ try {
396
+ makeArmEphemeralOnDelivered(cfg)(caller)
397
+ } catch {
398
+ /* best-effort */
399
+ }
365
400
  return {
366
401
  ok: true,
367
402
  delivered_to: result.value.delivered_to,
@@ -432,6 +467,7 @@ const USAGE = `usage: iapeer <verb> [args]
432
467
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
433
468
  compact <peer> [runtime] compact the peer's context (/compact)
434
469
  self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
470
+ self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
435
471
  native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
436
472
  memory-plugin <on|off> (--peer <p> | --all) install/remove the slot-declared provider plugin (claude per-peer, codex host-global)
437
473
  `
@@ -913,6 +949,37 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
913
949
  if (!positionals[0] || !positionals[1]) return usage(errOut)
914
950
  return await runAlwaysOn(positionals[0], positionals[1], process.cwd())
915
951
  }
952
+ case 'self-done': {
953
+ // SILENT-FINISH self-call for an ephemeral worker (контракт ЖЦ §wake_policy;
954
+ // развилка boris 10.06): a worker whose task produced NOTHING to send must
955
+ // still release its M3 FIFO — but an EMPTY report would violate Артур's
956
+ // invariant «событие-всё-отфильтровано = тишина» (no empty wakes of the
957
+ // target). This verb is the non-waking arm: it sets the worker's OWN
958
+ // .ephemeral-armed (same marker the ok-outbound hook sets), so the quiet
959
+ // window reaps it within seconds and the drain feeds the next task — nobody
960
+ // is woken. Doctrine for silent finishers: «нечего отправлять → iapeer
961
+ // self-done вместо ответа». The unarmed idle bound (ephemeralUnarmedIdleSecs)
962
+ // remains the backstop for workers that do neither. On a NON-ephemeral peer
963
+ // the marker is inert (quiet-reap keys on wake_policy) — warn, exit 0.
964
+ const identity = env.PEER_IDENTITY?.trim()
965
+ if (!identity) {
966
+ errOut('self-done: PEER_IDENTITY is not set — this verb is an agent self-call from inside a session\n')
967
+ return 1
968
+ }
969
+ if (!parseSessionName(identity)) {
970
+ errOut(`self-done: invalid PEER_IDENTITY "${identity}" — expected <runtime>-<personality>\n`)
971
+ return 1
972
+ }
973
+ const cfg = loadLifecycleConfig(env)
974
+ setEphemeralArmed(cfg, identity)
975
+ const ephemeral = isEphemeralPeer(process.cwd())
976
+ out(
977
+ `self-done: armed ${identity} for the quiet-window reap (no one woken)` +
978
+ (ephemeral ? '' : ' — NOTE: this peer is not wake_policy:ephemeral, the marker is inert') +
979
+ '\n',
980
+ )
981
+ return 0
982
+ }
916
983
  case 'self-fresh': {
917
984
  // /new AGENT-FACING TRIGGER (TARGET redesign). Run BY the agent itself as the
918
985
  // FINAL step of a /new graceful wind-down (the owner triggers it via a per-peer
@@ -79,6 +79,16 @@ export interface LifecycleConfig {
79
79
  * transcript mtime is a LIVENESS proxy — "no longer writing" — not a semantic
80
80
  * "done" signal. */
81
81
  ephemeralQuietSecs: number
82
+ /** wake_policy:ephemeral — the UNARMED idle bound (seconds): an ephemeral session
83
+ * that never armed (finished silently / lost its arm to a daemon-restart window)
84
+ * is reaped after this much activity-proxy silence. Live case (scriber 10.06):
85
+ * a worker that ended «тихо» without its final outbound stalled its M3 FIFO for
86
+ * the FULL generic idleSecs (1 h) — with serial drain that blocks the whole
87
+ * conveyor per silent worker. Bound chosen ≫ the legitimate silent-tool case
88
+ * (sleep-180) and ≪ idleSecs. DOCUMENTED RISK: an ephemeral worker whose tool
89
+ * stays silent longer than this MID-TASK is reaped and its consumed queue item
90
+ * is lost — ephemeral workers must emit activity (or their reply) within it. */
91
+ ephemeralUnarmedIdleSecs: number
82
92
  }
83
93
 
84
94
  export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): LifecycleConfig {
@@ -102,6 +112,7 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
102
112
  crashLoopMax: num(env.IAPEER_CRASHLOOP_MAX, 3),
103
113
  crashLoopWindowSecs: num(env.IAPEER_CRASHLOOP_WINDOW_SECS, 300),
104
114
  ephemeralQuietSecs: num(env.IAPEER_EPHEMERAL_QUIET_SECS, 20),
115
+ ephemeralUnarmedIdleSecs: num(env.IAPEER_EPHEMERAL_UNARMED_IDLE_SECS, 600),
105
116
  }
106
117
  }
107
118
 
@@ -1140,6 +1151,30 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1140
1151
  trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
1141
1152
  continue
1142
1153
  }
1154
+ // UNARMED ephemeral idle bound (live case scriber 10.06): a worker that ended
1155
+ // SILENTLY (no final outbound → never armed; or its arm was lost to a CLI/
1156
+ // daemon-restart window) used to wait out the FULL generic idleSecs (1 h) —
1157
+ // and the M3 serial drain waits for the session's death, so ONE silent worker
1158
+ // stalled its whole conveyor. This bound is the defense-in-depth backstop:
1159
+ // ≫ the legitimate silent-tool case (sleep-180), ≪ idleSecs. The ШТАТНЫЙ
1160
+ // silent-finish path is `iapeer self-done` (arm without waking anyone —
1161
+ // Артур's invariant «нет пустых пробуждений» stays intact); this branch only
1162
+ // bounds the damage when a worker does neither. Policy reap: NO .idle-reaped
1163
+ // (ephemeral never resumes), NO recordDeath (the ring counts faults).
1164
+ if (isEphemeralPeer(s.cwd) && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
1165
+ killSession(sock, s.identity)
1166
+ clearEphemeralArmed(cfg, s.identity)
1167
+ removeSessionState(cfg, s.identity)
1168
+ out.push({
1169
+ identity: s.identity,
1170
+ action: 'reaped-ephemeral',
1171
+ reason: `unarmed idle ${ageSecs}s (silent-finish backstop; штатный путь — iapeer self-done)`,
1172
+ personality: s.personality,
1173
+ runtime: s.runtime,
1174
+ })
1175
+ trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
1176
+ continue
1177
+ }
1143
1178
  if (ageSecs > cfg.idleSecs) {
1144
1179
  // THE ONLY place .idle-reaped is written: this is the one death the daemon
1145
1180
  // INITIATES. Its presence on the next wake = the session was parked cleanly =
@@ -802,6 +802,69 @@ describe('superviseTick quiet-reap (M2 die-after-reply, real tmux)', () => {
802
802
  },
803
803
  30000,
804
804
  )
805
+
806
+ test.if(tmuxAvailable)(
807
+ 'UNARMED ephemeral past the unarmed idle bound → reaped-ephemeral (silent-finish backstop; live case scriber 10.06)',
808
+ () => {
809
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-eu-root-'))
810
+ const laDir = mkdtempSync(join(tmpdir(), 'iapeer-eu-la-'))
811
+ const cwd = profileCwd(false, true) // ephemeral worker profile
812
+ const env = {
813
+ ...process.env,
814
+ IAPEER_ROOT: root,
815
+ IAPEER_LAUNCHAGENTS_DIR: laDir,
816
+ IAPEER_SOCK_DIR: join(root, 'socks'),
817
+ IAPEER_EPHEMERAL_UNARMED_IDLE_SECS: '30', // ≪ the 60s age below, ≫ quiet 20s
818
+ }
819
+ const cfg = loadLifecycleConfig(env)
820
+ const identity = 'claude-eu'
821
+ const sock = join(root, 'socks', 'tmux-iap-claude-eu.sock')
822
+ const alive = () => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity]).status === 0
823
+ try {
824
+ mkdirSync(join(root, 'socks'), { recursive: true })
825
+ spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '300'])
826
+ expect(alive()).toBe(true)
827
+ mkdirSync(cfg.stateDir, { recursive: true })
828
+ writeFileSync(
829
+ join(cfg.stateDir, `${identity}.session`),
830
+ JSON.stringify({ identity, runtime: 'claude', personality: 'eu', cwd, wokeAt: Date.now() - 60_000 }),
831
+ )
832
+ // NOT armed (the worker ended silently) — past the unarmed bound → policy reap
833
+ const o = superviseTick(cfg, { env }).find(x => x.identity === identity)
834
+ expect(o?.action).toBe('reaped-ephemeral')
835
+ expect(o?.reason).toContain('unarmed idle')
836
+ expect(o?.personality).toBe('eu') // M3 drain fields present → queue feeds next
837
+ expect(alive()).toBe(false)
838
+ // policy death: no resume-eligibility, no crash-loop count
839
+ expect(hasIdleReaped(cfg, identity)).toBe(false)
840
+ expect(readDeaths(cfg, identity).length).toBe(0)
841
+ const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
842
+ expect(logged).toContain('outcome=ephemeral-unarmed-bound')
843
+ // a NON-ephemeral peer with the same age is untouched by this bound
844
+ // (its session lives on ITS OWN identity-derived socket — the tick keys
845
+ // sockets on runtime-personality, not on the test's prior sock)
846
+ const plainCwd = profileCwd(false, false)
847
+ const eupSock = join(root, 'socks', 'tmux-iap-claude-eup.sock')
848
+ try {
849
+ writeFileSync(
850
+ join(cfg.stateDir, `claude-eup.session`),
851
+ JSON.stringify({ identity: 'claude-eup', runtime: 'claude', personality: 'eup', cwd: plainCwd, wokeAt: Date.now() - 60_000 }),
852
+ )
853
+ spawnSync('tmux', ['-S', eupSock, 'new-session', '-d', '-s', 'claude-eup', 'sleep', '300'])
854
+ expect(superviseTick(cfg, { env }).find(x => x.identity === 'claude-eup')?.action).toBe('alive')
855
+ } finally {
856
+ spawnSync('tmux', ['-S', eupSock, 'kill-server'], { stdio: 'ignore' })
857
+ rmSync(plainCwd, { recursive: true, force: true })
858
+ }
859
+ } finally {
860
+ spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
861
+ rmSync(root, { recursive: true, force: true })
862
+ rmSync(laDir, { recursive: true, force: true })
863
+ rmSync(cwd, { recursive: true, force: true })
864
+ }
865
+ },
866
+ 30000,
867
+ )
805
868
  })
806
869
 
807
870
  // ─────────────────────────────────────────────────────────────────────────────