@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 +1 -1
- package/src/cli/cli.test.ts +20 -2
- package/src/cli/index.ts +9 -0
- package/src/lifecycle/index.ts +36 -9
- package/src/lifecycle/lifecycle.test.ts +31 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -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
|
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -148,7 +148,10 @@ function writeSessionState(cfg: LifecycleConfig, state: SessionState): void {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
|
|
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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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
|
|
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
|
|
466
|
-
|
|
472
|
+
// 3a. NOT parked-clean → FRESH 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()
|