@agfpd/iapeer 0.2.49 → 0.2.51
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 +54 -1
- package/src/cli/index.ts +107 -4
- package/src/launch/canary.test.ts +43 -1
- package/src/launch/canary.ts +20 -12
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -7,10 +7,11 @@ 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 { addRuntime, defaultRuntime, formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
10
|
+
import { addRuntime, compactPeer, 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'
|
|
14
|
+
import { ok } from '../core/errors.ts'
|
|
14
15
|
|
|
15
16
|
let root: string
|
|
16
17
|
let laDir: string
|
|
@@ -452,6 +453,58 @@ describe('new (unconditional fresh restart)', () => {
|
|
|
452
453
|
})
|
|
453
454
|
})
|
|
454
455
|
|
|
456
|
+
// ─── compact-resume — control addresses the dialogue, not just a live session ──
|
|
457
|
+
|
|
458
|
+
describe('compact (resume sleeping dialogue)', () => {
|
|
459
|
+
test('clean idle-reaped asleep peer → explicit resume wake, then /compact control', async () => {
|
|
460
|
+
await register('sleepy', 'codex')
|
|
461
|
+
const e = env()
|
|
462
|
+
const cfg = loadLifecycleConfig(e)
|
|
463
|
+
setIdleReaped(cfg, 'codex-sleepy')
|
|
464
|
+
const wakeCalls: unknown[] = []
|
|
465
|
+
const controlCalls: unknown[] = []
|
|
466
|
+
const o = await compactPeer('sleepy', 'codex', {
|
|
467
|
+
env: e,
|
|
468
|
+
wakeFn: async args => {
|
|
469
|
+
wakeCalls.push(args)
|
|
470
|
+
return { status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }
|
|
471
|
+
},
|
|
472
|
+
controlFn: (personality, runtime, command) => {
|
|
473
|
+
controlCalls.push({ personality, runtime, command })
|
|
474
|
+
return ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' })
|
|
475
|
+
},
|
|
476
|
+
})
|
|
477
|
+
expect(o).toMatchObject({ action: 'compacted', runtime: 'codex', woke: true })
|
|
478
|
+
expect(wakeCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', task: '', resume: true }])
|
|
479
|
+
expect(controlCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', command: { name: 'compact' } }])
|
|
480
|
+
expect(hasIdleReaped(cfg, 'codex-sleepy')).toBe(false)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('crashed / first-wake asleep peer (no clean idle marker) → nothing-to-compact, no fresh wake', async () => {
|
|
484
|
+
await register('freshy', 'codex')
|
|
485
|
+
let woke = false
|
|
486
|
+
const o = await compactPeer('freshy', 'codex', {
|
|
487
|
+
env: env(),
|
|
488
|
+
wakeFn: async () => ((woke = true), { status: 'READY', woke: true }),
|
|
489
|
+
})
|
|
490
|
+
expect(o.action).toBe('nothing-to-compact')
|
|
491
|
+
expect(o.reason).toContain('nothing to compact')
|
|
492
|
+
expect(woke).toBe(false)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
test('idle marker but no transcript/session to resume → nothing-to-compact (not fresh)', async () => {
|
|
496
|
+
await register('empty', 'codex')
|
|
497
|
+
const e = env()
|
|
498
|
+
setIdleReaped(loadLifecycleConfig(e), 'codex-empty')
|
|
499
|
+
const o = await compactPeer('empty', 'codex', {
|
|
500
|
+
env: e,
|
|
501
|
+
wakeFn: async args => ({ status: 'FAILED', woke: false, runtime: args.runtime, reason: 'no codex session to resume' }),
|
|
502
|
+
})
|
|
503
|
+
expect(o.action).toBe('nothing-to-compact')
|
|
504
|
+
expect(o.reason).toContain('nothing to compact')
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
|
|
455
508
|
// ─── add-runtime / default-runtime — the fleet-switch levers (codex-parity) ───
|
|
456
509
|
|
|
457
510
|
describe('add-runtime / default-runtime (fleet-switch levers)', () => {
|
package/src/cli/index.ts
CHANGED
|
@@ -31,10 +31,12 @@ import {
|
|
|
31
31
|
clearNewEager,
|
|
32
32
|
clearStopped,
|
|
33
33
|
folderLaunch,
|
|
34
|
+
hasIdleReaped,
|
|
34
35
|
isLaunchdManaged,
|
|
35
36
|
isEphemeralPeer,
|
|
36
37
|
isStopped,
|
|
37
38
|
killSession,
|
|
39
|
+
lastActiveRuntime,
|
|
38
40
|
loadLifecycleConfig,
|
|
39
41
|
purgeIdentityState,
|
|
40
42
|
removeSessionState,
|
|
@@ -503,6 +505,95 @@ export async function newPeer(
|
|
|
503
505
|
return { personality, runtime: rt, action: 'fresh' }
|
|
504
506
|
}
|
|
505
507
|
|
|
508
|
+
export interface CompactPeerOutcome {
|
|
509
|
+
personality: string
|
|
510
|
+
runtime: string
|
|
511
|
+
action: 'compacted' | 'nothing-to-compact' | 'refused-foreign-launchd' | 'refused-infra' | 'refused-undeclared-runtime' | 'failed'
|
|
512
|
+
woke?: boolean
|
|
513
|
+
reason?: string
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* compact <peer> [runtime] — compact the DIALOGUE, not merely the currently-live
|
|
518
|
+
* session. A live target receives the in-session `/compact` control as before. A
|
|
519
|
+
* cleanly idle-reaped target is first resumed into the same dialogue, then compacted.
|
|
520
|
+
* A crashed / never-run / non-resumable target returns an honest "nothing to compact"
|
|
521
|
+
* instead of starting a fresh empty session and compacting that.
|
|
522
|
+
*/
|
|
523
|
+
export async function compactPeer(
|
|
524
|
+
personality: string,
|
|
525
|
+
runtime: string | undefined,
|
|
526
|
+
opts: CliEnvOptions & {
|
|
527
|
+
wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: true }) => Promise<WakeResult>
|
|
528
|
+
controlFn?: typeof routeControl
|
|
529
|
+
} = {},
|
|
530
|
+
): Promise<CompactPeerOutcome> {
|
|
531
|
+
const env = opts.env ?? process.env
|
|
532
|
+
const cfg = loadLifecycleConfig(env)
|
|
533
|
+
const peer = findPeer(readPeersIndex({ env }), personality)
|
|
534
|
+
if (!peer) throw new Error(`peer "${personality}" is not registered`)
|
|
535
|
+
if (isForeignLaunchd(personality, env)) {
|
|
536
|
+
return {
|
|
537
|
+
personality,
|
|
538
|
+
runtime: peer.runtime,
|
|
539
|
+
action: 'refused-foreign-launchd',
|
|
540
|
+
reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not control it`,
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (runtime && !isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
|
|
544
|
+
if (runtime && peer.runtime !== runtime && !peer.runtimes.includes(runtime as Runtime)) {
|
|
545
|
+
return {
|
|
546
|
+
personality,
|
|
547
|
+
runtime,
|
|
548
|
+
action: 'refused-undeclared-runtime',
|
|
549
|
+
reason: `peer "${personality}" does not declare runtime "${runtime}" (declared: ${peer.runtimes.join(', ')})`,
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const control = opts.controlFn ?? routeControl
|
|
554
|
+
const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir))
|
|
555
|
+
if (runtime ? liveRuntimes.includes(runtime as Runtime) : liveRuntimes.length > 0) {
|
|
556
|
+
const r = control(personality, runtime, { name: 'compact' })
|
|
557
|
+
if (!r.ok) return { personality, runtime: runtime ?? peer.runtime, action: 'failed', reason: r.error.message }
|
|
558
|
+
return { personality, runtime: r.value.controlled.runtime, action: 'compacted', woke: false }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const rt: Runtime = (runtime as Runtime | undefined) ?? lastActiveRuntime(peer, cfg) ?? peer.runtime
|
|
562
|
+
if (!peer.runtimes.includes(rt)) {
|
|
563
|
+
return {
|
|
564
|
+
personality,
|
|
565
|
+
runtime: rt,
|
|
566
|
+
action: 'refused-undeclared-runtime',
|
|
567
|
+
reason: `peer "${personality}" does not declare runtime "${rt}" (declared: ${peer.runtimes.join(', ')})`,
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (isInfraRuntime(rt)) {
|
|
571
|
+
return {
|
|
572
|
+
personality,
|
|
573
|
+
runtime: rt,
|
|
574
|
+
action: 'refused-infra',
|
|
575
|
+
reason: `infra runtime "${rt}" is launchd-held and has no compactable TUI dialogue`,
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const identity = buildProcessAddress(rt, personality)
|
|
579
|
+
if (!hasIdleReaped(cfg, identity)) {
|
|
580
|
+
return { personality, runtime: rt, action: 'nothing-to-compact', reason: 'context is fresh; nothing to compact' }
|
|
581
|
+
}
|
|
582
|
+
const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
|
|
583
|
+
const w = await wake({ personality, runtime: rt, task: '', resume: true })
|
|
584
|
+
if (w.status !== 'READY') {
|
|
585
|
+
const reason = w.reason ?? 'wake failed'
|
|
586
|
+
if (/no .*session to resume|no transcript to resume|nothing to resume/i.test(reason)) {
|
|
587
|
+
return { personality, runtime: rt, action: 'nothing-to-compact', reason: 'context is fresh; nothing to compact' }
|
|
588
|
+
}
|
|
589
|
+
return { personality, runtime: rt, action: 'failed', reason }
|
|
590
|
+
}
|
|
591
|
+
clearIdleReaped(cfg, identity)
|
|
592
|
+
const r = control(personality, rt, { name: 'compact' })
|
|
593
|
+
if (!r.ok) return { personality, runtime: rt, action: 'failed', woke: true, reason: r.error.message }
|
|
594
|
+
return { personality, runtime: rt, action: 'compacted', woke: true }
|
|
595
|
+
}
|
|
596
|
+
|
|
506
597
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
507
598
|
// remove — delete a peer's record from the registry through the LOCKED writer
|
|
508
599
|
// (registry.removePeer). Direct edits of peers-profiles.json are refused at
|
|
@@ -804,7 +895,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
804
895
|
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
|
|
805
896
|
attach <peer> [runtime] ensure-live + resume, then tmux attach
|
|
806
897
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
807
|
-
compact <peer> [runtime] compact the peer's
|
|
898
|
+
compact <peer> [runtime] compact the peer's dialogue (/compact); resumes clean-asleep first
|
|
808
899
|
self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
|
|
809
900
|
self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
|
|
810
901
|
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
@@ -1521,10 +1612,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1521
1612
|
killSession(sock, identity)
|
|
1522
1613
|
return 0
|
|
1523
1614
|
}
|
|
1524
|
-
case 'interrupt':
|
|
1525
|
-
case 'compact': {
|
|
1615
|
+
case 'interrupt': {
|
|
1526
1616
|
// In-session control (Ф-E, clean-slash namespace): interrupt a stuck/raving
|
|
1527
|
-
// turn (Escape)
|
|
1617
|
+
// turn (Escape). UNCONDITIONAL — acts on the live session.
|
|
1528
1618
|
if (!positionals[0]) return usage(errOut)
|
|
1529
1619
|
const r = routeControl(positionals[0], positionals[1], { name: verb })
|
|
1530
1620
|
if (!r.ok) {
|
|
@@ -1534,6 +1624,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1534
1624
|
out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
|
|
1535
1625
|
return 0
|
|
1536
1626
|
}
|
|
1627
|
+
case 'compact': {
|
|
1628
|
+
// Dialogue control (UX directive 12.06): if the session is merely asleep
|
|
1629
|
+
// after a clean idle-reap, resume the same dialogue and compact it; if there
|
|
1630
|
+
// is no resumable dialogue, fail honestly instead of compacting a fresh one.
|
|
1631
|
+
if (!positionals[0]) return usage(errOut)
|
|
1632
|
+
const o = await compactPeer(positionals[0], positionals[1], { env })
|
|
1633
|
+
if (o.action === 'compacted') {
|
|
1634
|
+
out(`compact → ${o.personality} (${o.runtime})${o.woke ? ' after resume' : ''}\n`)
|
|
1635
|
+
return 0
|
|
1636
|
+
}
|
|
1637
|
+
errOut(`compact: ${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
1638
|
+
return 1
|
|
1639
|
+
}
|
|
1537
1640
|
case 'connect': {
|
|
1538
1641
|
// Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
|
|
1539
1642
|
// `connect telegram <peer> [--token <t>]`. The human owes only the token;
|
|
@@ -69,7 +69,8 @@ describe('canary script (pure)', () => {
|
|
|
69
69
|
// proven live); the SERVER's liveness decides, after a dismissal grace sleep.
|
|
70
70
|
expect(s).not.toContain(`[ "$rc" -eq 0 ] || [ "$rc" -ge 128 ]; then exit 0`) // the v1 hole
|
|
71
71
|
expect(s).toContain('sleep 2') // dismissal grace window
|
|
72
|
-
expect(s).toContain(`
|
|
72
|
+
expect(s).toContain(`kill -0 "$SPID" 2>/dev/null; then exit 0`) // original server alive → nothing to record
|
|
73
|
+
expect(s).toContain(`[ -z "$SPID" ]`) // fallback only when the original pid was unavailable
|
|
73
74
|
expect(s).toContain('cause=server-vanished') // connection drop (SIGKILL/OOM class)
|
|
74
75
|
expect(s).toContain('cause=signaled-server-gone') // rc=0: channel/client-TERM, server died
|
|
75
76
|
expect(s).toContain('cause=client-killed-server-gone') // rc≥128: client took a hard kill
|
|
@@ -119,6 +120,7 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
|
|
|
119
120
|
'notifier-canatear',
|
|
120
121
|
'claude-canasweep',
|
|
121
122
|
'claude-canaclient',
|
|
123
|
+
'claude-canareplaced',
|
|
122
124
|
]
|
|
123
125
|
|
|
124
126
|
afterAll(() => {
|
|
@@ -285,4 +287,44 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
|
|
|
285
287
|
},
|
|
286
288
|
20000,
|
|
287
289
|
)
|
|
290
|
+
|
|
291
|
+
test(
|
|
292
|
+
'dirty server death followed by immediate replacement on the same socket still records the ORIGINAL death',
|
|
293
|
+
async () => {
|
|
294
|
+
const identity = 'claude-canareplaced'
|
|
295
|
+
const { sock, logDir } = bringUp(identity)
|
|
296
|
+
const pid = serverPid(sock)
|
|
297
|
+
expect(pid).toBeGreaterThan(0)
|
|
298
|
+
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
299
|
+
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
300
|
+
await sleep(500)
|
|
301
|
+
|
|
302
|
+
process.kill(pid, 'SIGKILL')
|
|
303
|
+
// Reuse the same socket before the canary's 2s dismissal-grace window
|
|
304
|
+
// expires. The old socket-level probe treated this successor as "server
|
|
305
|
+
// alive" and lost the predecessor's death; the canary must key on SPID.
|
|
306
|
+
let started = false
|
|
307
|
+
for (let i = 0; i < 20; i++) {
|
|
308
|
+
const r = spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '120'], {
|
|
309
|
+
stdio: 'ignore',
|
|
310
|
+
})
|
|
311
|
+
if (r.status === 0) {
|
|
312
|
+
started = true
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
await sleep(50)
|
|
316
|
+
}
|
|
317
|
+
expect(started).toBe(true)
|
|
318
|
+
|
|
319
|
+
const log = exitLogPath(logDir)
|
|
320
|
+
expect(await waitFor(() => existsSync(log) && readFileSync(log, 'utf8').includes('ev=server-exit'), 8000)).toBe(
|
|
321
|
+
true,
|
|
322
|
+
)
|
|
323
|
+
const line = readFileSync(log, 'utf8')
|
|
324
|
+
expect(line).toContain(`ev=server-exit identity=${identity}`)
|
|
325
|
+
expect(line).toContain(`server_pid=${pid}`)
|
|
326
|
+
expect(readdirSync(serverDeathsDir(logDir)).length).toBe(1)
|
|
327
|
+
},
|
|
328
|
+
20000,
|
|
329
|
+
)
|
|
288
330
|
})
|
package/src/launch/canary.ts
CHANGED
|
@@ -19,14 +19,18 @@
|
|
|
19
19
|
// sh recorder (`dismissCanary` → TERM → trap → silent exit). POSIX trap
|
|
20
20
|
// semantics make this race-free: the trap runs before any recording.
|
|
21
21
|
// • When wait-for returns — ANY code — the script sleeps 2 s (a concurrent
|
|
22
|
-
// dismissal TERM wins here), then probes SERVER
|
|
23
|
-
// silently (a lost canary is re-armed and logged
|
|
24
|
-
// within a tick);
|
|
25
|
-
// and unclaimed → ONE logfmt line `ev=server-exit`
|
|
26
|
-
// per-peer death-cause home, next to pane-died's
|
|
27
|
-
// forensics snapshot (vm_stat / swap / top-RSS ps /
|
|
28
|
-
// captured within seconds — the evidence the 60 s
|
|
29
|
-
// recover ("системных следов ноль" was the recurring
|
|
22
|
+
// dismissal TERM wins here), then probes the ORIGINAL SERVER PID captured at
|
|
23
|
+
// arm time: still alive → exit silently (a lost canary is re-armed and logged
|
|
24
|
+
// by the supervise retrofit within a tick); gone with nobody having dismissed
|
|
25
|
+
// us → the death is real and unclaimed → ONE logfmt line `ev=server-exit`
|
|
26
|
+
// into exits.log (the per-peer death-cause home, next to pane-died's
|
|
27
|
+
// `ev=session-exit`) + a forensics snapshot (vm_stat / swap / top-RSS ps /
|
|
28
|
+
// fresh DiagnosticReports) captured within seconds — the evidence the 60 s
|
|
29
|
+
// supervise tick can never recover ("системных следов ноль" was the recurring
|
|
30
|
+
// investigation outcome). PID identity matters: a wake/attach can replace the
|
|
31
|
+
// tmux server on the same socket before the 2 s grace elapses; probing only
|
|
32
|
+
// the socket would mistake the successor for the dead original and lose the
|
|
33
|
+
// canary record (live canary-gap 11.06).
|
|
30
34
|
// The raw wait_rc still ATTRIBUTES the death (cause=server-vanished /
|
|
31
35
|
// signaled-server-gone / client-killed-server-gone).
|
|
32
36
|
// Residual blind spot (structural): a killer that SIGKILLs the sh recorder
|
|
@@ -98,12 +102,16 @@ export function canaryScript(o: CanaryScriptOptions): string {
|
|
|
98
102
|
// `rc=0 || rc>=128 → silent` was exactly that hole). v2 contract instead:
|
|
99
103
|
// • deliberate teardowns DISMISS this sh (TERM → trap above) — the sleep
|
|
100
104
|
// below gives a concurrently-delivered dismissal time to win;
|
|
101
|
-
// • then the SERVER's liveness, not the exit code, decides: alive →
|
|
105
|
+
// • then the ORIGINAL SERVER PID's liveness, not the exit code, decides: alive →
|
|
102
106
|
// nothing to record (a lost canary is re-armed and logged by the
|
|
103
|
-
// supervise retrofit within a tick); dead and nobody
|
|
104
|
-
// the death is real and unclaimed → record it.
|
|
107
|
+
// supervise retrofit within a tick); original server dead and nobody
|
|
108
|
+
// dismissed us → the death is real and unclaimed → record it. IMPORTANT:
|
|
109
|
+
// check the ORIGINAL SPID, not merely "some server answers on the same
|
|
110
|
+
// socket". A wake/attach can replace the server inside this 2 s window;
|
|
111
|
+
// socket-liveness would then silently swallow the predecessor's death.
|
|
105
112
|
`sleep 2`,
|
|
106
|
-
`if
|
|
113
|
+
`if [ -n "$SPID" ] && kill -0 "$SPID" 2>/dev/null; then exit 0; fi`,
|
|
114
|
+
`if [ -z "$SPID" ] && '${o.tmuxBin}' -S '${o.sock}' has-session 2>/dev/null; then exit 0; fi`,
|
|
107
115
|
// The exit code still ATTRIBUTES the recorded death (raw wait_rc is kept):
|
|
108
116
|
// rc=0 → signaled-server-gone (channel signal or client-TERM, server died)
|
|
109
117
|
// rc≥128 → client-killed-server-gone (client took a non-TERM kill)
|