@agfpd/iapeer 0.2.9 → 0.2.11
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/daemon/daemon.test.ts +151 -1
- package/src/daemon/deliverylog.ts +64 -0
- package/src/daemon/index.ts +86 -6
- package/src/daemon/main.test.ts +112 -0
- package/src/daemon/main.ts +91 -1
- package/src/identity/identity.test.ts +17 -0
- package/src/identity/index.ts +13 -0
- package/src/launch/bootdeliver.test.ts +106 -0
- package/src/launch/index.ts +25 -5
- package/src/launch/types.ts +2 -1
- package/src/lifecycle/eventlog.ts +18 -69
- package/src/lifecycle/index.ts +197 -3
- package/src/lifecycle/lifecycle.test.ts +160 -4
- package/src/lifecycle/queue.test.ts +185 -0
- package/src/lifecycle/queue.ts +159 -0
- package/src/provision/index.ts +21 -0
- package/src/provision/provision.test.ts +57 -1
- package/src/storage/rotatelog.test.ts +44 -0
- package/src/storage/rotatelog.ts +109 -0
- package/src/transport/index.ts +39 -0
|
@@ -4,13 +4,16 @@ import { tmpdir } from 'os'
|
|
|
4
4
|
import { join } from 'path'
|
|
5
5
|
import {
|
|
6
6
|
attachPeer,
|
|
7
|
+
clearEphemeralArmed,
|
|
7
8
|
clearNewEager,
|
|
8
9
|
clearStopped,
|
|
9
10
|
composeFirstMessage,
|
|
10
11
|
countRecentDeaths,
|
|
11
12
|
folderLaunch,
|
|
13
|
+
hasEphemeralArmed,
|
|
12
14
|
hasIdleReaped,
|
|
13
15
|
hasNewEager,
|
|
16
|
+
isEphemeralPeer,
|
|
14
17
|
isLaunchdManaged,
|
|
15
18
|
isStopped,
|
|
16
19
|
lastActiveRuntime,
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
recordDeath,
|
|
21
24
|
resolveWakeMode,
|
|
22
25
|
resolveWakeRuntime,
|
|
26
|
+
setEphemeralArmed,
|
|
23
27
|
setIdleReaped,
|
|
24
28
|
setNewEager,
|
|
25
29
|
setStopped,
|
|
@@ -29,6 +33,7 @@ import {
|
|
|
29
33
|
writeTopic,
|
|
30
34
|
type LifecycleConfig,
|
|
31
35
|
} from './index.ts'
|
|
36
|
+
import { spawnSync } from 'child_process'
|
|
32
37
|
import { upsertPeer, type PeerRecord } from '../registry/index.ts'
|
|
33
38
|
|
|
34
39
|
function peer(over: Partial<PeerRecord>): PeerRecord {
|
|
@@ -366,8 +371,9 @@ describe('C2 initial_prompt (composeFirstMessage)', () => {
|
|
|
366
371
|
// = .idle-reaped marker, plus peer-type/topic; NO agent-dropped fresh mark).
|
|
367
372
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
368
373
|
|
|
369
|
-
/** A temp cwd with a peer-profile; interfaces.telegram present → human-conversational
|
|
370
|
-
|
|
374
|
+
/** A temp cwd with a peer-profile; interfaces.telegram present → human-conversational;
|
|
375
|
+
* ephemeral → wake_policy "ephemeral". */
|
|
376
|
+
function profileCwd(human: boolean, ephemeral = false): string {
|
|
371
377
|
const cwd = mkdtempSync(join(tmpdir(), 'iapeer-wm-cwd-'))
|
|
372
378
|
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
373
379
|
writeFileSync(
|
|
@@ -378,6 +384,7 @@ function profileCwd(human: boolean): string {
|
|
|
378
384
|
runtimes: ['claude'],
|
|
379
385
|
intelligence: human ? 'natural' : 'artificial',
|
|
380
386
|
...(human ? { interfaces: { telegram: { user_id: 1 } } } : {}),
|
|
387
|
+
...(ephemeral ? { wake_policy: 'ephemeral' } : {}),
|
|
381
388
|
}),
|
|
382
389
|
)
|
|
383
390
|
return cwd
|
|
@@ -395,8 +402,8 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
|
|
|
395
402
|
for (const c of cwds) rmSync(c, { recursive: true, force: true })
|
|
396
403
|
})
|
|
397
404
|
const cfg = () => ({ stateDir } as LifecycleConfig)
|
|
398
|
-
const cwd = (human = false) => {
|
|
399
|
-
const c = profileCwd(human)
|
|
405
|
+
const cwd = (human = false, ephemeral = false) => {
|
|
406
|
+
const c = profileCwd(human, ephemeral)
|
|
400
407
|
cwds.push(c)
|
|
401
408
|
return c
|
|
402
409
|
}
|
|
@@ -449,6 +456,26 @@ describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
|
|
|
449
456
|
expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript, 'unrelated-bug')).toEqual({ resume: false, cause: 'idle-reaped-new-topic' })
|
|
450
457
|
expect(hasIdleReaped(c, 'claude-p')).toBe(false) // consumed even on the fresh executor branch
|
|
451
458
|
})
|
|
459
|
+
|
|
460
|
+
// ── M1: wake_policy "ephemeral" → ALWAYS fresh on delivery, overrides resume ──
|
|
461
|
+
test('DEFAULT + ephemeral → FRESH (ephemeral-policy), even with a resumable transcript', () => {
|
|
462
|
+
expect(resolveWakeMode(cfg(), 'claude-p', cwd(false, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
|
|
463
|
+
})
|
|
464
|
+
test('DEFAULT + ephemeral + idle-reaped → FRESH (overrides idle-reaped-resume), marker consumed', () => {
|
|
465
|
+
const c = cfg()
|
|
466
|
+
setIdleReaped(c, 'claude-p')
|
|
467
|
+
expect(resolveWakeMode(c, 'claude-p', cwd(false, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
|
|
468
|
+
expect(hasIdleReaped(c, 'claude-p')).toBe(false) // stray marker consumed
|
|
469
|
+
})
|
|
470
|
+
test('DEFAULT + ephemeral + telegram (human) → FRESH (ephemeral WINS over human type)', () => {
|
|
471
|
+
const c = cfg()
|
|
472
|
+
setIdleReaped(c, 'claude-p')
|
|
473
|
+
expect(resolveWakeMode(c, 'claude-p', cwd(true, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
|
|
474
|
+
})
|
|
475
|
+
test('ephemeral does NOT hijack explicit attach (argsResume=true still resumes)', () => {
|
|
476
|
+
// attach is an operator action; ephemeral only governs the delivery path.
|
|
477
|
+
expect(resolveWakeMode(cfg(), 'claude-p', cwd(false, true), true, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'attach' })
|
|
478
|
+
})
|
|
452
479
|
})
|
|
453
480
|
|
|
454
481
|
describe('idle-reaped marker round-trip', () => {
|
|
@@ -535,6 +562,135 @@ describe('superviseTick death-cause accounting (TARGET)', () => {
|
|
|
535
562
|
rmSync(laDir, { recursive: true, force: true })
|
|
536
563
|
}
|
|
537
564
|
})
|
|
565
|
+
|
|
566
|
+
test('a DEAD session clears a stale .ephemeral-armed (the mark dies with its session)', () => {
|
|
567
|
+
// The mark armed on the dead session's outbound; were it to survive, the NEXT
|
|
568
|
+
// session would be quiet-reap eligible BEFORE answering its own task.
|
|
569
|
+
const { env, cfg, root, laDir } = deadSessionEnv('q')
|
|
570
|
+
try {
|
|
571
|
+
setEphemeralArmed(cfg, 'claude-q')
|
|
572
|
+
const out = superviseTick(cfg, { env })
|
|
573
|
+
expect(out.find(x => x.identity === 'claude-q')?.action).toBe('reaped-gone')
|
|
574
|
+
expect(hasEphemeralArmed(cfg, 'claude-q')).toBe(false)
|
|
575
|
+
} finally {
|
|
576
|
+
rmSync(root, { recursive: true, force: true })
|
|
577
|
+
rmSync(laDir, { recursive: true, force: true })
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
583
|
+
// wake_policy:ephemeral M2 — armed marker + quiet-reap (die-after-reply)
|
|
584
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
585
|
+
|
|
586
|
+
describe('ephemeral-armed marker + config', () => {
|
|
587
|
+
test('set/has/clear round-trip; clear is idempotent', () => {
|
|
588
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-eph-mark-'))
|
|
589
|
+
const cfg = { stateDir } as LifecycleConfig
|
|
590
|
+
try {
|
|
591
|
+
expect(hasEphemeralArmed(cfg, 'claude-e')).toBe(false)
|
|
592
|
+
setEphemeralArmed(cfg, 'claude-e')
|
|
593
|
+
expect(hasEphemeralArmed(cfg, 'claude-e')).toBe(true)
|
|
594
|
+
clearEphemeralArmed(cfg, 'claude-e')
|
|
595
|
+
clearEphemeralArmed(cfg, 'claude-e') // idempotent
|
|
596
|
+
expect(hasEphemeralArmed(cfg, 'claude-e')).toBe(false)
|
|
597
|
+
} finally {
|
|
598
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
599
|
+
}
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test('ephemeralQuietSecs: default 20, env-tunable', () => {
|
|
603
|
+
expect(loadLifecycleConfig({ HOME: '/tmp' } as NodeJS.ProcessEnv).ephemeralQuietSecs).toBe(20)
|
|
604
|
+
expect(
|
|
605
|
+
loadLifecycleConfig({ HOME: '/tmp', IAPEER_EPHEMERAL_QUIET_SECS: '45' } as NodeJS.ProcessEnv)
|
|
606
|
+
.ephemeralQuietSecs,
|
|
607
|
+
).toBe(45)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
test('isEphemeralPeer keys on the cwd profile; read hiccup → false (safe default)', () => {
|
|
611
|
+
const eph = profileCwd(false, true)
|
|
612
|
+
const plain = profileCwd(false, false)
|
|
613
|
+
try {
|
|
614
|
+
expect(isEphemeralPeer(eph)).toBe(true)
|
|
615
|
+
expect(isEphemeralPeer(plain)).toBe(false)
|
|
616
|
+
expect(isEphemeralPeer('/tmp/definitely-no-such-peer-cwd')).toBe(false)
|
|
617
|
+
} finally {
|
|
618
|
+
rmSync(eph, { recursive: true, force: true })
|
|
619
|
+
rmSync(plain, { recursive: true, force: true })
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
describe('superviseTick quiet-reap (M2 die-after-reply, real tmux)', () => {
|
|
625
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
626
|
+
|
|
627
|
+
test.if(tmuxAvailable)(
|
|
628
|
+
'ARMED + quiet → reaped-ephemeral (killed, marks cleared, NO death/idle-reaped); unarmed/not-quiet → alive',
|
|
629
|
+
() => {
|
|
630
|
+
const root = mkdtempSync(join(tmpdir(), 'iapeer-eq-root-'))
|
|
631
|
+
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-eq-la-')) // empty → not launchd-managed
|
|
632
|
+
const cwd = profileCwd(false, true) // ephemeral worker profile
|
|
633
|
+
const env = {
|
|
634
|
+
...process.env,
|
|
635
|
+
IAPEER_ROOT: root,
|
|
636
|
+
IAPEER_LAUNCHAGENTS_DIR: laDir,
|
|
637
|
+
IAPEER_SOCK_DIR: join(root, 'socks'),
|
|
638
|
+
}
|
|
639
|
+
const cfg = loadLifecycleConfig(env) // ephemeralQuietSecs 20 ≪ idleSecs 3600
|
|
640
|
+
const identity = 'claude-eq'
|
|
641
|
+
const sock = join(root, 'socks', 'tmux-iap-claude-eq.sock')
|
|
642
|
+
const writeState = (wokeAt: number) => {
|
|
643
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
644
|
+
writeFileSync(
|
|
645
|
+
join(cfg.stateDir, `${identity}.session`),
|
|
646
|
+
JSON.stringify({ identity, runtime: 'claude', personality: 'eq', cwd, wokeAt }),
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
const alive = () => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity]).status === 0
|
|
650
|
+
try {
|
|
651
|
+
mkdirSync(join(root, 'socks'), { recursive: true })
|
|
652
|
+
spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '300'])
|
|
653
|
+
expect(alive()).toBe(true)
|
|
654
|
+
// no transcript in the temp cwd → activity proxy = wokeAt fallback:
|
|
655
|
+
// quiet age is fully controlled by the .session wokeAt below.
|
|
656
|
+
|
|
657
|
+
// 1) NOT armed + quiet-aged → alive (a silent long tool-run is NOT reaped:
|
|
658
|
+
// sleep-180 protection; only the ordinary idle bound applies).
|
|
659
|
+
writeState(Date.now() - 60_000) // age ~60s > quiet 20s, ≪ idle 3600s
|
|
660
|
+
expect(superviseTick(cfg, { env }).find(x => x.identity === identity)?.action).toBe('alive')
|
|
661
|
+
expect(alive()).toBe(true)
|
|
662
|
+
|
|
663
|
+
// 2) ARMED but NOT quiet → alive (post-reply housekeeping keeps it alive).
|
|
664
|
+
setEphemeralArmed(cfg, identity)
|
|
665
|
+
writeState(Date.now()) // age ~0 < quiet
|
|
666
|
+
expect(superviseTick(cfg, { env }).find(x => x.identity === identity)?.action).toBe('alive')
|
|
667
|
+
expect(alive()).toBe(true)
|
|
668
|
+
|
|
669
|
+
// 3) ARMED + quiet → reaped-ephemeral, with the M3 drain fields.
|
|
670
|
+
writeState(Date.now() - 60_000)
|
|
671
|
+
const o = superviseTick(cfg, { env }).find(x => x.identity === identity)
|
|
672
|
+
expect(o?.action).toBe('reaped-ephemeral')
|
|
673
|
+
expect(o?.personality).toBe('eq')
|
|
674
|
+
expect(o?.runtime).toBe('claude')
|
|
675
|
+
expect(alive()).toBe(false) // session killed
|
|
676
|
+
expect(hasEphemeralArmed(cfg, identity)).toBe(false) // mark consumed
|
|
677
|
+
expect(existsSync(join(cfg.stateDir, `${identity}.session`))).toBe(false)
|
|
678
|
+
// deliberate policy death: never resume-eligible, never a crash-loop count
|
|
679
|
+
expect(hasIdleReaped(cfg, identity)).toBe(false)
|
|
680
|
+
expect(readDeaths(cfg, identity).length).toBe(0)
|
|
681
|
+
// durable decision trace
|
|
682
|
+
const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
|
|
683
|
+
expect(logged).toContain(`action=reaped-ephemeral`)
|
|
684
|
+
expect(logged).toContain('outcome=ephemeral-done')
|
|
685
|
+
} finally {
|
|
686
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
687
|
+
rmSync(root, { recursive: true, force: true })
|
|
688
|
+
rmSync(laDir, { recursive: true, force: true })
|
|
689
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
30000,
|
|
693
|
+
)
|
|
538
694
|
})
|
|
539
695
|
|
|
540
696
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Ephemeral serial queue (M3) — FIFO primitives + the drain consumer.
|
|
2
|
+
// Retry semantics (boris acceptance (b)) are pinned here: a FAILED wake leaves
|
|
3
|
+
// the item at the head and the NEXT drain call retries the SAME task; only a
|
|
4
|
+
// READY wake consumes it. Strict FIFO order is asserted across drains.
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { spawnSync } from 'child_process'
|
|
11
|
+
import {
|
|
12
|
+
drainAllEphemeralQueues,
|
|
13
|
+
drainEphemeralQueue,
|
|
14
|
+
enqueueEphemeralTask,
|
|
15
|
+
ephemeralQueueDepth,
|
|
16
|
+
ephemeralQueueDir,
|
|
17
|
+
listQueuedIdentities,
|
|
18
|
+
peekEphemeralTask,
|
|
19
|
+
removeEphemeralTask,
|
|
20
|
+
type LifecycleConfig,
|
|
21
|
+
type WakeArgs,
|
|
22
|
+
type WakeResult,
|
|
23
|
+
} from './index.ts'
|
|
24
|
+
|
|
25
|
+
const dirs: string[] = []
|
|
26
|
+
function mkTmp(): string {
|
|
27
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-equeue-'))
|
|
28
|
+
dirs.push(d)
|
|
29
|
+
return d
|
|
30
|
+
}
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function mkCfg(): LifecycleConfig {
|
|
36
|
+
const root = mkTmp()
|
|
37
|
+
return {
|
|
38
|
+
stateDir: join(root, 'state'),
|
|
39
|
+
sockDir: join(root, 'socks'),
|
|
40
|
+
eventLogDir: join(root, 'logs'),
|
|
41
|
+
} as LifecycleConfig
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('ephemeral queue primitives (FIFO)', () => {
|
|
45
|
+
test('enqueue returns depth; peek is non-destructive; remove consumes; strict FIFO', () => {
|
|
46
|
+
const cfg = mkCfg()
|
|
47
|
+
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(0)
|
|
48
|
+
expect(peekEphemeralTask(cfg, 'claude-w')).toBeNull()
|
|
49
|
+
|
|
50
|
+
expect(enqueueEphemeralTask(cfg, 'claude-w', { task: 'first', topic: 't1' })).toBe(1)
|
|
51
|
+
expect(enqueueEphemeralTask(cfg, 'claude-w', { task: 'second' })).toBe(2)
|
|
52
|
+
expect(enqueueEphemeralTask(cfg, 'claude-w', { task: 'third', topic: 't3' })).toBe(3)
|
|
53
|
+
|
|
54
|
+
const head = peekEphemeralTask(cfg, 'claude-w')
|
|
55
|
+
expect(head?.task).toBe('first')
|
|
56
|
+
expect(head?.topic).toBe('t1')
|
|
57
|
+
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(3) // peek did not consume
|
|
58
|
+
|
|
59
|
+
removeEphemeralTask(cfg, 'claude-w', head!.seq)
|
|
60
|
+
removeEphemeralTask(cfg, 'claude-w', head!.seq) // idempotent
|
|
61
|
+
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(2)
|
|
62
|
+
expect(peekEphemeralTask(cfg, 'claude-w')?.task).toBe('second')
|
|
63
|
+
expect(peekEphemeralTask(cfg, 'claude-w')?.topic).toBeUndefined()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('enqueue skips taken seq names (exclusive-create) — no overwrite of a pending task', () => {
|
|
67
|
+
const cfg = mkCfg()
|
|
68
|
+
enqueueEphemeralTask(cfg, 'claude-w', { task: 'one' })
|
|
69
|
+
// simulate a competitor that already claimed the next seq
|
|
70
|
+
writeFileSync(join(ephemeralQueueDir(cfg, 'claude-w'), '000002'), JSON.stringify({ task: 'competitor' }))
|
|
71
|
+
expect(enqueueEphemeralTask(cfg, 'claude-w', { task: 'three' })).toBe(3)
|
|
72
|
+
// all three distinct tasks live side-by-side
|
|
73
|
+
const dir = ephemeralQueueDir(cfg, 'claude-w')
|
|
74
|
+
expect(JSON.parse(readFileSync(join(dir, '000002'), 'utf8')).task).toBe('competitor')
|
|
75
|
+
expect(JSON.parse(readFileSync(join(dir, '000003'), 'utf8')).task).toBe('three')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a poison head (corrupt JSON) is dropped, not wedging the queue', () => {
|
|
79
|
+
const cfg = mkCfg()
|
|
80
|
+
mkdirSync(ephemeralQueueDir(cfg, 'claude-w'), { recursive: true })
|
|
81
|
+
writeFileSync(join(ephemeralQueueDir(cfg, 'claude-w'), '000001'), 'NOT JSON {{{')
|
|
82
|
+
enqueueEphemeralTask(cfg, 'claude-w', { task: 'good' })
|
|
83
|
+
const head = peekEphemeralTask(cfg, 'claude-w')
|
|
84
|
+
expect(head?.task).toBe('good')
|
|
85
|
+
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(1) // poison slot dropped
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('listQueuedIdentities: only non-empty queues, sorted', () => {
|
|
89
|
+
const cfg = mkCfg()
|
|
90
|
+
enqueueEphemeralTask(cfg, 'claude-b', { task: 'x' })
|
|
91
|
+
enqueueEphemeralTask(cfg, 'claude-a', { task: 'y' })
|
|
92
|
+
mkdirSync(ephemeralQueueDir(cfg, 'claude-empty'), { recursive: true }) // empty dir → excluded
|
|
93
|
+
expect(listQueuedIdentities(cfg)).toEqual(['claude-a', 'claude-b'])
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('drainEphemeralQueue (peek → wake → rm-on-READY)', () => {
|
|
98
|
+
function fakeWake(
|
|
99
|
+
script: Array<'READY' | 'FAILED'>,
|
|
100
|
+
calls: WakeArgs[],
|
|
101
|
+
): (args: WakeArgs) => Promise<WakeResult> {
|
|
102
|
+
return async args => {
|
|
103
|
+
calls.push(args)
|
|
104
|
+
const status = script[Math.min(calls.length - 1, script.length - 1)]!
|
|
105
|
+
return { status, woke: status === 'READY', runtime: 'claude' }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
test('empty queue → null, wake NOT called', async () => {
|
|
110
|
+
const cfg = mkCfg()
|
|
111
|
+
const calls: WakeArgs[] = []
|
|
112
|
+
expect(await drainEphemeralQueue(cfg, 'w', 'claude', { wakeFn: fakeWake(['READY'], calls) })).toBeNull()
|
|
113
|
+
expect(calls).toEqual([])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('READY consumes the head; successive drains feed tasks in STRICT FIFO order', async () => {
|
|
117
|
+
const cfg = mkCfg()
|
|
118
|
+
enqueueEphemeralTask(cfg, 'claude-w', { task: 'task-A', topic: 'ta' })
|
|
119
|
+
enqueueEphemeralTask(cfg, 'claude-w', { task: 'task-B' })
|
|
120
|
+
const calls: WakeArgs[] = []
|
|
121
|
+
const deps = { wakeFn: fakeWake(['READY'], calls) }
|
|
122
|
+
|
|
123
|
+
expect((await drainEphemeralQueue(cfg, 'w', 'claude', deps))?.status).toBe('READY')
|
|
124
|
+
expect(calls[0]).toMatchObject({ personality: 'w', runtime: 'claude', task: 'task-A', topic: 'ta' })
|
|
125
|
+
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(1) // A consumed
|
|
126
|
+
|
|
127
|
+
expect((await drainEphemeralQueue(cfg, 'w', 'claude', deps))?.status).toBe('READY')
|
|
128
|
+
expect(calls[1]).toMatchObject({ task: 'task-B' }) // FIFO: B strictly after A
|
|
129
|
+
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(0)
|
|
130
|
+
expect(await drainEphemeralQueue(cfg, 'w', 'claude', deps)).toBeNull() // drained dry
|
|
131
|
+
|
|
132
|
+
// durable drain trace (acceptance (a)): ev=ephemeral-drain with depth
|
|
133
|
+
const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
|
|
134
|
+
expect(logged).toContain('ev=ephemeral-drain')
|
|
135
|
+
expect(logged).toContain('identity=claude-w')
|
|
136
|
+
expect(logged).toContain('depth=2')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('FAILED wake LEAVES the item at the head — the next drain RETRIES the same task (acceptance (b))', async () => {
|
|
140
|
+
const cfg = mkCfg()
|
|
141
|
+
enqueueEphemeralTask(cfg, 'claude-w', { task: 'flaky-task' })
|
|
142
|
+
const calls: WakeArgs[] = []
|
|
143
|
+
const deps = { wakeFn: fakeWake(['FAILED', 'READY'], calls) }
|
|
144
|
+
|
|
145
|
+
expect((await drainEphemeralQueue(cfg, 'w', 'claude', deps))?.status).toBe('FAILED')
|
|
146
|
+
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(1) // NOT consumed on failure
|
|
147
|
+
|
|
148
|
+
expect((await drainEphemeralQueue(cfg, 'w', 'claude', deps))?.status).toBe('READY')
|
|
149
|
+
expect(calls.length).toBe(2)
|
|
150
|
+
expect(calls[1]?.task).toBe('flaky-task') // the SAME task, retried
|
|
151
|
+
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(0)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
155
|
+
test.if(tmuxAvailable)('a LIVE session blocks the drain (one task per session invariant)', async () => {
|
|
156
|
+
const cfg = mkCfg()
|
|
157
|
+
mkdirSync(cfg.sockDir, { recursive: true })
|
|
158
|
+
const sock = join(cfg.sockDir, 'tmux-iap-claude-w.sock')
|
|
159
|
+
enqueueEphemeralTask(cfg, 'claude-w', { task: 'queued-while-busy' })
|
|
160
|
+
const calls: WakeArgs[] = []
|
|
161
|
+
try {
|
|
162
|
+
spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', 'claude-w', 'sleep', '60'])
|
|
163
|
+
expect(await drainEphemeralQueue(cfg, 'w', 'claude', { wakeFn: fakeWake(['READY'], calls) })).toBeNull()
|
|
164
|
+
expect(calls).toEqual([]) // no wake while the session lives
|
|
165
|
+
expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(1) // task waits for the reap
|
|
166
|
+
} finally {
|
|
167
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('drainAllEphemeralQueues: scans every queued identity, H4-skips launchd-managed', async () => {
|
|
172
|
+
const cfg = mkCfg()
|
|
173
|
+
const laDir = mkTmp()
|
|
174
|
+
const env = { ...process.env, IAPEER_LAUNCHAGENTS_DIR: laDir } as NodeJS.ProcessEnv
|
|
175
|
+
enqueueEphemeralTask(cfg, 'claude-free', { task: 'x' })
|
|
176
|
+
enqueueEphemeralTask(cfg, 'claude-held', { task: 'y' })
|
|
177
|
+
writeFileSync(join(laDir, 'com.iapeer.held.plist'), '') // 'held' is launchd-managed
|
|
178
|
+
const calls: WakeArgs[] = []
|
|
179
|
+
const results = await drainAllEphemeralQueues(cfg, { env, wakeFn: fakeWake(['READY'], calls) })
|
|
180
|
+
expect(results.length).toBe(1)
|
|
181
|
+
expect(calls.map(c => c.personality)).toEqual(['free']) // held NEVER woken (H4)
|
|
182
|
+
expect(ephemeralQueueDepth(cfg, 'claude-free')).toBe(0)
|
|
183
|
+
expect(ephemeralQueueDepth(cfg, 'claude-held')).toBe(1) // untouched
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Ephemeral serial queue (wake_policy:"ephemeral" M3) — per-peer disk FIFO of
|
|
2
|
+
// pending tasks for a stateless worker. Deliveries to an ephemeral target are
|
|
3
|
+
// NEVER injected into a live session (one task = one clean context window =
|
|
4
|
+
// the whole point of the policy); they are ALWAYS enqueued here, and the drain
|
|
5
|
+
// (drainEphemeralQueue in index.ts) feeds the worker one task per fresh session.
|
|
6
|
+
//
|
|
7
|
+
// Layout: `<stateDir>/<identity>.queue/<seq>` — one JSON file per task
|
|
8
|
+
// ({ task, topic? }), zero-padded numeric names so lexicographic order IS the
|
|
9
|
+
// FIFO order. Durable by construction: the queue survives a daemon restart and
|
|
10
|
+
// is drained by the supervise tick (the same scan that retries a failed wake).
|
|
11
|
+
//
|
|
12
|
+
// Concurrency: the daemon is the main writer, but a direct CLI `iap send`
|
|
13
|
+
// (daemon down) can race it from another process. Enqueue is therefore
|
|
14
|
+
// EXCLUSIVE-CREATE: write a temp file, then linkSync it to the next seq —
|
|
15
|
+
// linkSync fails with EEXIST on a taken name (atomic on POSIX), so two
|
|
16
|
+
// concurrent enqueues can never share a seq; the loser just advances. Content
|
|
17
|
+
// is complete before the link lands (no partial reads).
|
|
18
|
+
//
|
|
19
|
+
// Retry semantics (boris acceptance (b)): the consumer PEEKS (reads without
|
|
20
|
+
// removing), wakes the worker, and removes the item ONLY on READY — a failed
|
|
21
|
+
// wake leaves the task at the head for the next supervise-tick drain. See
|
|
22
|
+
// drainEphemeralQueue.
|
|
23
|
+
|
|
24
|
+
import { linkSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'fs'
|
|
25
|
+
import { join } from 'path'
|
|
26
|
+
import type { LifecycleConfig } from './index.ts'
|
|
27
|
+
|
|
28
|
+
/** One queued task for an ephemeral worker. */
|
|
29
|
+
export interface EphemeralQueueItem {
|
|
30
|
+
/** The routed envelope — becomes the boot first-message of the fresh session. */
|
|
31
|
+
task: string
|
|
32
|
+
/** Optional topic (threading; recorded by the wake as the session topic). */
|
|
33
|
+
topic?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A peeked item: the queue position (for the later remove) plus the payload. */
|
|
37
|
+
export interface PeekedQueueItem extends EphemeralQueueItem {
|
|
38
|
+
seq: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ephemeralQueueDir(cfg: LifecycleConfig, identity: string): string {
|
|
42
|
+
return join(cfg.stateDir, `${identity}.queue`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SEQ_PAD = 6
|
|
46
|
+
|
|
47
|
+
function listSeqs(dir: string): string[] {
|
|
48
|
+
let entries: string[]
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(dir)
|
|
51
|
+
} catch {
|
|
52
|
+
return [] // no dir yet → empty queue
|
|
53
|
+
}
|
|
54
|
+
// Only the numbered items — temp files (.tmp-*) and strays are not queue entries.
|
|
55
|
+
return entries.filter(name => /^\d+$/.test(name)).sort()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Queue depth (pending tasks). 0 for a missing dir. */
|
|
59
|
+
export function ephemeralQueueDepth(cfg: LifecycleConfig, identity: string): number {
|
|
60
|
+
return listSeqs(ephemeralQueueDir(cfg, identity)).length
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Append a task to the identity's FIFO. Returns the depth AFTER the append
|
|
65
|
+
* (≥1 — usable as the "qd" observability field). Exclusive-create: safe against
|
|
66
|
+
* a concurrent enqueue from another process. Throws only on a real FS failure
|
|
67
|
+
* (the caller surfaces it as a delivery error — an un-enqueued task must NOT be
|
|
68
|
+
* reported queued).
|
|
69
|
+
*/
|
|
70
|
+
export function enqueueEphemeralTask(
|
|
71
|
+
cfg: LifecycleConfig,
|
|
72
|
+
identity: string,
|
|
73
|
+
item: EphemeralQueueItem,
|
|
74
|
+
): number {
|
|
75
|
+
const dir = ephemeralQueueDir(cfg, identity)
|
|
76
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
77
|
+
const tmp = join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
78
|
+
writeFileSync(tmp, JSON.stringify({ task: item.task, ...(item.topic ? { topic: item.topic } : {}) }), {
|
|
79
|
+
mode: 0o600,
|
|
80
|
+
})
|
|
81
|
+
try {
|
|
82
|
+
// Next seq = max existing + 1; on an EEXIST race, advance and retry.
|
|
83
|
+
let seq = (() => {
|
|
84
|
+
const seqs = listSeqs(dir)
|
|
85
|
+
return seqs.length ? parseInt(seqs[seqs.length - 1]!, 10) + 1 : 1
|
|
86
|
+
})()
|
|
87
|
+
// Bounded retry: a competitor can win a name at most once per its own enqueue.
|
|
88
|
+
for (let attempt = 0; attempt < 1000; attempt++, seq++) {
|
|
89
|
+
const target = join(dir, String(seq).padStart(SEQ_PAD, '0'))
|
|
90
|
+
try {
|
|
91
|
+
linkSync(tmp, target) // atomic exclusive-create (EEXIST when taken)
|
|
92
|
+
return listSeqs(dir).length
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`ephemeral queue enqueue: could not claim a seq for ${identity} after 1000 attempts`)
|
|
98
|
+
} finally {
|
|
99
|
+
try {
|
|
100
|
+
unlinkSync(tmp)
|
|
101
|
+
} catch {
|
|
102
|
+
/* already gone */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read the HEAD of the FIFO without removing it (retry semantics: the item is
|
|
109
|
+
* removed only after the wake went READY — removeEphemeralTask). null on empty.
|
|
110
|
+
* A corrupt head (unparseable JSON) is dropped with its slot — a poison task
|
|
111
|
+
* must not wedge the whole queue — and the next item (if any) is returned.
|
|
112
|
+
*/
|
|
113
|
+
export function peekEphemeralTask(cfg: LifecycleConfig, identity: string): PeekedQueueItem | null {
|
|
114
|
+
const dir = ephemeralQueueDir(cfg, identity)
|
|
115
|
+
for (const seq of listSeqs(dir)) {
|
|
116
|
+
const path = join(dir, seq)
|
|
117
|
+
try {
|
|
118
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
|
|
119
|
+
if (typeof raw.task === 'string' && raw.task.length > 0) {
|
|
120
|
+
return { seq, task: raw.task, topic: typeof raw.topic === 'string' ? raw.topic : undefined }
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
/* unreadable/corrupt → drop the slot below */
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
rmSync(path, { force: true }) // poison/corrupt item: drop, do not wedge the queue
|
|
127
|
+
} catch {
|
|
128
|
+
return null // cannot even drop it — give up this round, retry next tick
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Remove a consumed item (after its wake went READY). Idempotent. */
|
|
135
|
+
export function removeEphemeralTask(cfg: LifecycleConfig, identity: string, seq: string): void {
|
|
136
|
+
try {
|
|
137
|
+
rmSync(join(ephemeralQueueDir(cfg, identity), seq), { force: true })
|
|
138
|
+
} catch {
|
|
139
|
+
/* already gone */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Identities with a non-empty queue (the supervise-tick drain scan; also the
|
|
144
|
+
* drain-on-start surface — the queue is durable across daemon restarts). */
|
|
145
|
+
export function listQueuedIdentities(cfg: LifecycleConfig): string[] {
|
|
146
|
+
let entries: string[]
|
|
147
|
+
try {
|
|
148
|
+
entries = readdirSync(cfg.stateDir)
|
|
149
|
+
} catch {
|
|
150
|
+
return []
|
|
151
|
+
}
|
|
152
|
+
const out: string[] = []
|
|
153
|
+
for (const name of entries) {
|
|
154
|
+
if (!name.endsWith('.queue')) continue
|
|
155
|
+
const identity = name.slice(0, -'.queue'.length)
|
|
156
|
+
if (listSeqs(join(cfg.stateDir, name)).length > 0) out.push(identity)
|
|
157
|
+
}
|
|
158
|
+
return out.sort()
|
|
159
|
+
}
|
package/src/provision/index.ts
CHANGED
|
@@ -91,6 +91,27 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
|
|
|
91
91
|
runtimeBin,
|
|
92
92
|
warn: opts.warn,
|
|
93
93
|
})
|
|
94
|
+
// wake_policy:"ephemeral" sanity (M2 edge cases — warn, don't refuse: the policy
|
|
95
|
+
// lives in the hand-editable local profile, provision just surfaces the mismatch):
|
|
96
|
+
// • + interfaces.telegram: ephemeral WINS in resolveWakeMode (explicit policy
|
|
97
|
+
// beats the inferred human type), so a human dialogue channel would die-after-
|
|
98
|
+
// reply and never resume — almost certainly a config mistake.
|
|
99
|
+
// • + infra (always-on) runtime: launchd KeepAlive owns the session (H4 read-only)
|
|
100
|
+
// — the daemon never wakes/reaps it, so the ephemeral policy is INERT.
|
|
101
|
+
if (profile.wake_policy === 'ephemeral') {
|
|
102
|
+
if (profile.interfaces?.telegram != null) {
|
|
103
|
+
opts.warn?.(
|
|
104
|
+
`peer "${profile.personality}" declares BOTH wake_policy:"ephemeral" AND interfaces.telegram — ` +
|
|
105
|
+
`ephemeral wins (always-fresh, die-after-reply); a human telegram dialogue should not be ephemeral`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
if (isInfraRuntime(opts.runtime)) {
|
|
109
|
+
opts.warn?.(
|
|
110
|
+
`peer "${profile.personality}" declares wake_policy:"ephemeral" on always-on infra runtime ` +
|
|
111
|
+
`"${opts.runtime}" — launchd owns the session (H4), the daemon never wakes/reaps it, the policy is inert`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
94
115
|
// 2) registry entry — without it the daemon does not see the peer (tool-list,
|
|
95
116
|
// caller resolution, wake-on-miss findPeer all read peers-profiles.json).
|
|
96
117
|
await upsertPeer(
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// IAPEER_LAUNCHAGENTS_DIR temp dirs so the suite never touches the live fleet.
|
|
4
4
|
|
|
5
5
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
6
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
7
7
|
import { tmpdir } from 'os'
|
|
8
8
|
import { join } from 'path'
|
|
9
9
|
import { provisionPeer } from './index.ts'
|
|
@@ -102,3 +102,59 @@ describe('provisionPeer', () => {
|
|
|
102
102
|
expect(findPeer(readPeersIndex({ env }), 'timer')?.cwd).toBe(cwd)
|
|
103
103
|
})
|
|
104
104
|
})
|
|
105
|
+
|
|
106
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
// wake_policy:"ephemeral" provision warnings (M2 edge cases — warn, not refuse)
|
|
108
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe('provisionPeer ephemeral warnings', () => {
|
|
111
|
+
test('ephemeral + interfaces.telegram → WARN (ephemeral wins; human dialogue should not die-after-reply)', async () => {
|
|
112
|
+
const root = mkTmp()
|
|
113
|
+
const env = envFor(root)
|
|
114
|
+
const cwd = join(root, 'wtg')
|
|
115
|
+
// pre-existing profile (provision returns it unchanged) carrying the bad combo
|
|
116
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
117
|
+
writeFileSync(
|
|
118
|
+
peerProfilePath(cwd),
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
personality: 'wtg',
|
|
121
|
+
runtime: 'claude',
|
|
122
|
+
intelligence: 'natural',
|
|
123
|
+
interfaces: { telegram: { user_id: 1 } },
|
|
124
|
+
wake_policy: 'ephemeral',
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
const warns: string[] = []
|
|
128
|
+
await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
129
|
+
expect(warns.some(w => /ephemeral/.test(w) && /telegram/.test(w))).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('plain ephemeral worker (claude, no telegram) → NO ephemeral warn', async () => {
|
|
133
|
+
const root = mkTmp()
|
|
134
|
+
const env = envFor(root)
|
|
135
|
+
const cwd = join(root, 'weph')
|
|
136
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
137
|
+
writeFileSync(
|
|
138
|
+
peerProfilePath(cwd),
|
|
139
|
+
JSON.stringify({ personality: 'weph', runtime: 'claude', wake_policy: 'ephemeral' }),
|
|
140
|
+
)
|
|
141
|
+
const warns: string[] = []
|
|
142
|
+
await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
|
|
143
|
+
expect(warns.filter(w => /ephemeral/.test(w))).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('ephemeral + always-on infra runtime → WARN (H4: launchd owns it, the policy is inert)', async () => {
|
|
147
|
+
const root = mkTmp()
|
|
148
|
+
const { dir: bindir } = fakeBinDir()
|
|
149
|
+
const env = envFor(root, bindir)
|
|
150
|
+
const cwd = join(root, 'winf')
|
|
151
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
152
|
+
writeFileSync(
|
|
153
|
+
peerProfilePath(cwd),
|
|
154
|
+
JSON.stringify({ personality: 'winf', runtime: 'notifier', wake_policy: 'ephemeral' }),
|
|
155
|
+
)
|
|
156
|
+
const warns: string[] = []
|
|
157
|
+
await provisionPeer({ cwd, runtime: 'notifier', env, warn: m => warns.push(m) })
|
|
158
|
+
expect(warns.some(w => /ephemeral/.test(w) && /inert|launchd/i.test(w))).toBe(true)
|
|
159
|
+
})
|
|
160
|
+
})
|