@agfpd/iapeer 0.2.25 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/cli.test.ts +11 -0
- package/src/cli/index.ts +68 -1
- package/src/lifecycle/index.ts +35 -0
- package/src/lifecycle/lifecycle.test.ts +63 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -130,6 +130,17 @@ describe('remove (registry record via the locked writer)', () => {
|
|
|
130
130
|
expect((await removePeerCli('twice', { env: e })).action).toBe('removed')
|
|
131
131
|
expect((await removePeerCli('twice', { env: e })).action).toBe('absent')
|
|
132
132
|
})
|
|
133
|
+
test('self-done arms the caller\'s own quiet-reap (non-waking silent finish); refuses without PEER_IDENTITY', async () => {
|
|
134
|
+
const e = env()
|
|
135
|
+
// no PEER_IDENTITY → self-call refusal
|
|
136
|
+
expect(await runCli(['self-done'], e)).toBe(1)
|
|
137
|
+
// with PEER_IDENTITY → marker set, exit 0, nobody contacted
|
|
138
|
+
const e2 = { ...e, PEER_IDENTITY: 'claude-silentworker' }
|
|
139
|
+
expect(await runCli(['self-done'], e2)).toBe(0)
|
|
140
|
+
const { hasEphemeralArmed } = await import('../lifecycle/index.ts')
|
|
141
|
+
expect(hasEphemeralArmed(loadLifecycleConfig(e2), 'claude-silentworker')).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
133
144
|
test('purges identity-keyed lifecycle state with the record — a namesake newborn must not inherit a dead peer\'s parking (boris 10.06)', async () => {
|
|
134
145
|
await register('reborn')
|
|
135
146
|
const e = env()
|
package/src/cli/index.ts
CHANGED
|
@@ -30,11 +30,13 @@ import {
|
|
|
30
30
|
clearStopped,
|
|
31
31
|
folderLaunch,
|
|
32
32
|
isLaunchdManaged,
|
|
33
|
+
isEphemeralPeer,
|
|
33
34
|
isStopped,
|
|
34
35
|
killSession,
|
|
35
36
|
loadLifecycleConfig,
|
|
36
37
|
purgeIdentityState,
|
|
37
38
|
removeSessionState,
|
|
39
|
+
setEphemeralArmed,
|
|
38
40
|
setIdleReaped,
|
|
39
41
|
setNewEager,
|
|
40
42
|
setStopped,
|
|
@@ -348,8 +350,9 @@ export async function sendMessage(
|
|
|
348
350
|
// unawaited in-process wake would die with it; the daemon's supervise-tick
|
|
349
351
|
// drain scan (≤60 s) picks the queue up — the EXISTING retry path for failed
|
|
350
352
|
// kicks, not a new mechanism.
|
|
351
|
-
const { makeEphemeralRouteDeps } = await import('../daemon/main.ts')
|
|
353
|
+
const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps } = await import('../daemon/main.ts')
|
|
352
354
|
const cfg = loadLifecycleConfig(env)
|
|
355
|
+
const t0 = Date.now()
|
|
353
356
|
const result = await routeSend(
|
|
354
357
|
caller,
|
|
355
358
|
{
|
|
@@ -361,7 +364,39 @@ export async function sendMessage(
|
|
|
361
364
|
},
|
|
362
365
|
{ wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}) },
|
|
363
366
|
)
|
|
367
|
+
// delivery.log sink — CLI-path parity (boris's observability gap 10.06: enqueues
|
|
368
|
+
// routed through the CLI left to=<peer> at ZERO for the day while real wakes
|
|
369
|
+
// happened; the daemon tool-path logs, this path was blind). Same fields, plus
|
|
370
|
+
// path=cli so the two entry points are distinguishable. Both branches logged.
|
|
371
|
+
const { appendDeliveryEvent } = await import('../daemon/deliverylog.ts')
|
|
372
|
+
appendDeliveryEvent(cfg.eventLogDir, {
|
|
373
|
+
ev: 'delivery',
|
|
374
|
+
path: 'cli',
|
|
375
|
+
caller: caller.address,
|
|
376
|
+
to: opts.target,
|
|
377
|
+
rt: opts.runtime,
|
|
378
|
+
ok: String(result.ok),
|
|
379
|
+
via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
|
|
380
|
+
woke: result.ok ? String(result.value.woke) : undefined,
|
|
381
|
+
queued: result.ok && result.value.queued ? 'true' : undefined,
|
|
382
|
+
qd: result.ok ? result.value.queueDepth : undefined,
|
|
383
|
+
ms: Date.now() - t0,
|
|
384
|
+
len: opts.message.length,
|
|
385
|
+
att: opts.attachments?.length || undefined,
|
|
386
|
+
topic: opts.topic,
|
|
387
|
+
err: result.ok ? undefined : result.error.message,
|
|
388
|
+
})
|
|
364
389
|
if (!result.ok) throw new Error(result.error.message)
|
|
390
|
+
// M2 arm-on-outbound — CLI-path parity (live gap 10.06: an ephemeral worker's
|
|
391
|
+
// final reply sent through the CLI fallback — e.g. inside a daemon-restart
|
|
392
|
+
// window, four deploys that day — never armed, so the worker idled to the
|
|
393
|
+
// unarmed bound and stalled its FIFO). Same hook the daemon path uses; ONLY on
|
|
394
|
+
// an ok outcome, errors swallowed (arming is best-effort, never fails the send).
|
|
395
|
+
try {
|
|
396
|
+
makeArmEphemeralOnDelivered(cfg)(caller)
|
|
397
|
+
} catch {
|
|
398
|
+
/* best-effort */
|
|
399
|
+
}
|
|
365
400
|
return {
|
|
366
401
|
ok: true,
|
|
367
402
|
delivered_to: result.value.delivered_to,
|
|
@@ -432,6 +467,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
432
467
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
433
468
|
compact <peer> [runtime] compact the peer's context (/compact)
|
|
434
469
|
self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
|
|
470
|
+
self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
|
|
435
471
|
native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
|
|
436
472
|
memory-plugin <on|off> (--peer <p> | --all) install/remove the slot-declared provider plugin (claude per-peer, codex host-global)
|
|
437
473
|
`
|
|
@@ -913,6 +949,37 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
913
949
|
if (!positionals[0] || !positionals[1]) return usage(errOut)
|
|
914
950
|
return await runAlwaysOn(positionals[0], positionals[1], process.cwd())
|
|
915
951
|
}
|
|
952
|
+
case 'self-done': {
|
|
953
|
+
// SILENT-FINISH self-call for an ephemeral worker (контракт ЖЦ §wake_policy;
|
|
954
|
+
// развилка boris 10.06): a worker whose task produced NOTHING to send must
|
|
955
|
+
// still release its M3 FIFO — but an EMPTY report would violate Артур's
|
|
956
|
+
// invariant «событие-всё-отфильтровано = тишина» (no empty wakes of the
|
|
957
|
+
// target). This verb is the non-waking arm: it sets the worker's OWN
|
|
958
|
+
// .ephemeral-armed (same marker the ok-outbound hook sets), so the quiet
|
|
959
|
+
// window reaps it within seconds and the drain feeds the next task — nobody
|
|
960
|
+
// is woken. Doctrine for silent finishers: «нечего отправлять → iapeer
|
|
961
|
+
// self-done вместо ответа». The unarmed idle bound (ephemeralUnarmedIdleSecs)
|
|
962
|
+
// remains the backstop for workers that do neither. On a NON-ephemeral peer
|
|
963
|
+
// the marker is inert (quiet-reap keys on wake_policy) — warn, exit 0.
|
|
964
|
+
const identity = env.PEER_IDENTITY?.trim()
|
|
965
|
+
if (!identity) {
|
|
966
|
+
errOut('self-done: PEER_IDENTITY is not set — this verb is an agent self-call from inside a session\n')
|
|
967
|
+
return 1
|
|
968
|
+
}
|
|
969
|
+
if (!parseSessionName(identity)) {
|
|
970
|
+
errOut(`self-done: invalid PEER_IDENTITY "${identity}" — expected <runtime>-<personality>\n`)
|
|
971
|
+
return 1
|
|
972
|
+
}
|
|
973
|
+
const cfg = loadLifecycleConfig(env)
|
|
974
|
+
setEphemeralArmed(cfg, identity)
|
|
975
|
+
const ephemeral = isEphemeralPeer(process.cwd())
|
|
976
|
+
out(
|
|
977
|
+
`self-done: armed ${identity} for the quiet-window reap (no one woken)` +
|
|
978
|
+
(ephemeral ? '' : ' — NOTE: this peer is not wake_policy:ephemeral, the marker is inert') +
|
|
979
|
+
'\n',
|
|
980
|
+
)
|
|
981
|
+
return 0
|
|
982
|
+
}
|
|
916
983
|
case 'self-fresh': {
|
|
917
984
|
// /new AGENT-FACING TRIGGER (TARGET redesign). Run BY the agent itself as the
|
|
918
985
|
// FINAL step of a /new graceful wind-down (the owner triggers it via a per-peer
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -79,6 +79,16 @@ export interface LifecycleConfig {
|
|
|
79
79
|
* transcript mtime is a LIVENESS proxy — "no longer writing" — not a semantic
|
|
80
80
|
* "done" signal. */
|
|
81
81
|
ephemeralQuietSecs: number
|
|
82
|
+
/** wake_policy:ephemeral — the UNARMED idle bound (seconds): an ephemeral session
|
|
83
|
+
* that never armed (finished silently / lost its arm to a daemon-restart window)
|
|
84
|
+
* is reaped after this much activity-proxy silence. Live case (scriber 10.06):
|
|
85
|
+
* a worker that ended «тихо» without its final outbound stalled its M3 FIFO for
|
|
86
|
+
* the FULL generic idleSecs (1 h) — with serial drain that blocks the whole
|
|
87
|
+
* conveyor per silent worker. Bound chosen ≫ the legitimate silent-tool case
|
|
88
|
+
* (sleep-180) and ≪ idleSecs. DOCUMENTED RISK: an ephemeral worker whose tool
|
|
89
|
+
* stays silent longer than this MID-TASK is reaped and its consumed queue item
|
|
90
|
+
* is lost — ephemeral workers must emit activity (or their reply) within it. */
|
|
91
|
+
ephemeralUnarmedIdleSecs: number
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): LifecycleConfig {
|
|
@@ -102,6 +112,7 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
|
|
|
102
112
|
crashLoopMax: num(env.IAPEER_CRASHLOOP_MAX, 3),
|
|
103
113
|
crashLoopWindowSecs: num(env.IAPEER_CRASHLOOP_WINDOW_SECS, 300),
|
|
104
114
|
ephemeralQuietSecs: num(env.IAPEER_EPHEMERAL_QUIET_SECS, 20),
|
|
115
|
+
ephemeralUnarmedIdleSecs: num(env.IAPEER_EPHEMERAL_UNARMED_IDLE_SECS, 600),
|
|
105
116
|
}
|
|
106
117
|
}
|
|
107
118
|
|
|
@@ -1140,6 +1151,30 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1140
1151
|
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
|
|
1141
1152
|
continue
|
|
1142
1153
|
}
|
|
1154
|
+
// UNARMED ephemeral idle bound (live case scriber 10.06): a worker that ended
|
|
1155
|
+
// SILENTLY (no final outbound → never armed; or its arm was lost to a CLI/
|
|
1156
|
+
// daemon-restart window) used to wait out the FULL generic idleSecs (1 h) —
|
|
1157
|
+
// and the M3 serial drain waits for the session's death, so ONE silent worker
|
|
1158
|
+
// stalled its whole conveyor. This bound is the defense-in-depth backstop:
|
|
1159
|
+
// ≫ the legitimate silent-tool case (sleep-180), ≪ idleSecs. The ШТАТНЫЙ
|
|
1160
|
+
// silent-finish path is `iapeer self-done` (arm without waking anyone —
|
|
1161
|
+
// Артур's invariant «нет пустых пробуждений» stays intact); this branch only
|
|
1162
|
+
// bounds the damage when a worker does neither. Policy reap: NO .idle-reaped
|
|
1163
|
+
// (ephemeral never resumes), NO recordDeath (the ring counts faults).
|
|
1164
|
+
if (isEphemeralPeer(s.cwd) && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
|
|
1165
|
+
killSession(sock, s.identity)
|
|
1166
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1167
|
+
removeSessionState(cfg, s.identity)
|
|
1168
|
+
out.push({
|
|
1169
|
+
identity: s.identity,
|
|
1170
|
+
action: 'reaped-ephemeral',
|
|
1171
|
+
reason: `unarmed idle ${ageSecs}s (silent-finish backstop; штатный путь — iapeer self-done)`,
|
|
1172
|
+
personality: s.personality,
|
|
1173
|
+
runtime: s.runtime,
|
|
1174
|
+
})
|
|
1175
|
+
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
|
|
1176
|
+
continue
|
|
1177
|
+
}
|
|
1143
1178
|
if (ageSecs > cfg.idleSecs) {
|
|
1144
1179
|
// THE ONLY place .idle-reaped is written: this is the one death the daemon
|
|
1145
1180
|
// INITIATES. Its presence on the next wake = the session was parked cleanly =
|
|
@@ -802,6 +802,69 @@ describe('superviseTick quiet-reap (M2 die-after-reply, real tmux)', () => {
|
|
|
802
802
|
},
|
|
803
803
|
30000,
|
|
804
804
|
)
|
|
805
|
+
|
|
806
|
+
test.if(tmuxAvailable)(
|
|
807
|
+
'UNARMED ephemeral past the unarmed idle bound → reaped-ephemeral (silent-finish backstop; live case scriber 10.06)',
|
|
808
|
+
() => {
|
|
809
|
+
const root = mkdtempSync(join(tmpdir(), 'iapeer-eu-root-'))
|
|
810
|
+
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-eu-la-'))
|
|
811
|
+
const cwd = profileCwd(false, true) // ephemeral worker profile
|
|
812
|
+
const env = {
|
|
813
|
+
...process.env,
|
|
814
|
+
IAPEER_ROOT: root,
|
|
815
|
+
IAPEER_LAUNCHAGENTS_DIR: laDir,
|
|
816
|
+
IAPEER_SOCK_DIR: join(root, 'socks'),
|
|
817
|
+
IAPEER_EPHEMERAL_UNARMED_IDLE_SECS: '30', // ≪ the 60s age below, ≫ quiet 20s
|
|
818
|
+
}
|
|
819
|
+
const cfg = loadLifecycleConfig(env)
|
|
820
|
+
const identity = 'claude-eu'
|
|
821
|
+
const sock = join(root, 'socks', 'tmux-iap-claude-eu.sock')
|
|
822
|
+
const alive = () => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity]).status === 0
|
|
823
|
+
try {
|
|
824
|
+
mkdirSync(join(root, 'socks'), { recursive: true })
|
|
825
|
+
spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '300'])
|
|
826
|
+
expect(alive()).toBe(true)
|
|
827
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
828
|
+
writeFileSync(
|
|
829
|
+
join(cfg.stateDir, `${identity}.session`),
|
|
830
|
+
JSON.stringify({ identity, runtime: 'claude', personality: 'eu', cwd, wokeAt: Date.now() - 60_000 }),
|
|
831
|
+
)
|
|
832
|
+
// NOT armed (the worker ended silently) — past the unarmed bound → policy reap
|
|
833
|
+
const o = superviseTick(cfg, { env }).find(x => x.identity === identity)
|
|
834
|
+
expect(o?.action).toBe('reaped-ephemeral')
|
|
835
|
+
expect(o?.reason).toContain('unarmed idle')
|
|
836
|
+
expect(o?.personality).toBe('eu') // M3 drain fields present → queue feeds next
|
|
837
|
+
expect(alive()).toBe(false)
|
|
838
|
+
// policy death: no resume-eligibility, no crash-loop count
|
|
839
|
+
expect(hasIdleReaped(cfg, identity)).toBe(false)
|
|
840
|
+
expect(readDeaths(cfg, identity).length).toBe(0)
|
|
841
|
+
const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
|
|
842
|
+
expect(logged).toContain('outcome=ephemeral-unarmed-bound')
|
|
843
|
+
// a NON-ephemeral peer with the same age is untouched by this bound
|
|
844
|
+
// (its session lives on ITS OWN identity-derived socket — the tick keys
|
|
845
|
+
// sockets on runtime-personality, not on the test's prior sock)
|
|
846
|
+
const plainCwd = profileCwd(false, false)
|
|
847
|
+
const eupSock = join(root, 'socks', 'tmux-iap-claude-eup.sock')
|
|
848
|
+
try {
|
|
849
|
+
writeFileSync(
|
|
850
|
+
join(cfg.stateDir, `claude-eup.session`),
|
|
851
|
+
JSON.stringify({ identity: 'claude-eup', runtime: 'claude', personality: 'eup', cwd: plainCwd, wokeAt: Date.now() - 60_000 }),
|
|
852
|
+
)
|
|
853
|
+
spawnSync('tmux', ['-S', eupSock, 'new-session', '-d', '-s', 'claude-eup', 'sleep', '300'])
|
|
854
|
+
expect(superviseTick(cfg, { env }).find(x => x.identity === 'claude-eup')?.action).toBe('alive')
|
|
855
|
+
} finally {
|
|
856
|
+
spawnSync('tmux', ['-S', eupSock, 'kill-server'], { stdio: 'ignore' })
|
|
857
|
+
rmSync(plainCwd, { recursive: true, force: true })
|
|
858
|
+
}
|
|
859
|
+
} finally {
|
|
860
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
861
|
+
rmSync(root, { recursive: true, force: true })
|
|
862
|
+
rmSync(laDir, { recursive: true, force: true })
|
|
863
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
30000,
|
|
867
|
+
)
|
|
805
868
|
})
|
|
806
869
|
|
|
807
870
|
// ─────────────────────────────────────────────────────────────────────────────
|