@agfpd/iapeer 0.4.36 → 0.4.38
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/index.ts +7 -0
- package/src/daemon/main.ts +24 -0
- package/src/lifecycle/index.ts +39 -1
- package/src/registry/index.ts +7 -0
- package/src/transport/index.ts +71 -3
package/package.json
CHANGED
package/src/daemon/index.ts
CHANGED
|
@@ -192,6 +192,8 @@ export interface CallToolDeps {
|
|
|
192
192
|
* see transport.RouteDeps.noteLiveTopic. Injected so the daemon never imports
|
|
193
193
|
* lifecycle; the production main wires makeNoteLiveTopic. */
|
|
194
194
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
195
|
+
/** В7 — confirmed-delivery stamp → the target's idle-proxy floor (see transport.RouteDeps.noteDelivered). */
|
|
196
|
+
noteDelivered?: (identity: string) => void
|
|
195
197
|
/** Busy-human-composer queue — see transport.createComposerDeliveryQueue. */
|
|
196
198
|
composerQueue?: ComposerQueueRouteDeps
|
|
197
199
|
/** H4 launchd-managed detector — see transport.RouteDeps.isLaunchdManaged (the
|
|
@@ -219,6 +221,7 @@ export async function callTool(
|
|
|
219
221
|
wake: deps.wake,
|
|
220
222
|
ephemeral: deps.ephemeral,
|
|
221
223
|
noteLiveTopic: deps.noteLiveTopic,
|
|
224
|
+
noteDelivered: deps.noteDelivered,
|
|
222
225
|
composerQueue: deps.composerQueue,
|
|
223
226
|
isLaunchdManaged: deps.isLaunchdManaged,
|
|
224
227
|
})
|
|
@@ -390,6 +393,9 @@ export interface StartDaemonOptions extends StorageOptions {
|
|
|
390
393
|
* main wires the lifecycle marker write (makeNoteLiveTopic).
|
|
391
394
|
*/
|
|
392
395
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
396
|
+
/** В7 — confirmed-delivery stamp → the target's idle-proxy floor (setLastDelivered). OFF by default;
|
|
397
|
+
* the production main wires it. */
|
|
398
|
+
noteDelivered?: (identity: string) => void
|
|
393
399
|
/**
|
|
394
400
|
* Busy-human-composer queue — async acceptance for live local TUI delivery when
|
|
395
401
|
* an attached operator has non-dim text in the target composer. OFF by default
|
|
@@ -433,6 +439,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
433
439
|
onDelivered: opts.onDelivered,
|
|
434
440
|
ephemeral: opts.ephemeral,
|
|
435
441
|
noteLiveTopic: opts.noteLiveTopic,
|
|
442
|
+
noteDelivered: opts.noteDelivered,
|
|
436
443
|
composerQueue: opts.composerQueue,
|
|
437
444
|
isLaunchdManaged: opts.isLaunchdManaged,
|
|
438
445
|
})
|
package/src/daemon/main.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
readTopic,
|
|
43
43
|
resolveWakeRuntime,
|
|
44
44
|
setEphemeralArmed,
|
|
45
|
+
setLastDelivered,
|
|
45
46
|
superviseTick,
|
|
46
47
|
wakeOrSpawn,
|
|
47
48
|
type LifecycleConfig,
|
|
@@ -222,6 +223,7 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
|
|
|
222
223
|
// flip + the shadow observer. tmux targets ignore it.
|
|
223
224
|
logDir: cfg.logDir,
|
|
224
225
|
noteLiveTopic,
|
|
226
|
+
noteDelivered: identity => setLastDelivered(cfg, identity),
|
|
225
227
|
onDelivered,
|
|
226
228
|
notifyFailed: async (job, reason) => {
|
|
227
229
|
const index = readPeersIndex({ env })
|
|
@@ -330,6 +332,9 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
330
332
|
// the wake resolver's executor discriminator then compares against the topic
|
|
331
333
|
// the session last WORKED ON, not the one it woke with.
|
|
332
334
|
noteLiveTopic,
|
|
335
|
+
// В7 — a confirmed live delivery floors the target's idle proxy (setLastDelivered), so a
|
|
336
|
+
// just-delivered message is never reaped away before its turn record lands.
|
|
337
|
+
noteDelivered: identity => setLastDelivered(cfg, identity),
|
|
333
338
|
// Busy-human-composer queue: fast queued ack, async drain, fail all pending
|
|
334
339
|
// on daemon close/update so a queued envelope is never silently lost.
|
|
335
340
|
composerQueue,
|
|
@@ -547,6 +552,25 @@ if (import.meta.main) {
|
|
|
547
552
|
}
|
|
548
553
|
process.on('SIGTERM', shutdown)
|
|
549
554
|
process.on('SIGINT', shutdown)
|
|
555
|
+
// В5 — a NON-signal crash (an unhandledRejection, or any uncaught error) would otherwise kill the
|
|
556
|
+
// daemon WITHOUT running handle.close() → failAll, so in-memory composer-queued messages (the sender
|
|
557
|
+
// already got queued:true) would vanish SILENTLY — the forbidden loss class. Run failAll on these
|
|
558
|
+
// paths too so senders are notified, then exit(1) so launchd KeepAlive respawns a clean daemon. A
|
|
559
|
+
// bounded timeout guarantees the exit even if close() hangs. (Hard SIGKILL/OOM cannot be caught — a
|
|
560
|
+
// durable on-disk composer queue would be needed for that, a deliberate follow-up.)
|
|
561
|
+
let crashing = false
|
|
562
|
+
const crashExit = (kind: string) => (errObj: unknown) => {
|
|
563
|
+
if (crashing) return
|
|
564
|
+
crashing = true
|
|
565
|
+
const detail = errObj instanceof Error ? (errObj.stack ?? errObj.message) : String(errObj)
|
|
566
|
+
process.stderr.write(`[iapeer-daemon] ${kind} — failing pending queued deliveries then exiting: ${detail}\n`)
|
|
567
|
+
const done = (): never => process.exit(1)
|
|
568
|
+
const t = setTimeout(done, 3000)
|
|
569
|
+
if (typeof t.unref === 'function') t.unref()
|
|
570
|
+
void handle.close().then(done, done)
|
|
571
|
+
}
|
|
572
|
+
process.on('uncaughtException', crashExit('uncaughtException'))
|
|
573
|
+
process.on('unhandledRejection', crashExit('unhandledRejection'))
|
|
550
574
|
await new Promise(() => {}) // launchd KeepAlive holds this process; block forever
|
|
551
575
|
}
|
|
552
576
|
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -292,6 +292,34 @@ export function clearIdleReaped(cfg: LifecycleConfig, identity: string): void {
|
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
function lastDeliveredPath(cfg: LifecycleConfig, identity: string): string {
|
|
296
|
+
return join(cfg.stateDir, `${identity}.last-delivered`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Stamp the moment a live delivery to this session was CONFIRMED (В7). superviseTick folds this into
|
|
300
|
+
* the idle proxy so a message delivered just as the session crosses idleSecs cannot be reaped out from
|
|
301
|
+
* under the just-delivered message before the runtime writes its turn record — the receiver's own
|
|
302
|
+
* process may not have appended the user-turn yet (codex socket-ack lands ok BEFORE ingest), so the
|
|
303
|
+
* transcript proxy alone would miss it and the reap would silently drop the message behind ok:true. */
|
|
304
|
+
export function setLastDelivered(cfg: LifecycleConfig, identity: string, nowMs: number = Date.now()): void {
|
|
305
|
+
mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
|
|
306
|
+
try {
|
|
307
|
+
writeFileSync(lastDeliveredPath(cfg, identity), String(nowMs), { mode: 0o600 })
|
|
308
|
+
} catch {
|
|
309
|
+
/* best-effort — the transcript proxy still governs; this only ADDS a floor, never removes one */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Epoch-ms of the last confirmed live delivery to this session, or 0 when none/unreadable. */
|
|
314
|
+
export function readLastDelivered(cfg: LifecycleConfig, identity: string): number {
|
|
315
|
+
try {
|
|
316
|
+
const n = parseInt(readFileSync(lastDeliveredPath(cfg, identity), 'utf8').trim(), 10)
|
|
317
|
+
return Number.isFinite(n) && n > 0 ? n : 0
|
|
318
|
+
} catch {
|
|
319
|
+
return 0
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
295
323
|
function newEagerPath(cfg: LifecycleConfig, identity: string): string {
|
|
296
324
|
return join(cfg.stateDir, `${identity}.new-eager`)
|
|
297
325
|
}
|
|
@@ -727,6 +755,12 @@ export async function withWakeLock<T>(
|
|
|
727
755
|
stale: 60_000,
|
|
728
756
|
update: 5_000,
|
|
729
757
|
retries: { retries: cfg.wakeLockRetries, factor: 1.3, minTimeout: 100, maxTimeout: 1_000 },
|
|
758
|
+
// В5 — a compromised wake-lock (mtime-refresh timer fired late, e.g. after system sleep >60s) MUST
|
|
759
|
+
// NOT rethrow from proper-lockfile's timer: unhandled it crashes the whole router and silently drops
|
|
760
|
+
// the in-memory composer queue. Log and continue — a controlled degrade beats killing the daemon.
|
|
761
|
+
onCompromised: (err: Error) => {
|
|
762
|
+
process.stderr.write(`[iapeer] WARN wake-lock compromised for ${identity} (continuing, not crashing): ${err.message}\n`)
|
|
763
|
+
},
|
|
730
764
|
})
|
|
731
765
|
try {
|
|
732
766
|
return await fn()
|
|
@@ -1322,7 +1356,11 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1322
1356
|
const ephemeral = isEphemeralPeer(s.cwd)
|
|
1323
1357
|
let lt = 0
|
|
1324
1358
|
try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
|
|
1325
|
-
|
|
1359
|
+
// В7 — fold in the last CONFIRMED live delivery. A message delivered just as the session crosses
|
|
1360
|
+
// idleSecs (codex acks BEFORE it writes the turn record) would otherwise be reaped out from under the
|
|
1361
|
+
// just-delivered message before lt catches up — a silent loss behind ok:true. lastDelivered only ever
|
|
1362
|
+
// RAISES the floor (a delivery is activity), so it can never keep a genuinely idle session alive.
|
|
1363
|
+
const mt = Math.max(lt, s.wokeAt, readLastDelivered(cfg, s.identity))
|
|
1326
1364
|
const ageSecs = Math.floor((nowMs - mt) / 1000)
|
|
1327
1365
|
// wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
|
|
1328
1366
|
// daemon routed its outbound reply) is reaped after a QUIET window, checked
|
package/src/registry/index.ts
CHANGED
|
@@ -297,6 +297,13 @@ export async function withPeersLock<T>(
|
|
|
297
297
|
stale: 10_000,
|
|
298
298
|
update: 1_000,
|
|
299
299
|
retries: { retries: 13, factor: 1.4, minTimeout: 50, maxTimeout: 500 },
|
|
300
|
+
// В5 — a compromised lock (e.g. the mtime-refresh timer fired late after system sleep, so the lock
|
|
301
|
+
// read as stale) MUST NOT rethrow from proper-lockfile's timer: unhandled, it would crash the whole
|
|
302
|
+
// router process and silently drop the in-memory composer queue. Log loudly and let the current
|
|
303
|
+
// (near-done) critical section finish — a controlled degrade beats taking the daemon down.
|
|
304
|
+
onCompromised: (err: Error) => {
|
|
305
|
+
process.stderr.write(`[iapeer] WARN peers-lock compromised (continuing, not crashing): ${err.message}\n`)
|
|
306
|
+
},
|
|
300
307
|
})
|
|
301
308
|
try {
|
|
302
309
|
return await fn(paths)
|
package/src/transport/index.ts
CHANGED
|
@@ -217,13 +217,37 @@ export interface WarmDeliverSeam {
|
|
|
217
217
|
* via launchd KeepAlive).
|
|
218
218
|
* See `deliverViaHost`.
|
|
219
219
|
*/
|
|
220
|
+
// В6 — per-target delivery serialization. deliverToHost is paste → 300ms settle → CR over the pty; two
|
|
221
|
+
// CONCURRENT deliveries to the SAME hosted session interleave their frames (paste2 lands inside paste1's
|
|
222
|
+
// settle window → CR1 submits the SPLICED "envelope1envelope2" as one turn, CR2 submits an empty
|
|
223
|
+
// composer; for a router target the splice breaks the mate's per-envelope framing). Chain deliveries per
|
|
224
|
+
// address so each paste+settle+CR (and its confirm) completes as a UNIT before the next to that address
|
|
225
|
+
// starts. Different addresses stay parallel. Covers the DOMINANT path — every agent send_to_peer routes
|
|
226
|
+
// through the SINGLE daemon process; a rare CLI in-process `iapeer send` is a separate process this chain
|
|
227
|
+
// does not span (fully hermetic serialization would live in the supervisor as a per-session transaction).
|
|
228
|
+
const deliverTails = new Map<string, Promise<unknown>>()
|
|
229
|
+
|
|
220
230
|
export async function deliverWarm(
|
|
221
231
|
target: DeliveryTarget,
|
|
222
232
|
envelope: string,
|
|
223
233
|
cwd?: string,
|
|
224
234
|
seam: WarmDeliverSeam = {},
|
|
225
235
|
): Promise<Result<void>> {
|
|
226
|
-
|
|
236
|
+
const address = target.address
|
|
237
|
+
const prev = deliverTails.get(address) ?? Promise.resolve()
|
|
238
|
+
// run AFTER prev settles (success or failure) — a prior delivery's error must not stall the chain
|
|
239
|
+
const run = prev.then(
|
|
240
|
+
() => deliverViaHost(target, envelope, cwd, seam),
|
|
241
|
+
() => deliverViaHost(target, envelope, cwd, seam),
|
|
242
|
+
)
|
|
243
|
+
deliverTails.set(
|
|
244
|
+
address,
|
|
245
|
+
run.then(
|
|
246
|
+
() => {},
|
|
247
|
+
() => {},
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
return run
|
|
227
251
|
}
|
|
228
252
|
|
|
229
253
|
async function deliverViaHost(
|
|
@@ -890,6 +914,10 @@ export interface RouteDeps {
|
|
|
890
914
|
* imports lifecycle (layering — main.ts is where the two meet); call sites
|
|
891
915
|
* swallow hook errors (a hook must never fail a delivered message). */
|
|
892
916
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
917
|
+
/** В7 — stamp a CONFIRMED live delivery (target identity) so superviseTick's idle proxy floors on it
|
|
918
|
+
* and cannot reap the session before the just-delivered message's turn record lands. Injected (same
|
|
919
|
+
* layering as noteLiveTopic — lifecycle.setLastDelivered wired in main.ts). */
|
|
920
|
+
noteDelivered?: (identity: string) => void
|
|
893
921
|
/** H4 launchd-managed detector (injected so transport never imports lifecycle —
|
|
894
922
|
* main.ts wires the real `isLaunchdManaged`). A MISS on a launchd-managed target is
|
|
895
923
|
* almost always a TRANSIENT restart window (router restart on connect / `iapeer
|
|
@@ -913,6 +941,16 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
|
|
|
913
941
|
}
|
|
914
942
|
}
|
|
915
943
|
|
|
944
|
+
/** Best-effort lastDelivered stamp (В7) — records a CONFIRMED live delivery so the idle-reap proxy
|
|
945
|
+
* cannot reap the session out from under a just-delivered message before its turn record lands. */
|
|
946
|
+
function noteDelivered(deps: RouteDeps, identity: string): void {
|
|
947
|
+
try {
|
|
948
|
+
deps.noteDelivered?.(identity)
|
|
949
|
+
} catch {
|
|
950
|
+
/* a post-delivery hook must never fail the delivery */
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
916
954
|
export interface ComposerDeliveryQueueOptions {
|
|
917
955
|
env?: NodeJS.ProcessEnv
|
|
918
956
|
pollMs?: number
|
|
@@ -938,6 +976,8 @@ export interface ComposerDeliveryQueueOptions {
|
|
|
938
976
|
deliver?: (target: DeliveryTarget, envelope: string, cwd?: string) => Result<void> | Promise<Result<void>>
|
|
939
977
|
notifyFailed: (job: ComposerQueuedDelivery, reason: string) => void | Promise<void>
|
|
940
978
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
979
|
+
/** В7 — stamp a confirmed queued delivery (same seam as RouteDeps.noteDelivered). */
|
|
980
|
+
noteDelivered?: (identity: string) => void
|
|
941
981
|
onDelivered?: (caller: ResolvedCaller) => void
|
|
942
982
|
}
|
|
943
983
|
|
|
@@ -1025,7 +1065,17 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
|
|
|
1025
1065
|
function kick(identity: string): void {
|
|
1026
1066
|
if (draining.has(identity)) return
|
|
1027
1067
|
draining.add(identity)
|
|
1028
|
-
void drain(identity)
|
|
1068
|
+
void drain(identity)
|
|
1069
|
+
.catch(() => {
|
|
1070
|
+
/* drain is internally best-effort; a stray throw must not surface as an unhandled rejection */
|
|
1071
|
+
})
|
|
1072
|
+
.finally(() => {
|
|
1073
|
+
draining.delete(identity)
|
|
1074
|
+
// В9 — close the enqueue-during-teardown race: a job pushed AFTER drain's empty-return but
|
|
1075
|
+
// BEFORE this finally cleared `draining` saw kick() no-op (draining still held), leaving it
|
|
1076
|
+
// undrained until some unrelated later enqueue. Re-kick if work remains so it drains promptly.
|
|
1077
|
+
if (queues.get(identity)?.length) kick(identity)
|
|
1078
|
+
})
|
|
1029
1079
|
}
|
|
1030
1080
|
|
|
1031
1081
|
async function drain(identity: string): Promise<void> {
|
|
@@ -1050,12 +1100,29 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
|
|
|
1050
1100
|
continue
|
|
1051
1101
|
}
|
|
1052
1102
|
|
|
1053
|
-
|
|
1103
|
+
// В8 — take the head OUT of the queue BEFORE committing to deliver. A concurrent failAll snapshots
|
|
1104
|
+
// only what remains queued, so an IN-FLIGHT job is never both delivered AND failed (the dup / false-
|
|
1105
|
+
// failure the exactly-once contract forbids). The head stayed queued during the composer poll-wait
|
|
1106
|
+
// above (still undelivered → failAll correctly fails it); it leaves the queue only at this commit.
|
|
1054
1107
|
q.shift()
|
|
1108
|
+
let delivered: Result<void>
|
|
1109
|
+
try {
|
|
1110
|
+
delivered = await deliver(job.target, job.envelope, job.peer.cwd)
|
|
1111
|
+
} catch (e) {
|
|
1112
|
+
// deliverWarm normally returns a Result, but a defensive catch keeps drain from throwing (which,
|
|
1113
|
+
// with the В9 re-kick, could otherwise spin) — an unexpected throw is a delivery failure.
|
|
1114
|
+
await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
1115
|
+
continue
|
|
1116
|
+
}
|
|
1055
1117
|
if (!delivered.ok) {
|
|
1056
1118
|
await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${delivered.error.message}`)
|
|
1057
1119
|
} else {
|
|
1058
1120
|
noteTopic({ noteLiveTopic: opts.noteLiveTopic }, job.target.address, job.topic)
|
|
1121
|
+
try {
|
|
1122
|
+
opts.noteDelivered?.(job.target.address) // В7 — floor the idle proxy for the queued delivery too
|
|
1123
|
+
} catch {
|
|
1124
|
+
/* best-effort */
|
|
1125
|
+
}
|
|
1059
1126
|
try {
|
|
1060
1127
|
opts.onDelivered?.(job.caller)
|
|
1061
1128
|
} catch {
|
|
@@ -1257,6 +1324,7 @@ export async function routeSend(
|
|
|
1257
1324
|
const delivered = await deliverWarm(target.value, envelope, peer.cwd)
|
|
1258
1325
|
if (!delivered.ok) return delivered
|
|
1259
1326
|
noteTopic(deps, target.value.address, topic)
|
|
1327
|
+
noteDelivered(deps, target.value.address) // В7 — floor the idle proxy so this delivery isn't reaped away
|
|
1260
1328
|
return ok({
|
|
1261
1329
|
ok: true,
|
|
1262
1330
|
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|