@agfpd/iapeer 0.2.10 → 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 +116 -3
- package/src/daemon/index.ts +58 -8
- package/src/daemon/main.test.ts +112 -0
- package/src/daemon/main.ts +87 -1
- package/src/lifecycle/index.ts +174 -4
- package/src/lifecycle/lifecycle.test.ts +134 -0
- 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/transport/index.ts +39 -0
|
@@ -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
|
+
})
|
package/src/transport/index.ts
CHANGED
|
@@ -378,6 +378,14 @@ export interface RouteResult {
|
|
|
378
378
|
delivered_to: { personality: string; runtime: string }
|
|
379
379
|
woke: boolean
|
|
380
380
|
ts: string
|
|
381
|
+
/** wake_policy:"ephemeral" M3 — true when the message was ENQUEUED for a
|
|
382
|
+
* stateless worker rather than injected/woken synchronously: the daemon
|
|
383
|
+
* drains the per-peer FIFO one task per fresh session (async). The
|
|
384
|
+
* substantive result arrives as the worker's own reply (FaaS semantics);
|
|
385
|
+
* `delivered_to` names the ACCEPTING queue identity, not a live session. */
|
|
386
|
+
queued?: boolean
|
|
387
|
+
/** Queue depth right after the enqueue (observability; only with queued). */
|
|
388
|
+
queueDepth?: number
|
|
381
389
|
}
|
|
382
390
|
|
|
383
391
|
// WakeFn — the lifecycle wake primitive, INJECTED (transport never imports
|
|
@@ -403,9 +411,29 @@ export interface WakeOutcome {
|
|
|
403
411
|
}
|
|
404
412
|
export type WakeFn = (req: WakeRequest) => Promise<WakeOutcome>
|
|
405
413
|
|
|
414
|
+
/** wake_policy:"ephemeral" M3 — the injected serial-queue delivery seam. Like
|
|
415
|
+
* WakeFn, transport never imports lifecycle: the daemon composition (main.ts)
|
|
416
|
+
* wires the queue + drain behind this contract; absent → every target takes
|
|
417
|
+
* the normal live/miss path (library/CLI/test callers unchanged). */
|
|
418
|
+
export interface EphemeralRouteDeps {
|
|
419
|
+
/** Is the target peer (by its registry cwd) an ephemeral stateless worker? */
|
|
420
|
+
isEphemeral: (cwd: string) => boolean
|
|
421
|
+
/** Always-enqueue delivery: enqueue + async drain kick → fast `{queued:true}`
|
|
422
|
+
* ack. MUST NOT block on the wake (the sender's content-level answer is the
|
|
423
|
+
* worker's own reply, not this transport ack). */
|
|
424
|
+
deliver: (args: {
|
|
425
|
+
peer: PeerRecord
|
|
426
|
+
envelope: string
|
|
427
|
+
topic?: string
|
|
428
|
+
runtime?: string
|
|
429
|
+
}) => Promise<Result<RouteResult>>
|
|
430
|
+
}
|
|
431
|
+
|
|
406
432
|
export interface RouteDeps {
|
|
407
433
|
/** On a miss, wake the dead peer instead of returning offline (Ф2). */
|
|
408
434
|
wake?: WakeFn
|
|
435
|
+
/** Ephemeral-target serial-queue delivery (M3); absent → normal routing. */
|
|
436
|
+
ephemeral?: EphemeralRouteDeps
|
|
409
437
|
}
|
|
410
438
|
|
|
411
439
|
function truncateTopic(raw: string | undefined): string | undefined {
|
|
@@ -483,6 +511,17 @@ export async function routeSend(
|
|
|
483
511
|
message,
|
|
484
512
|
})
|
|
485
513
|
|
|
514
|
+
// M3 wake_policy:"ephemeral" — an ephemeral target is NEVER injected into a live
|
|
515
|
+
// session and never woken synchronously here: the delivery is ALWAYS enqueued
|
|
516
|
+
// (per-peer disk FIFO) and drained one task per fresh session, asynchronously.
|
|
517
|
+
// ONE delivery path by design: no live/miss race, strict FIFO, a clean context
|
|
518
|
+
// window per task. Self-send is refused up front (a worker enqueueing to itself
|
|
519
|
+
// would deadlock its own die-after-reply reap on a forever-non-empty queue).
|
|
520
|
+
if (deps.ephemeral?.isEphemeral(peer.cwd)) {
|
|
521
|
+
if (caller.personality === peer.personality) return err('cannot send to self')
|
|
522
|
+
return deps.ephemeral.deliver({ peer, envelope, topic, runtime })
|
|
523
|
+
}
|
|
524
|
+
|
|
486
525
|
const target = resolvePeerDeliveryTarget(personality, runtime, peer)
|
|
487
526
|
if (target.ok) {
|
|
488
527
|
if (target.value.address === caller.address) return err('cannot send to self')
|