@agfpd/iapeer 0.2.42 → 0.2.44
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 +55 -1
- package/src/cli/index.ts +104 -2
- package/src/install/index.ts +1 -1
- package/src/launch/adapters/codex.ts +6 -1
- package/src/launch/index.ts +4 -0
- package/src/launch/launch.test.ts +3 -2
- package/src/launch/launchd.test.ts +67 -0
- package/src/launch/launchd.ts +68 -15
- package/src/update/index.ts +5 -5
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
|
7
7
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
8
|
import { tmpdir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
-
import { formatListTable, listPeers, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
10
|
+
import { formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
11
11
|
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
12
12
|
import { hasIdleReaped, isStopped, loadLifecycleConfig, setIdleReaped, setStopped } from '../lifecycle/index.ts'
|
|
13
13
|
import { launchdPlistPath } from '../launch/launchd.ts'
|
|
@@ -397,3 +397,57 @@ describe('parseArgs (audit #27 — value beginning with --)', () => {
|
|
|
397
397
|
expect(parseArgs(['x', '--description=']).flags.description).toBe('')
|
|
398
398
|
})
|
|
399
399
|
})
|
|
400
|
+
|
|
401
|
+
// ─── new — the UNCONDITIONAL fresh-restart control (docs/Control-команды §new) ─
|
|
402
|
+
|
|
403
|
+
describe('new (unconditional fresh restart)', () => {
|
|
404
|
+
test('happy path: fresh-slate (un-park + stale markers cleared) → fresh wake with resume:false task:"" → action fresh', async () => {
|
|
405
|
+
await register('alpha')
|
|
406
|
+
const e = env()
|
|
407
|
+
const cfg = loadLifecycleConfig(e)
|
|
408
|
+
// a parked peer with stale markers — /new must override ALL of it
|
|
409
|
+
setStopped(cfg, 'claude-alpha')
|
|
410
|
+
setIdleReaped(cfg, 'claude-alpha')
|
|
411
|
+
const calls: Array<{ personality: string; runtime: string; task: string; resume: boolean }> = []
|
|
412
|
+
const o = await newPeer('alpha', undefined, {
|
|
413
|
+
env: e,
|
|
414
|
+
wakeFn: async args => {
|
|
415
|
+
calls.push(args)
|
|
416
|
+
return { status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }
|
|
417
|
+
},
|
|
418
|
+
})
|
|
419
|
+
expect(o.action).toBe('fresh')
|
|
420
|
+
expect(o.runtime).toBe('claude')
|
|
421
|
+
expect(calls).toEqual([{ personality: 'alpha', runtime: 'claude', task: '', resume: false }])
|
|
422
|
+
expect(isStopped(cfg, 'claude-alpha')).toBe(false) // un-parked: /new outranks the C1 stop
|
|
423
|
+
expect(hasIdleReaped(cfg, 'claude-alpha')).toBe(false) // stale marker cleared
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('wake FAILED → action failed with the reason (exit-1 contract for the bot)', async () => {
|
|
427
|
+
await register('beta2')
|
|
428
|
+
const o = await newPeer('beta2', undefined, {
|
|
429
|
+
env: env(),
|
|
430
|
+
wakeFn: async () => ({ status: 'FAILED', woke: false, reason: 'boot hang' }),
|
|
431
|
+
})
|
|
432
|
+
expect(o.action).toBe('failed')
|
|
433
|
+
expect(o.reason).toBe('boot hang')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('refusals are explicit: foreign launchd / infra runtime / undeclared runtime / unknown peer', async () => {
|
|
437
|
+
const e = env()
|
|
438
|
+
// foreign PP plist → fleet guard
|
|
439
|
+
await register('boris')
|
|
440
|
+
writeFileSync(launchdPlistPath('boris', e), '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.iapeer.boris</string></dict></plist>\n')
|
|
441
|
+
expect((await newPeer('boris', undefined, { env: e })).action).toBe('refused-foreign-launchd')
|
|
442
|
+
// infra runtime → launchd-held, kickstart hint
|
|
443
|
+
await register('tg', 'telegram', 'natural')
|
|
444
|
+
const infra = await newPeer('tg', undefined, { env: e })
|
|
445
|
+
expect(infra.action).toBe('refused-infra')
|
|
446
|
+
expect(infra.reason).toContain('kickstart')
|
|
447
|
+
// undeclared runtime → explicit refusal, no silent launch
|
|
448
|
+
await register('gamma2')
|
|
449
|
+
expect((await newPeer('gamma2', 'codex', { env: e })).action).toBe('refused-undeclared-runtime')
|
|
450
|
+
// unknown peer → throws (same contract as stop/start)
|
|
451
|
+
await expect(newPeer('ghost-nobody', undefined, { env: e })).rejects.toThrow('not registered')
|
|
452
|
+
})
|
|
453
|
+
})
|
package/src/cli/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../regist
|
|
|
27
27
|
import { isPeerLive, routeControl, routeSend, type WakeFn } from '../transport/index.ts'
|
|
28
28
|
import {
|
|
29
29
|
attachPeer,
|
|
30
|
+
clearIdleReaped,
|
|
31
|
+
clearNewEager,
|
|
30
32
|
clearStopped,
|
|
31
33
|
folderLaunch,
|
|
32
34
|
isLaunchdManaged,
|
|
@@ -41,9 +43,10 @@ import {
|
|
|
41
43
|
setNewEager,
|
|
42
44
|
setStopped,
|
|
43
45
|
wakeOrSpawn,
|
|
46
|
+
type WakeResult,
|
|
44
47
|
} from '../lifecycle/index.ts'
|
|
45
48
|
import { getAdapter } from '../launch/index.ts'
|
|
46
|
-
import {
|
|
49
|
+
import { cycleDaemon, isFoundationOwnedPlist, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
|
|
47
50
|
import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
|
|
48
51
|
import { peerProfilePath } from '../storage/index.ts'
|
|
49
52
|
import {
|
|
@@ -269,6 +272,90 @@ export function startPeer(personality: string, runtime: string | undefined, opts
|
|
|
269
272
|
return out
|
|
270
273
|
}
|
|
271
274
|
|
|
275
|
+
export interface NewPeerOutcome {
|
|
276
|
+
personality: string
|
|
277
|
+
runtime: string
|
|
278
|
+
action: 'fresh' | 'refused-foreign-launchd' | 'refused-infra' | 'refused-undeclared-runtime' | 'failed'
|
|
279
|
+
reason?: string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* new <peer> [runtime] — the UNCONDITIONAL fresh-restart control command
|
|
284
|
+
* (docs/Control-команды §new; постановка boris 01:32 12.06, санкция Артура).
|
|
285
|
+
*
|
|
286
|
+
* The emergency lever for a HUNG or dead agent session: /alias-new covers only
|
|
287
|
+
* the COOPERATIVE path (a live peer reads the expanded prompt and runs
|
|
288
|
+
* `iapeer self-fresh`); a stuck/raving/dead peer never reads a prompt — this
|
|
289
|
+
* command restarts it MECHANICALLY, bypassing the peer entirely:
|
|
290
|
+
* fresh-slate markers (un-park C1 stop, clear stale .idle-reaped/.new-eager) →
|
|
291
|
+
* canary-clean teardown of any live session (killSession — a deliberate kill,
|
|
292
|
+
* never a death class; session-state dropped so supervise stays silent) →
|
|
293
|
+
* wakeOrSpawn(resume:false, task:'') — fresh BY CONSTRUCTION, the same recipe
|
|
294
|
+
* as the eager relaunch (the C2 initial_prompt seeds the first turn).
|
|
295
|
+
*
|
|
296
|
+
* exit-0 contract (сверка telegram-runtime): success ⟺ the fresh session is UP
|
|
297
|
+
* and READY (verified by the wake's ready gate, not merely scheduled) — for a
|
|
298
|
+
* sleeping, dead AND hung target alike. Duration is a real TUI boot: typically
|
|
299
|
+
* 5–30 s, bounded by cfg.bootDeadlineSecs. Idempotent in effect: each repeat
|
|
300
|
+
* leaves exactly one fresh live session (concurrent calls serialize on the
|
|
301
|
+
* wake lock). REFUSES foreign-launchd peers (fleet guard) and infra runtimes
|
|
302
|
+
* (launchd-held — their restart is `launchctl kickstart`'s domain), like
|
|
303
|
+
* stop/start; an explicitly-passed runtime the peer does not declare is an
|
|
304
|
+
* explicit refusal (never a silent launch of an undeclared runtime).
|
|
305
|
+
*/
|
|
306
|
+
export async function newPeer(
|
|
307
|
+
personality: string,
|
|
308
|
+
runtime: string | undefined,
|
|
309
|
+
opts: CliEnvOptions & { wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: false }) => Promise<WakeResult> } = {},
|
|
310
|
+
): Promise<NewPeerOutcome> {
|
|
311
|
+
const env = opts.env ?? process.env
|
|
312
|
+
const cfg = loadLifecycleConfig(env)
|
|
313
|
+
const peer = findPeer(readPeersIndex({ env }), personality)
|
|
314
|
+
if (!peer) throw new Error(`peer "${personality}" is not registered`)
|
|
315
|
+
if (isForeignLaunchd(personality, env)) {
|
|
316
|
+
return {
|
|
317
|
+
personality,
|
|
318
|
+
runtime: peer.runtime,
|
|
319
|
+
action: 'refused-foreign-launchd',
|
|
320
|
+
reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not restart it`,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (runtime && !isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
|
|
324
|
+
const rt: Runtime = (runtime as Runtime | undefined) ?? peer.runtime
|
|
325
|
+
if (!peer.runtimes.includes(rt)) {
|
|
326
|
+
return {
|
|
327
|
+
personality,
|
|
328
|
+
runtime: rt,
|
|
329
|
+
action: 'refused-undeclared-runtime',
|
|
330
|
+
reason: `peer "${personality}" does not declare runtime "${rt}" (declared: ${peer.runtimes.join(', ')})`,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (isInfraRuntime(rt)) {
|
|
334
|
+
return {
|
|
335
|
+
personality,
|
|
336
|
+
runtime: rt,
|
|
337
|
+
action: 'refused-infra',
|
|
338
|
+
reason: `infra runtime "${rt}" is launchd-held — restart it with: launchctl kickstart -k gui/$(id -u)/${launchdLabel(personality)}`,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const identity = buildProcessAddress(rt, personality)
|
|
342
|
+
const sock = buildSocketPath(rt, personality, cfg.sockDir)
|
|
343
|
+
// Fresh slate: an explicit operator /new outranks a C1 park (it demands a live
|
|
344
|
+
// fresh session NOW) and any stale death/fresh markers (the wake below is fresh
|
|
345
|
+
// by construction — markers must not leak into a LATER wake's decision).
|
|
346
|
+
clearStopped(cfg, identity)
|
|
347
|
+
clearIdleReaped(cfg, identity)
|
|
348
|
+
clearNewEager(cfg, identity)
|
|
349
|
+
killSession(sock, identity) // canary-clean deliberate teardown; no-op when dead
|
|
350
|
+
removeSessionState(cfg, identity) // never a death class — supervise stays silent
|
|
351
|
+
const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
|
|
352
|
+
const r = await wake({ personality, runtime: rt, task: '', resume: false })
|
|
353
|
+
if (r.status !== 'READY') {
|
|
354
|
+
return { personality, runtime: rt, action: 'failed', reason: r.reason ?? 'wake failed' }
|
|
355
|
+
}
|
|
356
|
+
return { personality, runtime: rt, action: 'fresh' }
|
|
357
|
+
}
|
|
358
|
+
|
|
272
359
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
273
360
|
// remove — delete a peer's record from the registry through the LOCKED writer
|
|
274
361
|
// (registry.removePeer). Direct edits of peers-profiles.json are refused at
|
|
@@ -565,6 +652,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
565
652
|
<runtime> launch the cwd's peer (ALWAYS fresh)
|
|
566
653
|
connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
|
|
567
654
|
enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
|
|
655
|
+
new <peer> [runtime] UNCONDITIONAL fresh restart: canary-clean kill + fresh wake (emergency lever for a hung/dead session — bypasses the peer; exit 0 = fresh session up+ready)
|
|
568
656
|
attach <peer> [runtime] ensure-live + resume, then tmux attach
|
|
569
657
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
570
658
|
compact <peer> [runtime] compact the peer's context (/compact)
|
|
@@ -948,6 +1036,20 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
948
1036
|
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
949
1037
|
return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
|
|
950
1038
|
}
|
|
1039
|
+
case 'new': {
|
|
1040
|
+
// UNCONDITIONAL fresh restart (control, system class — docs/Control-команды
|
|
1041
|
+
// §new): the emergency lever for a hung/dead session, bypasses the peer.
|
|
1042
|
+
// Source: operator CLI, or telegram-runtime's clean-/new detect (their bot
|
|
1043
|
+
// shells `iapeer new <peer> <runtime>` — exit 0 ⟺ fresh session up+ready).
|
|
1044
|
+
if (!positionals[0]) return usage(errOut)
|
|
1045
|
+
const o = await newPeer(positionals[0], positionals[1], { env })
|
|
1046
|
+
if (o.action === 'fresh') {
|
|
1047
|
+
out(`new: ${o.runtime}-${o.personality} fresh session up\n`)
|
|
1048
|
+
return 0
|
|
1049
|
+
}
|
|
1050
|
+
errOut(`new: ${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
1051
|
+
return 1
|
|
1052
|
+
}
|
|
951
1053
|
case 'start': {
|
|
952
1054
|
// --all re-enables every REGISTERED peer (mirror of `stop --all` — the 11.06
|
|
953
1055
|
// incident: bringing a stopped fleet back required a manual loop over ~25
|
|
@@ -1091,7 +1193,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1091
1193
|
errOut(`rollback failed: ${rb.reason}\n`)
|
|
1092
1194
|
return 1
|
|
1093
1195
|
}
|
|
1094
|
-
const restart =
|
|
1196
|
+
const restart = cycleDaemon(env)
|
|
1095
1197
|
if (restart.state === 'restarted') {
|
|
1096
1198
|
const h = await waitForDaemonHealthy({ env })
|
|
1097
1199
|
out(
|
package/src/install/index.ts
CHANGED
|
@@ -112,7 +112,7 @@ export interface RollbackResult {
|
|
|
112
112
|
* foundation keeps a single `.prev`, not a history stack): rollback restores the
|
|
113
113
|
* binary that was live BEFORE the most recent install. Atomic (copy .prev → .tmp →
|
|
114
114
|
* rename over the binary), so the binary is never absent. The CALLER restarts the
|
|
115
|
-
* daemon afterwards (
|
|
115
|
+
* daemon afterwards (cycleDaemon) — rollback only swaps the bytes. Sandbox-guarded.
|
|
116
116
|
*/
|
|
117
117
|
export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env): RollbackResult {
|
|
118
118
|
const binPath = iapeerBinPath(env)
|
|
@@ -319,11 +319,16 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
319
319
|
* interrupts the turn — NOT a double. (Codex's own footer says "esc to
|
|
320
320
|
* interrupt"; one Escape yields "■ Conversation interrupted". The contract's
|
|
321
321
|
* open "×1/×2?" is resolved to ×1, same as claude.) Session + context intact.
|
|
322
|
-
* - compact →
|
|
322
|
+
* - compact → type '/compact' then Enter. SNAPPED LIVE 12.06 (codex-cli
|
|
323
|
+
* 0.138.0): the slash EXISTS now — autocomplete documents it («summarize
|
|
324
|
+
* conversation to prevent hitting the context limit») and a live run
|
|
325
|
+
* yields «• Context compacted». The 0.136-era "codex has no /compact"
|
|
326
|
+
* finding is stale; the mapping is keyboard-identical to claude's.
|
|
323
327
|
* - anything else → null.
|
|
324
328
|
*/
|
|
325
329
|
executeControl(command: ControlCommand): ControlPlan | null {
|
|
326
330
|
if (command.name === 'interrupt') return { sequence: [['Escape']] }
|
|
331
|
+
if (command.name === 'compact') return { sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 }
|
|
327
332
|
return null
|
|
328
333
|
},
|
|
329
334
|
}
|
package/src/launch/index.ts
CHANGED
|
@@ -61,6 +61,8 @@ export {
|
|
|
61
61
|
isFoundationOwnedPlist,
|
|
62
62
|
launchctlBootstrap,
|
|
63
63
|
bootstrapJobCore,
|
|
64
|
+
cycleDaemon,
|
|
65
|
+
cycleDaemonCore,
|
|
64
66
|
resolveExecutable,
|
|
65
67
|
IAPEER_PLIST_OWNER_KEY,
|
|
66
68
|
} from './launchd.ts'
|
|
@@ -71,6 +73,8 @@ export type {
|
|
|
71
73
|
BootstrapState,
|
|
72
74
|
BootstrapCoreDeps,
|
|
73
75
|
BootstrapCoreResult,
|
|
76
|
+
DaemonRestartResult,
|
|
77
|
+
DaemonRestartState,
|
|
74
78
|
LaunchctlRunner,
|
|
75
79
|
} from './launchd.ts'
|
|
76
80
|
|
|
@@ -296,9 +296,10 @@ describe('executeControl (Ф-E control commands)', () => {
|
|
|
296
296
|
expect(claudeAdapter.executeControl({ name: 'compact' })).toEqual({ sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 })
|
|
297
297
|
expect(claudeAdapter.executeControl({ name: 'bogus' })).toBeNull()
|
|
298
298
|
})
|
|
299
|
-
test('codex: interrupt → [Escape] (×1
|
|
299
|
+
test('codex: interrupt → [Escape] (×1, snapped live); compact → /compact + Enter (snapped live 12.06, codex-cli 0.138)', () => {
|
|
300
300
|
expect(codexAdapter.executeControl({ name: 'interrupt' })).toEqual({ sequence: [['Escape']] })
|
|
301
|
-
expect(codexAdapter.executeControl({ name: 'compact' })).
|
|
301
|
+
expect(codexAdapter.executeControl({ name: 'compact' })).toEqual({ sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 })
|
|
302
|
+
expect(codexAdapter.executeControl({ name: 'bogus' })).toBeNull()
|
|
302
303
|
})
|
|
303
304
|
test('routers (telegram/notifier) refuse all control (no TUI turn)', () => {
|
|
304
305
|
expect(telegramAdapter.executeControl({ name: 'interrupt' })).toBeNull()
|
|
@@ -11,6 +11,7 @@ import { tmpdir } from 'os'
|
|
|
11
11
|
import { join } from 'path'
|
|
12
12
|
import {
|
|
13
13
|
bootstrapJobCore,
|
|
14
|
+
cycleDaemonCore,
|
|
14
15
|
getAdapter,
|
|
15
16
|
installAlwaysOnPlist,
|
|
16
17
|
isFoundationOwnedPlist,
|
|
@@ -430,3 +431,69 @@ describe('buildAlwaysOnSpec carries intelligence (telegram crash-loop guard)', (
|
|
|
430
431
|
rmSync(cwd, { recursive: true, force: true })
|
|
431
432
|
})
|
|
432
433
|
})
|
|
434
|
+
|
|
435
|
+
// ─── cycleDaemonCore — the LWCR-safe deploy restart (bootout+bootstrap) ───────
|
|
436
|
+
// Proven live 12.06 twice: a kickstart after a binary replacement respawns into
|
|
437
|
+
// an EX_CONFIG(78) crash-loop when launchd's managed Launch Constraint still
|
|
438
|
+
// pins the OLD binary's signature (com.iapeer.arthur, then com.agfpd.iapeer
|
|
439
|
+
// itself on the 0.2.43 deploy). The cure — and now the ONLY restart form the
|
|
440
|
+
// update pipeline uses — is a full re-registration.
|
|
441
|
+
|
|
442
|
+
describe('cycleDaemonCore (LWCR-safe bootout+bootstrap)', () => {
|
|
443
|
+
function harness(script: { printStatuses: number[]; bootoutStatus?: number; bootstrapStatuses?: number[] }) {
|
|
444
|
+
const calls: string[][] = []
|
|
445
|
+
let printI = 0
|
|
446
|
+
let bootI = 0
|
|
447
|
+
const run = (args: string[]) => {
|
|
448
|
+
calls.push(args)
|
|
449
|
+
if (args[0] === 'print') {
|
|
450
|
+
const status = script.printStatuses[Math.min(printI, script.printStatuses.length - 1)]!
|
|
451
|
+
printI++
|
|
452
|
+
return { status, stderr: '' }
|
|
453
|
+
}
|
|
454
|
+
if (args[0] === 'bootout') return { status: script.bootoutStatus ?? 0, stderr: script.bootoutStatus ? 'boom' : '' }
|
|
455
|
+
const status = (script.bootstrapStatuses ?? [0])[Math.min(bootI, (script.bootstrapStatuses ?? [0]).length - 1)]!
|
|
456
|
+
bootI++
|
|
457
|
+
return { status, stderr: status === 0 ? '' : 'Bootstrap failed' }
|
|
458
|
+
}
|
|
459
|
+
return { calls, deps: { run, sleepMs: () => {} } }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
test('loaded job: bootout → gone → bootstrap → restarted', () => {
|
|
463
|
+
// print: loaded (pre-check) → gone (core wait) → bootstrap ok
|
|
464
|
+
const h = harness({ printStatuses: [0, 1], bootstrapStatuses: [0] })
|
|
465
|
+
expect(cycleDaemonCore('501', '/p.plist', h.deps)).toEqual({ state: 'restarted' })
|
|
466
|
+
expect(h.calls.some(c => c[0] === 'bootout')).toBe(true)
|
|
467
|
+
expect(h.calls.some(c => c[0] === 'bootstrap')).toBe(true)
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
test('not loaded → not-loaded, NOTHING booted out', () => {
|
|
471
|
+
const h = harness({ printStatuses: [1] })
|
|
472
|
+
expect(cycleDaemonCore('501', '/p.plist', h.deps)).toEqual({ state: 'not-loaded' })
|
|
473
|
+
expect(h.calls.some(c => c[0] === 'bootout')).toBe(false)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
test('bootout fails → failed loud, no bootstrap attempt', () => {
|
|
477
|
+
const h = harness({ printStatuses: [0], bootoutStatus: 9 })
|
|
478
|
+
const r = cycleDaemonCore('501', '/p.plist', h.deps)
|
|
479
|
+
expect(r.state).toBe('failed')
|
|
480
|
+
expect(r.detail).toContain('bootout failed')
|
|
481
|
+
expect(h.calls.some(c => c[0] === 'bootstrap')).toBe(false)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
test('job never unloads after bootout (undead beyond budget) → failed with the manual recipe', () => {
|
|
485
|
+
// print: loaded (pre-check), then loaded FOREVER (the core wait + retries see it listed)
|
|
486
|
+
const h = harness({ printStatuses: [0, 0] })
|
|
487
|
+
const r = cycleDaemonCore('501', '/p.plist', h.deps)
|
|
488
|
+
expect(r.state).toBe('failed')
|
|
489
|
+
expect(r.detail).toContain('manual rescue')
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('bootstrap retries exhausted → failed with rescue hint', () => {
|
|
493
|
+
// pre-check loaded → gone for the wait → every bootstrap attempt fails, re-checks stay gone
|
|
494
|
+
const h = harness({ printStatuses: [0, 1], bootstrapStatuses: [5, 5, 5, 5] })
|
|
495
|
+
const r = cycleDaemonCore('501', '/p.plist', h.deps)
|
|
496
|
+
expect(r.state).toBe('failed')
|
|
497
|
+
expect(r.detail).toContain('manual rescue')
|
|
498
|
+
})
|
|
499
|
+
})
|
package/src/launch/launchd.ts
CHANGED
|
@@ -299,34 +299,87 @@ export function bootstrapJobCore(
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
export type DaemonRestartState =
|
|
302
|
-
| 'restarted' //
|
|
302
|
+
| 'restarted' // bootout + bootstrap succeeded → the daemon runs the freshly-installed binary
|
|
303
303
|
| 'not-loaded' // com.agfpd.iapeer is not in the gui domain → nothing to restart (new binary
|
|
304
304
|
// will be used whenever it IS next started; nothing to do here)
|
|
305
305
|
| 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch the real launchd
|
|
306
|
-
| 'failed' //
|
|
306
|
+
| 'failed' // bootout/bootstrap failed — the result carries the manual rescue recipe
|
|
307
307
|
|
|
308
308
|
export interface DaemonRestartResult {
|
|
309
309
|
state: DaemonRestartState
|
|
310
310
|
detail?: string
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* DI core of cycleDaemon (unit-testable without launchctl): bootout → wait-for-gone
|
|
315
|
+
* → bootstrap, composed on bootstrapJobCore's undead-job-safe wait/backoff.
|
|
316
|
+
* Disambiguation of bootstrapJobCore's 'already-loaded': at attempts=0 it means the
|
|
317
|
+
* job NEVER unloaded after our bootout (undead beyond budget) → FAIL loud with the
|
|
318
|
+
* manual recipe; at attempts>0 it means a retry found the job loaded (our previous
|
|
319
|
+
* attempt half-succeeded or KeepAlive raced us in) → the job IS up → success.
|
|
320
|
+
*/
|
|
321
|
+
export function cycleDaemonCore(
|
|
322
|
+
uid: string,
|
|
323
|
+
plistPath: string,
|
|
324
|
+
deps: BootstrapCoreDeps,
|
|
325
|
+
): DaemonRestartResult {
|
|
326
|
+
const listed = () => deps.run(['print', `gui/${uid}/${DAEMON_PLIST_LABEL}`]).status === 0
|
|
327
|
+
if (!listed()) return { state: 'not-loaded' }
|
|
328
|
+
const bo = deps.run(['bootout', `gui/${uid}/${DAEMON_PLIST_LABEL}`])
|
|
329
|
+
if (bo.status !== 0) {
|
|
330
|
+
return { state: 'failed', detail: `launchctl bootout failed: ${bo.stderr.trim() || `exit ${bo.status}`}` }
|
|
331
|
+
}
|
|
332
|
+
const core = bootstrapJobCore(uid, DAEMON_PLIST_LABEL, plistPath, deps)
|
|
333
|
+
if (core.state === 'loaded' || (core.state === 'already-loaded' && core.attempts > 0)) {
|
|
334
|
+
return { state: 'restarted' }
|
|
335
|
+
}
|
|
336
|
+
if (core.state === 'already-loaded') {
|
|
337
|
+
return {
|
|
338
|
+
state: 'failed',
|
|
339
|
+
detail:
|
|
340
|
+
`job never unloaded after bootout (undead beyond budget) — manual rescue: ` +
|
|
341
|
+
`launchctl bootout gui/${uid}/${DAEMON_PLIST_LABEL} && launchctl bootstrap gui/${uid} ${plistPath}`,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
state: 'failed',
|
|
346
|
+
detail: `${core.detail ?? 'bootstrap failed'} — manual rescue: launchctl bootstrap gui/${uid} ${plistPath}`,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
313
350
|
/**
|
|
314
351
|
* Restart the foundation daemon (`com.agfpd.iapeer`) onto the binary currently on
|
|
315
|
-
* disk — `launchctl
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
352
|
+
* disk — `launchctl bootout` + `bootstrap`, DELIBERATELY NOT `kickstart -k`.
|
|
353
|
+
*
|
|
354
|
+
* LWCR (launch constraints, the deploy bomb — PROVEN LIVE 12.06 twice): after a
|
|
355
|
+
* binary replacement, launchd's managed Launch Constraint for the REGISTERED job
|
|
356
|
+
* can still pin the OLD binary's signature — a kickstart then respawns into an
|
|
357
|
+
* EX_CONFIG(78) crash-loop (`state = spawn scheduled`, exit 78 from launchd
|
|
358
|
+
* itself; the binary runs fine by hand). Reproduced on com.iapeer.arthur
|
|
359
|
+
* (telegram-runtime's v0.14.0 deploy, bridge down 6 min) AND on com.agfpd.iapeer
|
|
360
|
+
* itself (the 0.2.43 deploy — daemon down until the manual cure). The ONLY cure
|
|
361
|
+
* is a full re-registration: bootout drops the job (and its constraint),
|
|
362
|
+
* bootstrap registers it against the binary now on disk. kickstart is
|
|
363
|
+
* structurally insufficient for this class.
|
|
364
|
+
*
|
|
365
|
+
* Composed on bootstrapJobCore (undead-job-safe wait-for-gone + backoff). Only
|
|
366
|
+
* cycles when the service is actually loaded — a not-loaded daemon needs no
|
|
367
|
+
* restart (the new binary is taken on its next start). The plist itself is
|
|
368
|
+
* version-stable (write-if-changed) and is NOT rewritten here. Sandbox-safe.
|
|
321
369
|
*/
|
|
322
|
-
export function
|
|
323
|
-
if (env.IAPEER_TEST_SANDBOX === '1'
|
|
370
|
+
export function cycleDaemon(env: NodeJS.ProcessEnv = process.env): DaemonRestartResult {
|
|
371
|
+
if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
|
|
372
|
+
return { state: 'skipped-sandbox' }
|
|
373
|
+
}
|
|
324
374
|
const uid = currentUid()
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
375
|
+
const plistPath = join(launchAgentsDir(env), `${DAEMON_PLIST_LABEL}.plist`)
|
|
376
|
+
return cycleDaemonCore(uid, plistPath, {
|
|
377
|
+
run: args => {
|
|
378
|
+
const r = spawnSync('launchctl', args, { encoding: 'utf8' })
|
|
379
|
+
return { status: r.status, stderr: r.stderr ?? '' }
|
|
380
|
+
},
|
|
381
|
+
sleepMs: ms => spawnSync('sleep', [String(ms / 1000)]),
|
|
382
|
+
})
|
|
330
383
|
}
|
|
331
384
|
|
|
332
385
|
/**
|
package/src/update/index.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// COMPILED binary can't rebuild itself, so we pull the freshly-published package
|
|
11
11
|
// and run ITS own source installer. DELIBERATELY NOT `npx … install` (see
|
|
12
12
|
// defaultRunInstall for why npx is unsafe here).
|
|
13
|
-
// 4.
|
|
13
|
+
// 4. cycle com.agfpd.iapeer IF loaded (bootout+bootstrap) (activate the new binary; kickstart hits the LWCR EX_CONFIG(78) bomb)
|
|
14
14
|
//
|
|
15
15
|
// Scope: the foundation ONLY (the @agfpd/iapeer binary + its daemon). It never
|
|
16
16
|
// touches the plist (version-stable launch path) and never touches other packages
|
|
@@ -27,7 +27,7 @@ import { tmpdir } from 'os'
|
|
|
27
27
|
import { join } from 'path'
|
|
28
28
|
import { IapError } from '../core/errors.ts'
|
|
29
29
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
30
|
-
import {
|
|
30
|
+
import { cycleDaemon, type DaemonRestartResult } from '../launch/launchd.ts'
|
|
31
31
|
import { defaultDaemonSocketPath } from '../daemon/index.ts'
|
|
32
32
|
|
|
33
33
|
/** The npm package the foundation publishes / updates from. */
|
|
@@ -48,7 +48,7 @@ export interface UpdateDeps {
|
|
|
48
48
|
/** Pull + rebuild the binary for `version` (default: `npx @agfpd/iapeer@<v> install`).
|
|
49
49
|
* Returns true on success. */
|
|
50
50
|
runInstall?: (version: string, env: NodeJS.ProcessEnv) => boolean
|
|
51
|
-
/** Restart the daemon onto the new binary (default:
|
|
51
|
+
/** Restart the daemon onto the new binary (default: cycleDaemon — bootout+bootstrap, the LWCR-safe cycle). */
|
|
52
52
|
restartDaemon?: (env: NodeJS.ProcessEnv) => DaemonRestartResult
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -68,7 +68,7 @@ export interface UpdateResult {
|
|
|
68
68
|
|
|
69
69
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
70
|
// Post-restart health — verify the daemon actually came up, not just that
|
|
71
|
-
//
|
|
71
|
+
// the daemon job cycle exited 0. A new binary that fails to boot would otherwise
|
|
72
72
|
// be reported as a successful "restarted". The probe connects to the daemon's unix
|
|
73
73
|
// socket (the always-present same-uid listener it binds right before printing READY);
|
|
74
74
|
// a refused connection = not serving. Requires a short STREAK of successes so a
|
|
@@ -240,7 +240,7 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
|
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
const restart = deps.restartDaemon ??
|
|
243
|
+
const restart = deps.restartDaemon ?? cycleDaemon
|
|
244
244
|
const d = restart(env)
|
|
245
245
|
return {
|
|
246
246
|
status: 'updated',
|