@agfpd/iapeer 0.2.43 → 0.2.45
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 +59 -1
- package/src/cli/index.ts +143 -3
- package/src/install/index.ts +1 -1
- package/src/launch/index.ts +4 -0
- 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, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
10
|
+
import { addRuntime, defaultRuntime, 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'
|
|
@@ -451,3 +451,61 @@ describe('new (unconditional fresh restart)', () => {
|
|
|
451
451
|
await expect(newPeer('ghost-nobody', undefined, { env: e })).rejects.toThrow('not registered')
|
|
452
452
|
})
|
|
453
453
|
})
|
|
454
|
+
|
|
455
|
+
// ─── add-runtime / default-runtime — the fleet-switch levers (codex-parity) ───
|
|
456
|
+
|
|
457
|
+
describe('add-runtime / default-runtime (fleet-switch levers)', () => {
|
|
458
|
+
test('add-runtime merges the runtime into profile+registry; idempotent; infra peers skipped', async () => {
|
|
459
|
+
const e = env()
|
|
460
|
+
// a claude-only peer with a REAL cwd (initPeer scaffolds it)
|
|
461
|
+
const cwd = join(root, 'peers', 'aud1')
|
|
462
|
+
mkdirSync(cwd, { recursive: true })
|
|
463
|
+
await upsertPeer({ personality: 'aud1', runtime: 'claude', cwd, intelligence: 'artificial' }, { rootDir: root })
|
|
464
|
+
const { initPeer } = await import('../init/index.ts')
|
|
465
|
+
await initPeer({ cwd, runtime: 'claude', env: e }) // give it a local profile (claude-only)
|
|
466
|
+
// an infra peer in the registry — must be skipped by --all
|
|
467
|
+
await register('tginfra', 'telegram', 'natural')
|
|
468
|
+
const outcomes = await addRuntime('codex', { all: true, env: e })
|
|
469
|
+
const aud1 = outcomes.find(o => o.personality === 'aud1')!
|
|
470
|
+
expect(aud1.action).toBe('added')
|
|
471
|
+
expect(outcomes.find(o => o.personality === 'tginfra')!.action).toBe('skipped-infra-peer')
|
|
472
|
+
// profile + registry both carry codex now; default untouched
|
|
473
|
+
const { readPeerProfile } = await import('../identity/index.ts')
|
|
474
|
+
const prof = readPeerProfile(cwd)!
|
|
475
|
+
expect(prof.runtimes).toContain('codex')
|
|
476
|
+
expect(prof.runtime).toBe('claude') // default_runtime NOT flipped
|
|
477
|
+
const rec = findPeer(readPeersIndex({ env: e }), 'aud1')!
|
|
478
|
+
expect(rec.runtimes).toContain('codex')
|
|
479
|
+
// idempotent
|
|
480
|
+
expect((await addRuntime('codex', { peer: 'aud1', env: e }))[0].action).toBe('already')
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('add-runtime refuses an infra runtime as the argument', async () => {
|
|
484
|
+
await register('x1')
|
|
485
|
+
await expect(addRuntime('telegram', { peer: 'x1', env: env() })).rejects.toThrow('infra runtime')
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('default-runtime flips the primary (profile + registry in one command); refuses undeclared; symmetric back', async () => {
|
|
489
|
+
const e = env()
|
|
490
|
+
const cwd = join(root, 'peers', 'aud2')
|
|
491
|
+
mkdirSync(cwd, { recursive: true })
|
|
492
|
+
await upsertPeer({ personality: 'aud2', runtime: 'claude', cwd, intelligence: 'artificial' }, { rootDir: root })
|
|
493
|
+
const { initPeer } = await import('../init/index.ts')
|
|
494
|
+
await initPeer({ cwd, runtime: 'claude', env: e })
|
|
495
|
+
// undeclared codex → explicit refusal with the add-runtime hint
|
|
496
|
+
const refused = await defaultRuntime('codex', { peer: 'aud2', env: e })
|
|
497
|
+
expect(refused[0].action).toBe('refused-undeclared-runtime')
|
|
498
|
+
expect(refused[0].detail).toContain('add-runtime')
|
|
499
|
+
// declare codex, then flip
|
|
500
|
+
await addRuntime('codex', { peer: 'aud2', env: e })
|
|
501
|
+
expect((await defaultRuntime('codex', { peer: 'aud2', env: e }))[0].action).toBe('flipped')
|
|
502
|
+
const { readPeerProfile } = await import('../identity/index.ts')
|
|
503
|
+
expect(readPeerProfile(cwd)!.runtime).toBe('codex')
|
|
504
|
+
expect(readPeerProfile(cwd)!.runtimes[0]).toBe('codex') // normalizeRuntimes prepends the default
|
|
505
|
+
expect(findPeer(readPeersIndex({ env: e }), 'aud2')!.runtime).toBe('codex') // registry healed same-command
|
|
506
|
+
// idempotent + symmetric revert
|
|
507
|
+
expect((await defaultRuntime('codex', { peer: 'aud2', env: e }))[0].action).toBe('already')
|
|
508
|
+
expect((await defaultRuntime('claude', { peer: 'aud2', env: e }))[0].action).toBe('flipped')
|
|
509
|
+
expect(findPeer(readPeersIndex({ env: e }), 'aud2')!.runtime).toBe('claude')
|
|
510
|
+
})
|
|
511
|
+
})
|
package/src/cli/index.ts
CHANGED
|
@@ -46,8 +46,8 @@ import {
|
|
|
46
46
|
type WakeResult,
|
|
47
47
|
} from '../lifecycle/index.ts'
|
|
48
48
|
import { getAdapter } from '../launch/index.ts'
|
|
49
|
-
import {
|
|
50
|
-
import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
|
|
49
|
+
import { cycleDaemon, isFoundationOwnedPlist, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
|
|
50
|
+
import { readPeerProfile, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
|
|
51
51
|
import { peerProfilePath } from '../storage/index.ts'
|
|
52
52
|
import {
|
|
53
53
|
isConformant,
|
|
@@ -272,6 +272,123 @@ export function startPeer(personality: string, runtime: string | undefined, opts
|
|
|
272
272
|
return out
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
export interface AddRuntimeOutcome {
|
|
276
|
+
personality: string
|
|
277
|
+
action: 'added' | 'already' | 'skipped-infra-peer' | 'failed'
|
|
278
|
+
detail?: string
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* add-runtime <runtime> (--peer <p> | --all) — give EXISTING peers an additional
|
|
283
|
+
* AGENTIC runtime in one command (codex-parity audit, директива Артура 12.06: the
|
|
284
|
+
* fleet-switch enabler — 14/23 peers were claude-only with nothing to switch to).
|
|
285
|
+
* Per target: initPeer({cwd, runtime}) — ensurePeerProfile MERGES runtimes (the
|
|
286
|
+
* default_runtime lever is deliberately untouched — capability ≠ routing flip; see
|
|
287
|
+
* `default-runtime`), scaffolds the runtime scope, and the codex side runs its
|
|
288
|
+
* whole birth chain: cwd pre-trust, native-memory lever, memory provision
|
|
289
|
+
* (occasion=birth), host-wide MCP block + update-check-off. Idempotent by
|
|
290
|
+
* construction (merge + append-if-absent everywhere). Infra PEERS (telegram/
|
|
291
|
+
* notifier defaults) are skipped — adding an agentic runtime to a router peer is
|
|
292
|
+
* an operator decision, not a sweep.
|
|
293
|
+
*/
|
|
294
|
+
export async function addRuntime(
|
|
295
|
+
runtime: string,
|
|
296
|
+
opts: CliEnvOptions & { peer?: string; all?: boolean },
|
|
297
|
+
): Promise<AddRuntimeOutcome[]> {
|
|
298
|
+
const env = opts.env ?? process.env
|
|
299
|
+
if (!isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
|
|
300
|
+
if (isInfraRuntime(runtime)) {
|
|
301
|
+
throw new Error(`"${runtime}" is an infra runtime — infra presence is operator-add via \`iapeer create\` (plist semantics), not a sweep`)
|
|
302
|
+
}
|
|
303
|
+
const index = readPeersIndex({ env })
|
|
304
|
+
const targets = opts.all === true ? index.peers : index.peers.filter(p => p.personality === opts.peer)
|
|
305
|
+
if (targets.length === 0) throw new Error(opts.peer ? `peer "${opts.peer}" is not registered` : 'no targets — pass --peer <p> or --all')
|
|
306
|
+
const { initPeer } = await import('../init/index.ts')
|
|
307
|
+
const out: AddRuntimeOutcome[] = []
|
|
308
|
+
for (const p of targets) {
|
|
309
|
+
if (isInfraRuntime(p.runtime)) {
|
|
310
|
+
out.push({ personality: p.personality, action: 'skipped-infra-peer', detail: `default runtime "${p.runtime}" is infra` })
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
if (p.runtimes.includes(runtime as Runtime)) {
|
|
314
|
+
out.push({ personality: p.personality, action: 'already' })
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const warns: string[] = []
|
|
319
|
+
await initPeer({ cwd: p.cwd, runtime: runtime as Runtime, env, warn: m => warns.push(m) })
|
|
320
|
+
out.push({ personality: p.personality, action: 'added', detail: warns.length ? warns.join(' | ') : undefined })
|
|
321
|
+
} catch (e) {
|
|
322
|
+
out.push({ personality: p.personality, action: 'failed', detail: e instanceof Error ? e.message : String(e) })
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface DefaultRuntimeOutcome {
|
|
329
|
+
personality: string
|
|
330
|
+
action: 'flipped' | 'already' | 'refused-undeclared-runtime' | 'skipped-infra-peer' | 'failed'
|
|
331
|
+
detail?: string
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* default-runtime <runtime> (--peer <p> | --all) — flip the PRIMARY lever
|
|
336
|
+
* (contract Идентичность: primary держит default_runtime, НЕ порядок runtimes[];
|
|
337
|
+
* linus precedent 11.06). This is the routing/wake/first-launch default — the
|
|
338
|
+
* actual fleet-switch moment is a mass flip of exactly this field. The local
|
|
339
|
+
* profile is rewritten through the H1 merge-writer (default_runtime + the legacy
|
|
340
|
+
* in-sync mirror; normalizeRuntimes re-prepends the new default), then the
|
|
341
|
+
* registry is self-healed from the flipped locals (the reindex REPLACE rail —
|
|
342
|
+
* same as `verify --fix`), so routing flips in the same command. REFUSES a peer
|
|
343
|
+
* that does not declare the runtime (add-runtime first) and skips infra peers.
|
|
344
|
+
* Symmetric back: `default-runtime claude --all` reverts the fleet.
|
|
345
|
+
*/
|
|
346
|
+
export async function defaultRuntime(
|
|
347
|
+
runtime: string,
|
|
348
|
+
opts: CliEnvOptions & { peer?: string; all?: boolean },
|
|
349
|
+
): Promise<DefaultRuntimeOutcome[]> {
|
|
350
|
+
const env = opts.env ?? process.env
|
|
351
|
+
if (!isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
|
|
352
|
+
if (isInfraRuntime(runtime)) throw new Error(`"${runtime}" is an infra runtime — not a warm routing default`)
|
|
353
|
+
const index = readPeersIndex({ env })
|
|
354
|
+
const targets = opts.all === true ? index.peers : index.peers.filter(p => p.personality === opts.peer)
|
|
355
|
+
if (targets.length === 0) throw new Error(opts.peer ? `peer "${opts.peer}" is not registered` : 'no targets — pass --peer <p> or --all')
|
|
356
|
+
const out: DefaultRuntimeOutcome[] = []
|
|
357
|
+
for (const p of targets) {
|
|
358
|
+
if (isInfraRuntime(p.runtime)) {
|
|
359
|
+
out.push({ personality: p.personality, action: 'skipped-infra-peer', detail: `default runtime "${p.runtime}" is infra` })
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const profile = readPeerProfile(p.cwd)
|
|
364
|
+
if (!profile) {
|
|
365
|
+
out.push({ personality: p.personality, action: 'failed', detail: `no local profile at ${p.cwd}` })
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
if (profile.runtime === runtime) {
|
|
369
|
+
out.push({ personality: p.personality, action: 'already' })
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
if (!profile.runtimes.includes(runtime as Runtime)) {
|
|
373
|
+
out.push({
|
|
374
|
+
personality: p.personality,
|
|
375
|
+
action: 'refused-undeclared-runtime',
|
|
376
|
+
detail: `declares [${profile.runtimes.join(', ')}] — run \`iapeer add-runtime ${runtime} --peer ${p.personality}\` first`,
|
|
377
|
+
})
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
writePeerProfileAtomic(p.cwd, { ...profile, runtime: runtime as Runtime })
|
|
381
|
+
out.push({ personality: p.personality, action: 'flipped' })
|
|
382
|
+
} catch (e) {
|
|
383
|
+
out.push({ personality: p.personality, action: 'failed', detail: e instanceof Error ? e.message : String(e) })
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Self-heal the registry from the flipped locals so routing flips in the same
|
|
387
|
+
// command (REPLACE projection — the verify --fix rail).
|
|
388
|
+
if (out.some(o => o.action === 'flipped')) await reindexFromLocals({ env })
|
|
389
|
+
return out
|
|
390
|
+
}
|
|
391
|
+
|
|
275
392
|
export interface NewPeerOutcome {
|
|
276
393
|
personality: string
|
|
277
394
|
runtime: string
|
|
@@ -653,6 +770,8 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
653
770
|
connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
|
|
654
771
|
enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
|
|
655
772
|
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)
|
|
773
|
+
add-runtime <runtime> (--peer <p> | --all) add an agentic runtime to existing peer(s): runtimes-merge + scaffold + codex birth chain (pre-trust, native-memory, memory provision, MCP). default_runtime untouched
|
|
774
|
+
default-runtime <runtime> (--peer <p> | --all) flip the PRIMARY (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command
|
|
656
775
|
attach <peer> [runtime] ensure-live + resume, then tmux attach
|
|
657
776
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
658
777
|
compact <peer> [runtime] compact the peer's context (/compact)
|
|
@@ -1036,6 +1155,27 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1036
1155
|
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
1037
1156
|
return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
|
|
1038
1157
|
}
|
|
1158
|
+
case 'add-runtime': {
|
|
1159
|
+
// Fleet-switch enabler (codex-parity audit): add an agentic runtime to
|
|
1160
|
+
// existing peer(s) — full codex birth chain per target, idempotent.
|
|
1161
|
+
const rt = positionals[0]
|
|
1162
|
+
if (!rt) return usage(errOut)
|
|
1163
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
1164
|
+
if (flags.all !== true && !peerName) return usage(errOut)
|
|
1165
|
+
const outcomes = await addRuntime(rt, { peer: peerName, all: flags.all === true, env })
|
|
1166
|
+
for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
1167
|
+
return outcomes.some(o => o.action === 'failed') ? 1 : 0
|
|
1168
|
+
}
|
|
1169
|
+
case 'default-runtime': {
|
|
1170
|
+
// The PRIMARY flip (routing/wake default) — the fleet-switch moment itself.
|
|
1171
|
+
const rt = positionals[0]
|
|
1172
|
+
if (!rt) return usage(errOut)
|
|
1173
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
1174
|
+
if (flags.all !== true && !peerName) return usage(errOut)
|
|
1175
|
+
const outcomes = await defaultRuntime(rt, { peer: peerName, all: flags.all === true, env })
|
|
1176
|
+
for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
1177
|
+
return outcomes.some(o => o.action === 'failed') ? 1 : 0
|
|
1178
|
+
}
|
|
1039
1179
|
case 'new': {
|
|
1040
1180
|
// UNCONDITIONAL fresh restart (control, system class — docs/Control-команды
|
|
1041
1181
|
// §new): the emergency lever for a hung/dead session, bypasses the peer.
|
|
@@ -1193,7 +1333,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1193
1333
|
errOut(`rollback failed: ${rb.reason}\n`)
|
|
1194
1334
|
return 1
|
|
1195
1335
|
}
|
|
1196
|
-
const restart =
|
|
1336
|
+
const restart = cycleDaemon(env)
|
|
1197
1337
|
if (restart.state === 'restarted') {
|
|
1198
1338
|
const h = await waitForDaemonHealthy({ env })
|
|
1199
1339
|
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)
|
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
|
|
|
@@ -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',
|