@agfpd/iapeer 0.2.15 → 0.2.16

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.15",
3
+ "version": "0.2.16",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,12 +4,12 @@
4
4
  // persistent-peer launchd plist must be refused.
5
5
 
6
6
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
7
- import { mkdtempSync, rmSync, writeFileSync } from 'fs'
7
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
8
8
  import { tmpdir } from 'os'
9
9
  import { join } from 'path'
10
10
  import { formatListTable, listPeers, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
11
11
  import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
12
- import { isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
12
+ import { hasIdleReaped, isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
13
13
  import { launchdPlistPath } from '../launch/launchd.ts'
14
14
 
15
15
  let root: string
@@ -73,6 +73,24 @@ describe('stop / start (C1 durable flag, warm runtime)', () => {
73
73
  test('stop an unregistered peer → throws', () => {
74
74
  expect(() => stopPeer('nobody', undefined, { env: env() })).toThrow(/not registered/)
75
75
  })
76
+ test('stop is a CLEAN PARK: leaves the resume marker and drops the session-state (boris 10.06)', async () => {
77
+ await register('parked')
78
+ const e = env()
79
+ const cfg = loadLifecycleConfig(e)
80
+ // a live session-state the daemon would otherwise tag as a death post-kill
81
+ mkdirSync(cfg.stateDir, { recursive: true })
82
+ writeFileSync(
83
+ join(cfg.stateDir, 'claude-parked.session'),
84
+ JSON.stringify({ identity: 'claude-parked', runtime: 'claude', personality: 'parked', cwd: '/tmp/parked', wokeAt: Date.now() }),
85
+ )
86
+ stopPeer('parked', undefined, { env: e })
87
+ expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true) // park marker → post-start wake RESUMES
88
+ expect(existsSync(join(cfg.stateDir, 'claude-parked.session'))).toBe(false) // not a death for supervise
89
+ // start clears only the stop flag — the park marker survives for the wake
90
+ startPeer('parked', undefined, { env: e })
91
+ expect(isStopped(cfg, 'claude-parked')).toBe(false)
92
+ expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true)
93
+ })
76
94
  })
77
95
 
78
96
  describe('FLEET GUARD (H4) — foreign persistent-peer launchd plist is off-limits', () => {
package/src/cli/index.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  isStopped,
34
34
  killSession,
35
35
  loadLifecycleConfig,
36
+ removeSessionState,
37
+ setIdleReaped,
36
38
  setNewEager,
37
39
  setStopped,
38
40
  wakeOrSpawn,
@@ -196,8 +198,15 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
196
198
  killSession(sock, identity)
197
199
  out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
198
200
  } else {
201
+ // A deliberate stop is a CLEAN PARK, not a death (boris 10.06 — stop→start
202
+ // must survive ≥ idle-reap): park-mark BEFORE the kill so the post-`start`
203
+ // wake RESUMES, and drop the supervise session-state with the session so
204
+ // the tick never tags this kill as a death (crash-loop ring stays clean,
205
+ // no reaped-gone death class for a state the daemon knows 100%).
199
206
  setStopped(cfg, identity)
207
+ setIdleReaped(cfg, identity)
200
208
  killSession(sock, identity)
209
+ removeSessionState(cfg, identity)
201
210
  out.push({ personality, runtime: rt, action: 'stopped' })
202
211
  }
203
212
  }
@@ -148,7 +148,10 @@ function writeSessionState(cfg: LifecycleConfig, state: SessionState): void {
148
148
  }
149
149
  }
150
150
 
