@agfpd/iapeer 0.2.10 → 0.2.12
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/index.ts +24 -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/index.ts +3 -0
- 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/onboard/memory.test.ts +157 -0
- package/src/onboard/memory.ts +124 -0
- package/src/provision/index.ts +21 -0
- package/src/provision/provision.test.ts +57 -1
- package/src/status/index.ts +119 -0
- package/src/status/status.test.ts +125 -0
- package/src/transport/index.ts +39 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -352,7 +352,8 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
352
352
|
rollback revert to the previous binary (.prev) + restart the daemon
|
|
353
353
|
version | --version | -v print the installed binary's version
|
|
354
354
|
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
355
|
-
onboard [--dry-run] [--infra <csv>]
|
|
355
|
+
onboard [--dry-run] [--infra <csv>] [--no-memory] [--memory <pkg>] register the agfpd marketplace (+ infra runtimes; + default memory provider, default YES)
|
|
356
|
+
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
356
357
|
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
357
358
|
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
358
359
|
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
@@ -406,8 +407,30 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
406
407
|
} else if (infra.length) {
|
|
407
408
|
out(`onboard --dry-run: would onboard infra runtimes: ${infra.join(', ')}\n`)
|
|
408
409
|
}
|
|
410
|
+
// Memory slot (контракт «Слот памяти», 10.06): optional DEFAULT-YES install of
|
|
411
|
+
// the default provider; its own init runs with INHERITED stdio (the provider
|
|
412
|
+
// owns the install questions). The step is REPORT-ONLY for the exit code — an
|
|
413
|
+
// empty slot is a valid state regardless of why.
|
|
414
|
+
const { onboardMemoryProvider } = await import('../onboard/memory.ts')
|
|
415
|
+
const mem = await onboardMemoryProvider({
|
|
416
|
+
skip: flags['no-memory'] === true,
|
|
417
|
+
package: typeof flags.memory === 'string' ? flags.memory : undefined,
|
|
418
|
+
dryRun: flags['dry-run'] === true,
|
|
419
|
+
env,
|
|
420
|
+
})
|
|
421
|
+
const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
|
|
422
|
+
out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
|
|
409
423
|
return r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
|
|
410
424
|
}
|
|
425
|
+
case 'status': {
|
|
426
|
+
// Host snapshot (контракт «Слот памяти» §status): version + daemon health +
|
|
427
|
+
// the memory-slot line. Exit 1 iff the daemon is unhealthy (usable as a
|
|
428
|
+
// health gate); an EMPTY memory slot is a valid state and never fails.
|
|
429
|
+
const { hostStatus, formatHostStatus } = await import('../status/index.ts')
|
|
430
|
+
const s = await hostStatus({ env })
|
|
431
|
+
out(formatHostStatus(s))
|
|
432
|
+
return s.daemon.healthy ? 0 : 1
|
|
433
|
+
}
|
|
411
434
|
case 'install-runtime': {
|
|
412
435
|
// §6 onboard a runtime END-TO-END: npx-install the package (auto-resolved from
|
|
413
436
|
// the built-in runtime→package registry, or --package; self-deploys 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' },
|
|
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
|
-
|
|
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
|
+
})
|
package/src/daemon/index.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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()
|
package/src/daemon/main.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/daemon/main.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/index.ts
CHANGED
|
@@ -25,5 +25,8 @@ export * from './install/index.ts'
|
|
|
25
25
|
export * from './update/index.ts'
|
|
26
26
|
// Onboard — the host-phase (idempotent marketplace registration in claude + codex).
|
|
27
27
|
export * from './onboard/index.ts'
|
|
28
|
+
// Memory slot — the declarative provider slot (контракт «Слот памяти»): status read + onboard step.
|
|
29
|
+
export * from './status/index.ts'
|
|
30
|
+
export * from './onboard/memory.ts'
|
|
28
31
|
export { composeSystemPrompt, gatherPromptInput } from './launch/composeSystemPrompt.ts'
|
|
29
32
|
export type { GatherPromptOptions } from './launch/composeSystemPrompt.ts'
|