@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -106,13 +106,12 @@ describe('per-delivery outcome log (Ф-#8a — delivery.log)', () => {
106
106
  test('with deliveryLogDir wired: ONE logfmt outcome line per attempt, metadata only', async () => {
107
107
  const logDir = join(root, 'logs', 'iapeer')
108
108
  const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
109
- await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' }, undefined, logDir)
109
+ await callTool(caller, 'send_to_peer', { personality: 'offlinepeer', message: 'hi' }, { deliveryLogDir: logDir })
110
110
  await callTool(
111
111
  caller,
112
112
  'send_to_peer',
113
113
  { personality: 'nobody', message: 'hello there', topic: 'probe topic' },
114
- undefined,
115
- logDir,
114
+ { deliveryLogDir: logDir },
116
115
  )
117
116
  const lines = readFileSync(join(logDir, 'delivery.log'), 'utf8').trim().split('\n')
118
117
  expect(lines.length).toBe(2)
@@ -138,3 +137,117 @@ describe('per-delivery outcome log (Ф-#8a — delivery.log)', () => {
138
137
  expect(existsSync(join(probeDir, 'delivery.log'))).toBe(false)
139
138
  })
140
139
  })
140
+
141
+ describe('onDelivered hook (M2 arm-on-outbound seam)', () => {
142
+ test('NOT fired on a FAILED delivery — a worker whose reply did not land must not be armed', async () => {
143
+ const fired: string[] = []
144
+ const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
145
+ // offline target, no wake → routeSend fails → hook must stay silent
146
+ const r = await callTool(
147
+ caller,
148
+ 'send_to_peer',
149
+ { personality: 'offlinepeer', message: 'hi' },
150
+ { onDelivered: c => fired.push(c.address) },
151
+ )
152
+ expect(r.isError).toBe(true)
153
+ expect(fired).toEqual([])
154
+ })
155
+
156
+ test('a throwing hook never fails the tool call (fail-safe)', async () => {
157
+ const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
158
+ // even on the error path nothing throws; the ok-path guard is the same try/catch
159
+ const r = await callTool(
160
+ caller,
161
+ 'send_to_peer',
162
+ { personality: 'offlinepeer', message: 'hi' },
163
+ {
164
+ onDelivered: () => {
165
+ throw new Error('hook boom')
166
+ },
167
+ },
168
+ )
169
+ expect(r.isError).toBe(true) // the routeSend offline error, NOT the hook's
170
+ expect(r.content[0].text).toMatch(/offline/)
171
+ })
172
+ })
173
+
174
+ describe('ephemeral serial-queue seam (M3)', () => {
175
+ test('ephemeral TARGET → deliver() handles it (no live/miss routing); queued result + delivery.log queued/qd', async () => {
176
+ const logDir = join(root, 'logs', 'm3')
177
+ const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
178
+ const delivered: string[] = []
179
+ const r = await callTool(
180
+ caller,
181
+ 'send_to_peer',
182
+ { personality: 'offlinepeer', message: 'job for the worker', topic: 'job-1' },
183
+ {
184
+ deliveryLogDir: logDir,
185
+ ephemeral: {
186
+ isEphemeral: cwd => {
187
+ delivered.push(`checked:${cwd}`)
188
+ return true // offlinepeer plays an ephemeral worker
189
+ },
190
+ deliver: async ({ peer, envelope, topic }) => {
191
+ delivered.push(`deliver:${peer.personality}:${topic}`)
192
+ expect(envelope).toContain('job for the worker') // the routed envelope, not the raw body
193
+ return {
194
+ ok: true,
195
+ value: {
196
+ ok: true as const,
197
+ delivered_to: { personality: peer.personality, runtime: 'claude' },
198
+ woke: false,
199
+ queued: true,
200
+ queueDepth: 2,
201
+ ts: 'now',
202
+ },
203
+ }
204
+ },
205
+ },
206
+ },
207
+ )
208
+ expect(r.isError).toBeUndefined()
209
+ expect(delivered).toEqual(['checked:/tmp/offlinepeer', 'deliver:offlinepeer:job-1'])
210
+ const line = readFileSync(join(logDir, 'delivery.log'), 'utf8').trim()
211
+ expect(line).toContain('queued=true')
212
+ expect(line).toContain('qd=2')
213
+ expect(line).toContain('ok=true')
214
+ })
215
+
216
+ test('ephemeral self-send is refused up front (a worker enqueueing itself would deadlock its reap)', async () => {
217
+ const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
218
+ const r = await callTool(
219
+ caller,
220
+ 'send_to_peer',
221
+ { personality: 'boris', message: 'to myself' },
222
+ {
223
+ ephemeral: {
224
+ isEphemeral: () => true,
225
+ deliver: async () => {
226
+ throw new Error('must not be reached')
227
+ },
228
+ },
229
+ },
230
+ )
231
+ expect(r.isError).toBe(true)
232
+ expect(r.content[0].text).toMatch(/cannot send to self/)
233
+ })
234
+
235
+ test('non-ephemeral target ignores the seam (normal offline path)', async () => {
236
+ const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
237
+ const r = await callTool(
238
+ caller,
239
+ 'send_to_peer',
240
+ { personality: 'offlinepeer', message: 'hi' },
241
+ {
242
+ ephemeral: {
243
+ isEphemeral: () => false,
244
+ deliver: async () => {
245
+ throw new Error('must not be reached')
246
+ },
247
+ },
248
+ },
249
+ )
250
+ expect(r.isError).toBe(true)
251
+ expect(r.content[0].text).toMatch(/offline/) // took the ordinary miss path
252
+ })
253
+ })
@@ -43,7 +43,7 @@ import { IapError } from '../core/errors.ts'
43
43
  import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
44
44
  import { publicPeerSummary, readPeersIndex, type PeersIndex } from '../registry/index.ts'
45
45
  import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
46
- import { routeSend, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
46
+ import { routeSend, type EphemeralRouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
47
47
  import { appendDeliveryEvent } from './deliverylog.ts'
48
48
 
49
49
  export const CALLER_HEADER = 'x-iapeer-identity'
@@ -172,12 +172,29 @@ function errResult(text: string): ToolResult {
172
172
  return { isError: true, content: [{ type: 'text', text }] }
173
173
  }
174
174
 
175
+ /** Injected seams for callTool — all OPTIONAL, all wired by the composition point
176
+ * (daemon/main.ts); library/test callers omit them and stay hermetic. */
177
+ export interface CallToolDeps {
178
+ /** Wake-on-miss primitive (Ф2) — see StartDaemonOptions.wake. */
179
+ wake?: WakeFn
180
+ /** Per-delivery outcome log dir (Ф-#8a) — see StartDaemonOptions.deliveryLogDir. */
181
+ deliveryLogDir?: string
182
+ /** Fired AFTER a delivery resolved ok, with the request-resolved CALLER (the
183
+ * sender). The wake_policy:ephemeral M2 arm-on-outbound seam: the composition
184
+ * point marks an ephemeral caller as armed (its single reply is out → quiet-reap
185
+ * eligible). Kept as an injected hook so the daemon never imports lifecycle
186
+ * (layering: main.ts is the only place transport meets lifecycle). Failures are
187
+ * swallowed — a hook must never fail a delivery that already succeeded. */
188
+ onDelivered?: (caller: ResolvedCaller) => void
189
+ /** Ephemeral-target serial-queue delivery (M3) — see transport.EphemeralRouteDeps. */
190
+ ephemeral?: EphemeralRouteDeps
191
+ }
192
+
175
193
  export async function callTool(
176
194
  caller: ResolvedCaller,
177
195
  name: string,
178
196
  args: Record<string, unknown>,
179
- wake?: WakeFn,
180
- deliveryLogDir?: string,
197
+ deps: CallToolDeps = {},
181
198
  ): Promise<ToolResult> {
182
199
  if (name === 'send_to_peer') {
183
200
  const input: SendToPeerInput = {
@@ -188,13 +205,13 @@ export async function callTool(
188
205
  attachments: Array.isArray(args.attachments) ? (args.attachments as string[]) : undefined,
189
206
  }
190
207
  const t0 = Date.now()
191
- const sent = await routeSend(caller, input, { wake })
208
+ const sent = await routeSend(caller, input, { wake: deps.wake, ephemeral: deps.ephemeral })
192
209
  // Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
193
210
  // to lifecycle.log) — metadata only, never the body. Both branches of the
194
211
  // routeSend result are recorded, so a suspected loss is reconstructable from
195
212
  // disk (the gap the 09.06 investigation hit). No-op when no dir is wired
196
213
  // (library/test daemons); appendDeliveryEvent itself never throws.
197
- appendDeliveryEvent(deliveryLogDir, {
214
+ appendDeliveryEvent(deps.deliveryLogDir, {
198
215
  ev: 'delivery',
199
216
  caller: caller.address,
200
217
  to: input.personality,
@@ -202,12 +219,27 @@ export async function callTool(
202
219
  ok: String(sent.ok),
203
220
  via: sent.ok ? `${sent.value.delivered_to.runtime}-${sent.value.delivered_to.personality}` : undefined,
204
221
  woke: sent.ok ? String(sent.value.woke) : undefined,
222
+ // M3 queue observability (boris acceptance (a)): a queued accept is marked
223
+ // and carries the queue depth at accept time — a stuck queue is visible.
224
+ queued: sent.ok && sent.value.queued ? 'true' : undefined,
225
+ qd: sent.ok ? sent.value.queueDepth : undefined,
205
226
  ms: Date.now() - t0,
206
227
  len: input.message.length,
207
228
  att: input.attachments?.length || undefined,
208
229
  topic: input.topic,
209
230
  err: sent.ok ? undefined : sent.error.message,
210
231
  })
232
+ // M2 arm-on-outbound: ONLY on an ok outcome — a failed send means the caller's
233
+ // reply is NOT out, and arming then could quiet-reap a worker mid-retry (the
234
+ // ordinary idle bound covers that case instead). Fail-safe: hook errors are
235
+ // swallowed, the delivery result stands.
236
+ if (sent.ok) {
237
+ try {
238
+ deps.onDelivered?.(caller)
239
+ } catch {
240
+ /* a post-delivery hook must never fail the delivery */
241
+ }
242
+ }
211
243
  return sent.ok ? jsonResult(sent.value) : errResult(sent.error.message)
212
244
  }
213
245
  return errResult(`unknown tool: ${name}`)
@@ -223,7 +255,7 @@ function headerFromRequestInfo(extra: { requestInfo?: { headers?: Record<string,
223
255
  return Array.isArray(value) ? (value[0] as string) : (value as string | undefined)
224
256
  }
225
257
 
226
- export function createMcpServer(wake?: WakeFn, deliveryLogDir?: string): Server {
258
+ export function createMcpServer(deps: CallToolDeps = {}): Server {
227
259
  const server = new Server(SERVER_INFO, { capabilities: { tools: {} } })
228
260
 
229
261
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools(readPeersIndex()) }))
@@ -246,7 +278,7 @@ export function createMcpServer(wake?: WakeFn, deliveryLogDir?: string): Server
246
278
  process.stderr.write(`[iapeer-daemon] tools/call tool=${req.params.name} caller=${caller.address}\n`)
247
279
  }
248
280
  const args = (req.params.arguments ?? {}) as Record<string, unknown>
249
- return (await callTool(caller, req.params.name, args, wake, deliveryLogDir)) as CallToolResult
281
+ return (await callTool(caller, req.params.name, args, deps)) as CallToolResult
250
282
  })
251
283
 
252
284
  return server
@@ -322,6 +354,19 @@ export interface StartDaemonOptions extends StorageOptions {
322
354
  * delivery.log sits next to lifecycle.log under the SAME cfg-resolved root.
323
355
  */
324
356
  deliveryLogDir?: string
357
+ /**
358
+ * Post-delivery hook (wake_policy:ephemeral M2 arm-on-outbound seam) — fired with
359
+ * the request-resolved CALLER after each delivery that resolved ok. See
360
+ * CallToolDeps.onDelivered. OFF by default; the production main wires the
361
+ * lifecycle arm (an ephemeral caller's ok outbound ⇒ armed for quiet-reap).
362
+ */
363
+ onDelivered?: (caller: ResolvedCaller) => void
364
+ /**
365
+ * Ephemeral-target serial-queue delivery (wake_policy:"ephemeral" M3) — see
366
+ * transport.EphemeralRouteDeps. OFF by default; the production main wires the
367
+ * lifecycle queue + drain (makeEphemeralRouteDeps).
368
+ */
369
+ ephemeral?: EphemeralRouteDeps
325
370
  /**
326
371
  * Write the discovery file (router.json) at <root>/state/iapeer/router.json with
327
372
  * the active addresses `{sock, tcp}` — atomically on listen, removed on close.
@@ -349,7 +394,12 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
349
394
  res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'unauthorized' } }))
350
395
  return
351
396
  }
352
- const server = createMcpServer(opts.wake, opts.deliveryLogDir)
397
+ const server = createMcpServer({
398
+ wake: opts.wake,
399
+ deliveryLogDir: opts.deliveryLogDir,
400
+ onDelivered: opts.onDelivered,
401
+ ephemeral: opts.ephemeral,
402
+ })
353
403
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
354
404
  res.on('close', () => {
355
405
  transport.close()
@@ -13,10 +13,19 @@ import {
13
13
  buildDaemonPlistSpec,
14
14
  daemonPlistPath,
15
15
  installDaemonPlist,
16
+ makeArmEphemeralOnDelivered,
17
+ makeEphemeralRouteDeps,
16
18
  startConfiguredDaemon,
17
19
  DEFAULT_DAEMON_PORT,
18
20
  } from './main.ts'
19
21
  import { daemonDiscoveryPath, defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
22
+ import {
23
+ ephemeralQueueDepth,
24
+ hasEphemeralArmed,
25
+ peekEphemeralTask,
26
+ type LifecycleConfig,
27
+ } from '../lifecycle/index.ts'
28
+ import type { ResolvedCaller } from '../identity/index.ts'
20
29
  import { isFoundationOwnedPlist } from '../launch/index.ts'
21
30
  import { DAEMON_PLIST_LABEL } from '../core/constants.ts'
22
31
 
@@ -192,3 +201,106 @@ describe('dual-listen + router.json discovery', () => {
192
201
  }
193
202
  })
194
203
  })
204
+
205
+ // ─────────────────────────────────────────────────────────────────────────────
206
+ // wake_policy:ephemeral M2 — makeArmEphemeralOnDelivered (the arm-on-outbound
207
+ // composition: ephemeral caller's ok send ⇒ .ephemeral-armed for its identity)
208
+ // ─────────────────────────────────────────────────────────────────────────────
209
+
210
+ describe('makeArmEphemeralOnDelivered (M2 arm-on-outbound)', () => {
211
+ function caller(cwd: string): ResolvedCaller {
212
+ return {
213
+ personality: 'w',
214
+ runtime: 'claude',
215
+ address: 'claude-w',
216
+ description: '',
217
+ intelligence: 'artificial',
218
+ cwd,
219
+ record: { personality: 'w', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd },
220
+ } as ResolvedCaller
221
+ }
222
+
223
+ test('EPHEMERAL caller → armed for the CALLER identity; plain caller → no-op', () => {
224
+ const stateDir = join(mkTmp(), 'lifecycle')
225
+ const cfg = { stateDir } as LifecycleConfig
226
+ const arm = makeArmEphemeralOnDelivered(cfg)
227
+ // ephemeral worker cwd
228
+ const eph = mkTmp()
229
+ mkdirSync(join(eph, '.iapeer'), { recursive: true })
230
+ writeFileSync(
231
+ join(eph, '.iapeer', 'peer-profile.json'),
232
+ JSON.stringify({ personality: 'w', runtime: 'claude', wake_policy: 'ephemeral' }),
233
+ )
234
+ arm(caller(eph))
235
+ expect(hasEphemeralArmed(cfg, 'claude-w')).toBe(true)
236
+ // plain peer cwd (no wake_policy) → never armed
237
+ const plainCfg = { stateDir: join(mkTmp(), 'lifecycle') } as LifecycleConfig
238
+ const plain = mkTmp()
239
+ mkdirSync(join(plain, '.iapeer'), { recursive: true })
240
+ writeFileSync(
241
+ join(plain, '.iapeer', 'peer-profile.json'),
242
+ JSON.stringify({ personality: 'w', runtime: 'claude' }),
243
+ )
244
+ makeArmEphemeralOnDelivered(plainCfg)(caller(plain))
245
+ expect(hasEphemeralArmed(plainCfg, 'claude-w')).toBe(false)
246
+ })
247
+
248
+ test('missing profile / unreadable cwd → no-op, never throws (best-effort)', () => {
249
+ const cfg = { stateDir: join(mkTmp(), 'lifecycle') } as LifecycleConfig
250
+ const arm = makeArmEphemeralOnDelivered(cfg)
251
+ expect(() => arm(caller('/tmp/no-such-peer-cwd-anywhere'))).not.toThrow()
252
+ expect(hasEphemeralArmed(cfg, 'claude-w')).toBe(false)
253
+ })
254
+ })
255
+
256
+ // ─────────────────────────────────────────────────────────────────────────────
257
+ // wake_policy:"ephemeral" M3 — makeEphemeralRouteDeps (enqueue + async drain kick)
258
+ // ─────────────────────────────────────────────────────────────────────────────
259
+
260
+ describe('makeEphemeralRouteDeps (M3 serial-queue composition)', () => {
261
+ const workerPeer = {
262
+ personality: 'w',
263
+ runtime: 'claude' as const,
264
+ runtimes: ['claude' as const],
265
+ description: '',
266
+ intelligence: 'artificial' as const,
267
+ cwd: '/tmp/w',
268
+ }
269
+
270
+ test('deliver = enqueue to disk + fast {queued, qd} ack + drain kick (NOT awaited wake)', async () => {
271
+ const stateDir = join(mkTmp(), 'lifecycle')
272
+ const cfg = { stateDir } as LifecycleConfig
273
+ const kicks: string[] = []
274
+ const deps = makeEphemeralRouteDeps(cfg, {} as NodeJS.ProcessEnv, (p, rt) => kicks.push(`${rt}-${p}`))
275
+
276
+ const r1 = await deps.deliver({ peer: workerPeer, envelope: '<iap>task one</iap>', topic: 't1' })
277
+ const r2 = await deps.deliver({ peer: workerPeer, envelope: '<iap>task two</iap>' })
278
+
279
+ expect(r1.ok && r1.value.queued).toBe(true)
280
+ expect(r1.ok && r1.value.queueDepth).toBe(1)
281
+ expect(r1.ok && r1.value.woke).toBe(false)
282
+ expect(r2.ok && r2.value.queueDepth).toBe(2)
283
+ expect(kicks).toEqual(['claude-w', 'claude-w']) // drain kicked per enqueue
284
+ // durable FIFO on disk, head intact
285
+ expect(ephemeralQueueDepth(cfg, 'claude-w')).toBe(2)
286
+ expect(peekEphemeralTask(cfg, 'claude-w')).toMatchObject({ task: '<iap>task one</iap>', topic: 't1' })
287
+ })
288
+
289
+ test('enqueue failure → fail-loud delivery error (never a false queued ack)', async () => {
290
+ const root = mkTmp()
291
+ const blocker = join(root, 'state-is-a-file')
292
+ writeFileSync(blocker, 'not a dir')
293
+ const cfg = { stateDir: join(blocker, 'lifecycle') } as LifecycleConfig // mkdir must fail
294
+ const deps = makeEphemeralRouteDeps(cfg, {} as NodeJS.ProcessEnv, () => {})
295
+ const r = await deps.deliver({ peer: workerPeer, envelope: 'x' })
296
+ expect(r.ok).toBe(false)
297
+ if (!r.ok) expect(r.error.message).toMatch(/enqueue failed/)
298
+ })
299
+
300
+ test('runtime override resolves through the registry gate (undeclared → fail-loud)', async () => {
301
+ const cfg = { stateDir: join(mkTmp(), 'lifecycle') } as LifecycleConfig
302
+ const deps = makeEphemeralRouteDeps(cfg, {} as NodeJS.ProcessEnv, () => {})
303
+ const r = await deps.deliver({ peer: workerPeer, envelope: 'x', runtime: 'codex' })
304
+ expect(r.ok).toBe(false) // 'codex' is not declared for this worker
305
+ })
306
+ })
@@ -29,13 +29,22 @@ import {
29
29
  } from '../launch/launchd.ts'
30
30
  import type { LaunchdPlistSpec } from '../launch/launchd.ts'
31
31
  import {
32
+ drainAllEphemeralQueues,
33
+ drainEphemeralQueue,
34
+ enqueueEphemeralTask,
35
+ isEphemeralPeer,
32
36
  loadLifecycleConfig,
33
37
  processEagerRelaunches,
38
+ resolveWakeRuntime,
39
+ setEphemeralArmed,
34
40
  superviseTick,
35
41
  wakeOrSpawn,
36
42
  type LifecycleConfig,
37
43
  } from '../lifecycle/index.ts'
38
- import type { WakeFn, WakeOutcome, WakeRequest } from '../transport/index.ts'
44
+ import { buildProcessAddress } from '../core/socket.ts'
45
+ import { err, ok } from '../core/errors.ts'
46
+ import type { ResolvedCaller } from '../identity/index.ts'
47
+ import type { EphemeralRouteDeps, WakeFn, WakeOutcome, WakeRequest } from '../transport/index.ts'
39
48
  import { iapeerBinPath } from '../install/index.ts'
40
49
  import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
41
50
 
@@ -68,6 +77,72 @@ export function makeWakeFn(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): WakeFn
68
77
  )
69
78
  }
70
79
 
80
+ /**
81
+ * wake_policy:ephemeral M2 — the arm-on-outbound composition (the ONE place the
82
+ * daemon's onDelivered seam meets lifecycle, like makeWakeFn for wake). An
83
+ * ephemeral worker sends exactly ONE outbound — its final reply (ADR-006: no
84
+ * intermediate send_to_peer) — so "this caller's send was delivered ok" ⇒ the
85
+ * task is answered ⇒ arm the quiet-reap marker for the caller's identity.
86
+ * Non-ephemeral callers (the profile read keys on the caller's registry cwd) are
87
+ * a no-op, so the hook is safe to run on EVERY delivery. Best-effort: an arm
88
+ * failure must never fail the already-succeeded delivery (superviseTick's idle
89
+ * bound still reaps an unarmed worker eventually).
90
+ */
91
+ export function makeArmEphemeralOnDelivered(cfg: LifecycleConfig): (caller: ResolvedCaller) => void {
92
+ return caller => {
93
+ try {
94
+ if (caller.cwd && isEphemeralPeer(caller.cwd)) setEphemeralArmed(cfg, caller.address)
95
+ } catch {
96
+ /* arming is best-effort */
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * wake_policy:"ephemeral" M3 — the serial-queue delivery composition (the third
103
+ * lifecycle⇆transport seam after makeWakeFn / makeArmEphemeralOnDelivered).
104
+ * deliver = enqueue (exclusive-create FIFO) → fast `{queued:true, qd}` ack →
105
+ * ASYNC drain kick (deliberately not awaited: the wake takes ~30-60s and the
106
+ * sender's substantive answer is the worker's own reply — FaaS, ADR-006). A
107
+ * failed kick is not a lost task: the supervise-tick drain scan retries every
108
+ * non-empty queue without a live session. An enqueue FAILURE is a delivery
109
+ * error (fail-loud) — a task that did not reach disk must never be acked queued.
110
+ * `kick` is injectable for tests; default drains the real queue.
111
+ */
112
+ export function makeEphemeralRouteDeps(
113
+ cfg: LifecycleConfig,
114
+ env: NodeJS.ProcessEnv,
115
+ kick: (personality: string, runtime: Parameters<typeof drainEphemeralQueue>[2]) => void = (p, rt) => {
116
+ void drainEphemeralQueue(cfg, p, rt, { env }).catch(() => {})
117
+ },
118
+ ): EphemeralRouteDeps {
119
+ return {
120
+ isEphemeral: cwd => isEphemeralPeer(cwd),
121
+ deliver: async ({ peer, envelope, topic, runtime }) => {
122
+ const rt = resolveWakeRuntime(runtime, peer)
123
+ if (!rt.ok) return rt
124
+ const identity = buildProcessAddress(rt.value, peer.personality)
125
+ let depth: number
126
+ try {
127
+ depth = enqueueEphemeralTask(cfg, identity, { task: envelope, topic })
128
+ } catch (e) {
129
+ return err(
130
+ `ephemeral enqueue failed for "${peer.personality}": ${e instanceof Error ? e.message : String(e)}; message NOT queued`,
131
+ )
132
+ }
133
+ kick(peer.personality, rt.value)
134
+ return ok({
135
+ ok: true as const,
136
+ delivered_to: { personality: peer.personality, runtime: rt.value },
137
+ woke: false,
138
+ queued: true,
139
+ queueDepth: depth,
140
+ ts: new Date().toISOString(),
141
+ })
142
+ },
143
+ }
144
+ }
145
+
71
146
  export interface ConfiguredDaemonOptions {
72
147
  /** TCP loopback port (default DEFAULT_DAEMON_PORT). */
73
148
  port?: number
@@ -97,6 +172,12 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
97
172
  const bearerToken = opts.bearerToken ?? (env.IAPEER_BEARER_TOKEN?.trim() || undefined)
98
173
  return startDaemon({
99
174
  wake: makeWakeFn(cfg, env),
175
+ // M2 arm-on-outbound (see makeArmEphemeralOnDelivered): ephemeral caller's ok
176
+ // send ⇒ armed for the supervise quiet-reap. No-op for every other caller.
177
+ onDelivered: makeArmEphemeralOnDelivered(cfg),
178
+ // M3 serial queue (see makeEphemeralRouteDeps): ephemeral TARGET ⇒ enqueue +
179
+ // async drain. No-op for every other target.
180
+ ephemeral: makeEphemeralRouteDeps(cfg, env),
100
181
  supervise: {
101
182
  intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
102
183
  // idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
@@ -109,6 +190,11 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
109
190
  tick: async () => {
110
191
  const outcomes = superviseTick(cfg, { env })
111
192
  await processEagerRelaunches(cfg, outcomes, { env })
193
+ // M3 drain scan — ONE mechanism for the whole serial-queue loop: feeds the
194
+ // next task right after a reaped-ephemeral (same tick), drains on daemon
195
+ // start (durable queue), and RETRIES a failed wake (the item stayed at the
196
+ // head). Identities without a queue or with a live session are no-ops.
197
+ await drainAllEphemeralQueues(cfg, { env })
112
198
  },
113
199
  },
114
200
  bearerToken,