151
- function removeSessionState(cfg: LifecycleConfig, identity: string): void {
151
+ /** Drop the supervise session-state. Exported for the STOP verb: a deliberate stop
152
+ * removes the state with the session so superviseTick never tags the kill as a
153
+ * death (no crash-loop entry, no reaped-gone death class). */
154
+ export function removeSessionState(cfg: LifecycleConfig, identity: string): void {
152
155
  try {
153
156
  rmSync(sessionStatePath(cfg, identity), { force: true })
154
157
  } catch {
@@ -198,10 +201,12 @@ export function clearStopped(cfg: LifecycleConfig, identity: string): void {
198
201
  // Lifecycle markers — the DAEMON decides fresh-vs-resume by the DEATH CAUSE it
199
202
  // tracks itself (TARGET redesign). Plain files in state/lifecycle/<identity>.* :
200
203
  //
201
- // .idle-reaped : written ONLY when the daemon idle-reaps the session (the only
202
- // death the daemon initiates). Presence on the next wake = session was parked
203
- // cleanly = RESUME-eligible. ABSENT on a dead session = it died on its own
204
- // (crash / self-close) = FRESH. (resolver branch 3.)
204
+ // .idle-reaped : the CLEAN-PARK marker — written when the daemon idle-reaps the
205
+ // session AND by the deliberate `stop` verb (both are daemon/operator-initiated
206
+ // parks, not faults; boris 10.06: stop→start must survive idle-reap).
207
+ // Presence on the next wake = session was parked cleanly = RESUME-eligible.
208
+ // ABSENT on a dead session = it died on its own (crash / self-close) = FRESH.
209
+ // (resolver branch 3.)
205
210
  // .new-eager : set when /new is invoked (owner reset, via `iapeer self-fresh`).
206
211
  // Presence on a dead session = the daemon EAGERLY relaunches FRESH (does NOT
207
212
  // wait for a message) and injects initial_prompt. Consumed on the relaunch.
@@ -230,7 +235,9 @@ export function hasIdleReaped(cfg: LifecycleConfig, identity: string): boolean {
230
235
  return existsSync(idleReapedPath(cfg, identity))
231
236
  }
232
237
 
233
- /** Write the idle-reaped marker ONLY the idle-reap path in superviseTick does this. */
238
+ /** Write the clean-park marker. Three writers, all deliberate parks: the idle-reap
239
+ * path in superviseTick, the `stop` verb (park before kill), and superviseTick's
240
+ * stopped-catch-up branch (a stop that raced the tick). Never a crash path. */
234
241
  export function setIdleReaped(cfg: LifecycleConfig, identity: string): void {
235
242
  mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
236
243
  writeFileSync(idleReapedPath(cfg, identity), `${new Date().toISOString()}\n`, { mode: 0o600 })
@@ -462,8 +469,15 @@ export function resolveWakeMode(
462
469
  clearIdleReaped(cfg, identity)
463
470
  return { resume: false, cause: 'ephemeral-policy' }
464
471
  }
465
- // 3a. NOT idle-reapedit died on its own (crash / self-close) clean FRESH.
466
- if (!hasIdleReaped(cfg, identity)) return { resume: false, cause: 'crash-or-self-close' }
472
+ // 3a. NOT parked-cleanFRESH either way, but tell the two cases apart in the
473
+ // cause (boris finding, 10.06): a peer with NO transcript at all never ran here —
474
+ // that is its FIRST-ever wake, not a crash. (A peer whose transcripts were rotated
475
+ // away also reads first-wake — equally honest: there is nothing it could resume.)
476
+ if (!hasIdleReaped(cfg, identity)) {
477
+ return resolveResume(cwd).ok
478
+ ? { resume: false, cause: 'crash-or-self-close' }
479
+ : { resume: false, cause: 'first-wake' }
480
+ }
467
481
  // 3b. idle-reaped → resume-eligible. Consume the marker now (it has done its job).
468
482
  clearIdleReaped(cfg, identity)
469
483
  // human-conversational dialogue never auto-freshes; only an explicit /new resets it.
@@ -967,7 +981,7 @@ export function killSession(sock: string, identity: string): void {
967
981
 
968
982
  export interface SuperviseOutcome {
969
983
  identity: string
970
- action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'alive' | 'needs-eager-fresh'
984
+ action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'skipped-stopped' | 'alive' | 'needs-eager-fresh'
971
985
  reason?: string
972
986
  /** For 'needs-eager-fresh': the peer to EAGERLY re-launch fresh (its session died
973
987
  * carrying a .new-eager mark). The daemon timer drives the async relaunch.
@@ -1002,6 +1016,19 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1002
1016
  }
1003
1017
  const sock = buildSocketPath(s.runtime, s.personality, cfg.sockDir)
1004
1018
  if (!sessionAlive(sock, s.identity)) {
1019
+ // A DELIBERATE stop is not a death (boris 10.06): the stop verb parks clean
1020
+ // (.stopped + .idle-reaped) and kills the session itself. Catch-up branch for
1021
+ // a stop that raced this tick (or a pre-fix stop): drop the state quietly —
1022
+ // no crash-loop entry, no death class — and ensure the clean-park marker so
1023
+ // the post-`start` wake RESUMES (stop→start must survive ≥ idle-reap).
1024
+ if (isStopped(cfg, s.identity)) {
1025
+ removeSessionState(cfg, s.identity)
1026
+ clearEphemeralArmed(cfg, s.identity)
1027
+ setIdleReaped(cfg, s.identity) // idempotent with the stop verb's own park
1028
+ out.push({ identity: s.identity, action: 'skipped-stopped', reason: 'deliberate stop — parked clean, resumes on start' })
1029
+ trace({ identity: s.identity, action: 'skipped-stopped', outcome: 'resume-on-start' })
1030
+ continue
1031
+ }
1005
1032
  // A dead session: record a death for crash-loop accounting, then branch on the
1006
1033
  // .new-eager mark. This death was NOT daemon-initiated (the daemon only initiates
1007
1034
  // the idle-reap below) → it died on its own → do NOT write .idle-reaped here.
@@ -218,6 +218,24 @@ describe('superviseTick H4 guard', () => {
218
218
  expect(existsSync(join(stateDir, `${id}.session`))).toBe(true)
219
219
  })
220
220
 
221
+ test('a STOPPED peer with a dead session → skipped-stopped: no death record, park marker ensured', () => {
222
+ // boris repro (10.06): `iapeer stop` → next tick used to tag reaped-gone
223
+ // death=server-dead + recordDeath → post-start wake came up FRESH. A deliberate
224
+ // stop is a clean park the daemon knows 100% — never a death.
225
+ const c = cfg()
226
+ const id = writeState('iapeer-supstopped')
227
+ setStopped(c, id)
228
+ const out = superviseTick(c, { env: env(), nowMs: Date.now() })
229
+ const o = out.find(x => x.identity === id)
230
+ expect(o?.action).toBe('skipped-stopped')
231
+ expect(existsSync(join(stateDir, `${id}.session`))).toBe(false) // state dropped quietly
232
+ expect(readDeaths(c, id).length).toBe(0) // NOT a death — crash-loop ring untouched
233
+ expect(hasIdleReaped(c, id)).toBe(true) // clean park → post-start wake resumes
234
+ const logged = readFileSync(join(c.eventLogDir, 'lifecycle.log'), 'utf8')
235
+ expect(logged).toContain(`identity=${id} action=skipped-stopped`)
236
+ expect(logged).not.toContain('death=') // no death class for a deliberate stop
237
+ })
238
+
221
239
  test('a no-plist peer with a dead session → reaped-gone, state removed', () => {
222
240
  const c = cfg()
223
241
  const id = writeState('iapeer-supgone') // no plist, no live tmux session
@@ -433,6 +451,19 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
433
451
  expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, hasTranscript)).toEqual({ resume: false, cause: 'crash-or-self-close' })
434
452
  })
435
453
 
454
+ test('DEFAULT + NOT idle-reaped + NO transcript at all → FRESH with cause=first-wake (not a crash)', () => {
455
+ // boris finding (10.06): the first-ever wake of a freshly created peer used to
456
+ // read crash-or-self-close — a mis-classification. No transcript = never ran.
457
+ expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, noTranscript)).toEqual({ resume: false, cause: 'first-wake' })
458
+ })
459
+
460
+ // ── stop→start is a CLEAN PARK (boris 10.06): the park marker → RESUME ────────
461
+ test('stop→start path: clean-park marker set by stop → the next default wake RESUMES', () => {
462
+ const c = cfg()
463
+ setIdleReaped(c, 'claude-p') // what the stop verb writes before killing
464
+ expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
465
+ })
466
+
436
467
  // ── branch 3b: default + idle-reaped → resume-eligible, CONSUME the marker ───
437
468
  test('DEFAULT + idle-reaped + human-conversational (interfaces.telegram) → RESUME, marker consumed', () => {
438
469
  const c = cfg()