@agfpd/iapeer 0.2.32 → 0.2.33
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
package/src/launch/canary.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function canaryScript(o: CanaryScriptOptions): string {
|
|
|
117
117
|
`{`,
|
|
118
118
|
` echo "server-death identity=${o.identity} ts=$ts server_pid=$SPID wait_rc=$rc cause=$cause"`,
|
|
119
119
|
` echo '--- vm_stat'; vm_stat`,
|
|
120
|
-
` echo '--- swapusage'; sysctl -n vm.swapusage`,
|
|
120
|
+
` echo '--- swapusage'; /usr/sbin/sysctl -n vm.swapusage`,
|
|
121
121
|
` echo '--- ps-top-rss'; ps axo pid,ppid,rss,etime,command | sort -rn -k3 | head -25`,
|
|
122
122
|
` echo '--- diagnosticreports-user'; ls -t "$HOME/Library/Logs/DiagnosticReports" 2>/dev/null | head`,
|
|
123
123
|
` echo '--- diagnosticreports-system'; ls -t /Library/Logs/DiagnosticReports 2>/dev/null | head`,
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -654,7 +654,7 @@ export function classifyGoneSession(sock: string): { death: 'server-dead' | 'ses
|
|
|
654
654
|
// proves the server is alive and merely lost this session.
|
|
655
655
|
return tmux(sock, 'list-sessions').ok
|
|
656
656
|
? { death: 'session-gone', reason: 'session gone, tmux server alive (exit cause should be in exits.log)' }
|
|
657
|
-
: { death: 'server-dead', reason: 'tmux server dead — stale socket (SIGKILL
|
|
657
|
+
: { death: 'server-dead', reason: 'tmux server dead — stale socket (SIGKILL class — OOM only if a matching JetsamEvent report exists; check exits.log ev=server-exit canary record)' }
|
|
658
658
|
}
|
|
659
659
|
|
|
660
660
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -790,6 +790,13 @@ export interface WakeResult {
|
|
|
790
790
|
* deliberate halt, distinct from offline/wake-failure). The sender is told the
|
|
791
791
|
* peer is stopped, not that delivery failed transiently. */
|
|
792
792
|
stopped?: boolean
|
|
793
|
+
/** Was `task` actually delivered by this wake? true on the launch path (the
|
|
794
|
+
* task rode as the boot first-message); FALSE on the idempotent live-session
|
|
795
|
+
* fast path — a CONCURRENT sender's boot won the lock and only ITS envelope
|
|
796
|
+
* was delivered; this caller's task was NOT. The caller (routeSend) must then
|
|
797
|
+
* deliver the envelope itself via the live path, or the second sender's
|
|
798
|
+
* message is silently lost behind a false ok (contract forbids silent loss). */
|
|
799
|
+
taskDelivered?: boolean
|
|
793
800
|
}
|
|
794
801
|
|
|
795
802
|
export interface WakeDeps {
|
|
@@ -890,7 +897,9 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
890
897
|
// wake already brought it up) — no second spawn.
|
|
891
898
|
if (sessionAlive(sock, identity)) {
|
|
892
899
|
writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
|
|
893
|
-
|
|
900
|
+
// taskDelivered:false — the winning concurrent wake delivered ITS OWN task as
|
|
901
|
+
// the boot first-message; THIS caller's task was not delivered by anything.
|
|
902
|
+
return { status: 'READY', woke: false, runtime, process_address: identity, taskDelivered: false }
|
|
894
903
|
}
|
|
895
904
|
if (!existsSync(cwd)) {
|
|
896
905
|
return { status: 'FAILED', woke: false, runtime, reason: `peer cwd does not exist: ${cwd}` }
|
|
@@ -997,7 +1006,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
997
1006
|
// ring — a successful wake means this is NOT a tight crash loop. Best-effort.
|
|
998
1007
|
writeTopic(cfg, identity, args.topic?.trim() ?? '')
|
|
999
1008
|
trimDeaths(cfg, identity, cfg.crashLoopWindowSecs, Date.now())
|
|
1000
|
-
return { status: 'READY', woke: true, runtime, process_address: identity }
|
|
1009
|
+
return { status: 'READY', woke: true, runtime, process_address: identity, taskDelivered: true }
|
|
1001
1010
|
})
|
|
1002
1011
|
}
|
|
1003
1012
|
|
|
@@ -284,6 +284,40 @@ describe('wakeOrSpawn H4 refusal', () => {
|
|
|
284
284
|
})
|
|
285
285
|
})
|
|
286
286
|
|
|
287
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
288
|
+
// wakeOrSpawn live-session fast path — marks the caller's task as NOT delivered
|
|
289
|
+
// (the concurrent-sender envelope-loss seam: routeSend redelivers on this flag)
|
|
290
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
describe('wakeOrSpawn live-session fast path', () => {
|
|
293
|
+
test('READY with taskDelivered:false — the winning wake delivered only ITS envelope', async () => {
|
|
294
|
+
const root = mkdtempSync(join(tmpdir(), 'iapeer-fpl-root-'))
|
|
295
|
+
const laDir = mkdtempSync(join(tmpdir(), 'iapeer-fpl-la-')) // empty → not launchd-managed
|
|
296
|
+
const sockDir = join(root, 'socks')
|
|
297
|
+
const sock = join(sockDir, 'tmux-iap-claude-fpl.sock')
|
|
298
|
+
try {
|
|
299
|
+
mkdirSync(sockDir, { recursive: true })
|
|
300
|
+
await upsertPeer(
|
|
301
|
+
{ personality: 'fpl', runtime: 'claude', cwd: root, intelligence: 'artificial' },
|
|
302
|
+
{ rootDir: root },
|
|
303
|
+
)
|
|
304
|
+
// a live session already up — what a WINNING concurrent wake leaves behind
|
|
305
|
+
const up = spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', 'claude-fpl', 'cat'], { encoding: 'utf8' })
|
|
306
|
+
expect(up.status).toBe(0)
|
|
307
|
+
const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir }
|
|
308
|
+
const r = await wakeOrSpawn({ personality: 'fpl', runtime: 'claude', task: 'second sender envelope' }, { env })
|
|
309
|
+
expect(r.status).toBe('READY')
|
|
310
|
+
expect(r.woke).toBe(false)
|
|
311
|
+
// the contract under test: the fast path NEVER delivers the caller's task
|
|
312
|
+
expect(r.taskDelivered).toBe(false)
|
|
313
|
+
} finally {
|
|
314
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { encoding: 'utf8' })
|
|
315
|
+
rmSync(root, { recursive: true, force: true })
|
|
316
|
+
rmSync(laDir, { recursive: true, force: true })
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
287
321
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
288
322
|
// C1 — durable stopped flag (stop/start; daemon refuses to wake a stopped peer)
|
|
289
323
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/transport/index.ts
CHANGED
|
@@ -408,6 +408,12 @@ export interface WakeOutcome {
|
|
|
408
408
|
* refused, not failed. routeSend surfaces an explicit "stopped" error to the
|
|
409
409
|
* sender (contract Демон §stopped: stopped → no wake, no queue, clear error). */
|
|
410
410
|
stopped?: boolean
|
|
411
|
+
/** Did the wake itself deliver `task` (as the boot first-message)? An impl
|
|
412
|
+
* returns FALSE from its idempotent live-session fast path (a concurrent wake
|
|
413
|
+
* won and delivered only ITS envelope) — routeSend then delivers this caller's
|
|
414
|
+
* envelope via the live path. undefined → assumed delivered (legacy impls keep
|
|
415
|
+
* their prior behavior; the in-repo wakeOrSpawn always sets it explicitly). */
|
|
416
|
+
taskDelivered?: boolean
|
|
411
417
|
}
|
|
412
418
|
export type WakeFn = (req: WakeRequest) => Promise<WakeOutcome>
|
|
413
419
|
|
|
@@ -551,6 +557,23 @@ export async function routeSend(
|
|
|
551
557
|
return err(`woke "${personality}" but the session is not live (verify-before-act): ${live.error.message}`)
|
|
552
558
|
}
|
|
553
559
|
if (live.value.address === caller.address) return err('cannot send to self')
|
|
560
|
+
// taskDelivered:false — the wake took the idempotent live-session fast path (a
|
|
561
|
+
// CONCURRENT sender's boot won the wake.lock and delivered only ITS envelope as
|
|
562
|
+
// the boot first-message). OUR envelope was not delivered by anything yet —
|
|
563
|
+
// deliver it NOW via the live path (submit-confirm + liveness guards apply).
|
|
564
|
+
// Without this, the second concurrent sender's message is silently lost behind
|
|
565
|
+
// a false ok:true — the class the contract forbids. A delivery failure here is
|
|
566
|
+
// surfaced loudly (false-FAIL over false-OK).
|
|
567
|
+
if (woke.taskDelivered === false) {
|
|
568
|
+
const delivered = deliverViaTmux(live.value, envelope, peer.cwd)
|
|
569
|
+
if (!delivered.ok) return delivered
|
|
570
|
+
return ok({
|
|
571
|
+
ok: true,
|
|
572
|
+
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
573
|
+
woke: false, // honest: this sender's envelope went the LIVE path, not a boot
|
|
574
|
+
ts: new Date().toISOString(),
|
|
575
|
+
})
|
|
576
|
+
}
|
|
554
577
|
// The envelope was delivered as the boot first-message during wake.
|
|
555
578
|
return ok({
|
|
556
579
|
ok: true,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// routeSend wake-path delivery guarantee — the concurrent-sender fast-path hole.
|
|
2
|
+
//
|
|
3
|
+
// The race (found by code-read 09.06, vault «Находка — wake-on-miss fast-path …»):
|
|
4
|
+
// sender A's wake boots the session (A's envelope = boot first-message); sender B,
|
|
5
|
+
// blocked on wake.lock, enters AFTER the boot and takes the idempotent live-session
|
|
6
|
+
// fast path — which delivers NOTHING. Pre-fix, routeSend then reported {ok:true,
|
|
7
|
+
// woke:true} for B while B's envelope was silently lost (the class the delivery
|
|
8
|
+
// contract forbids). Post-fix, wakeOrSpawn marks the fast path taskDelivered:false
|
|
9
|
+
// and routeSend delivers B's envelope itself via the LIVE path.
|
|
10
|
+
//
|
|
11
|
+
// The fake WakeFn here reproduces the loser-of-the-lock outcome exactly: it brings
|
|
12
|
+
// the session up itself (= what the winning concurrent wake did) and returns READY
|
|
13
|
+
// without having delivered the caller's task.
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
16
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
17
|
+
import { tmpdir } from 'os'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import { spawnSync } from 'child_process'
|
|
20
|
+
import { routeSend, type WakeFn } from './index.ts'
|
|
21
|
+
import type { ResolvedCaller } from '../identity/index.ts'
|
|
22
|
+
import type { PeerRecord } from '../registry/index.ts'
|
|
23
|
+
|
|
24
|
+
let root: string
|
|
25
|
+
let sockDir: string
|
|
26
|
+
const prevRoot = process.env.IAPEER_ROOT
|
|
27
|
+
|
|
28
|
+
// Target peer: NON-TUI runtime (telegram) → the C-j delivery path lands into a
|
|
29
|
+
// plain `cat` pane with no TUI prompt machinery; capture-pane then shows the
|
|
30
|
+
// envelope text verbatim.
|
|
31
|
+
// NB: keep names SHORT — the tmux socket lives under mkdtemp and the unix
|
|
32
|
+
// socket path limit on macOS is 104 bytes.
|
|
33
|
+
const TARGET = 'fpw'
|
|
34
|
+
const SESSION = `telegram-${TARGET}`
|
|
35
|
+
const sockPath = () => join(sockDir, `tmux-iap-telegram-${TARGET}.sock`)
|
|
36
|
+
|
|
37
|
+
const callerRecord = {
|
|
38
|
+
personality: 'boris',
|
|
39
|
+
runtime: 'claude',
|
|
40
|
+
runtimes: ['claude'],
|
|
41
|
+
description: '',
|
|
42
|
+
intelligence: 'artificial',
|
|
43
|
+
cwd: '/tmp/boris',
|
|
44
|
+
} as unknown as PeerRecord
|
|
45
|
+
const caller: ResolvedCaller = {
|
|
46
|
+
personality: 'boris',
|
|
47
|
+
runtime: 'claude',
|
|
48
|
+
address: 'claude-boris',
|
|
49
|
+
description: '',
|
|
50
|
+
intelligence: 'artificial',
|
|
51
|
+
cwd: '/tmp/boris',
|
|
52
|
+
record: callerRecord,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function startTargetSession(): void {
|
|
56
|
+
const r = spawnSync('tmux', ['-S', sockPath(), 'new-session', '-d', '-x', '200', '-y', '50', '-s', SESSION, 'cat'], {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
})
|
|
59
|
+
if (r.status !== 0) throw new Error(`tmux new-session failed: ${r.stderr}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function killTargetSession(): void {
|
|
63
|
+
spawnSync('tmux', ['-S', sockPath(), 'kill-server'], { encoding: 'utf8' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function capturePane(): string {
|
|
67
|
+
const r = spawnSync('tmux', ['-S', sockPath(), 'capture-pane', '-p', '-t', SESSION], { encoding: 'utf8' })
|
|
68
|
+
return r.stdout ?? ''
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
root = mkdtempSync(join(tmpdir(), 'fpw-'))
|
|
73
|
+
sockDir = join(root, 'socks')
|
|
74
|
+
mkdirSync(sockDir, { recursive: true })
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(root, 'peers-profiles.json'),
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
version: 2,
|
|
79
|
+
peers: [
|
|
80
|
+
{ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/boris' },
|
|
81
|
+
{ personality: TARGET, runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'artificial', cwd: root },
|
|
82
|
+
],
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
process.env.IAPEER_ROOT = root
|
|
86
|
+
})
|
|
87
|
+
afterAll(() => {
|
|
88
|
+
killTargetSession()
|
|
89
|
+
if (prevRoot === undefined) delete process.env.IAPEER_ROOT
|
|
90
|
+
else process.env.IAPEER_ROOT = prevRoot
|
|
91
|
+
rmSync(root, { recursive: true, force: true })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
95
|
+
test('taskDelivered:false → routeSend delivers the envelope itself via the live path', async () => {
|
|
96
|
+
const marker = 'SECOND-SENDER-ENVELOPE-7741'
|
|
97
|
+
// The loser-of-the-lock outcome: the session is up (the WINNING wake booted it),
|
|
98
|
+
// READY came back, but THIS caller's task was never delivered.
|
|
99
|
+
const wake: WakeFn = async () => {
|
|
100
|
+
startTargetSession()
|
|
101
|
+
return { status: 'READY', woke: false, runtime: 'telegram', taskDelivered: false }
|
|
102
|
+
}
|
|
103
|
+
const r = await routeSend(caller, { personality: TARGET, message: marker }, { wake })
|
|
104
|
+
expect(r.ok).toBe(true)
|
|
105
|
+
if (!r.ok) return
|
|
106
|
+
// honest woke flag: the envelope went the LIVE path, not a boot first-message
|
|
107
|
+
expect(r.value.woke).toBe(false)
|
|
108
|
+
expect(r.value.delivered_to).toEqual({ personality: TARGET, runtime: 'telegram' })
|
|
109
|
+
// the envelope actually LANDED in the session (pre-fix: silently lost)
|
|
110
|
+
expect(capturePane()).toContain(marker)
|
|
111
|
+
killTargetSession()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('legacy WakeFn without taskDelivered → prior behavior (no redelivery, woke:true)', async () => {
|
|
115
|
+
const marker = 'LEGACY-IMPL-ENVELOPE-9962'
|
|
116
|
+
const wake: WakeFn = async () => {
|
|
117
|
+
startTargetSession()
|
|
118
|
+
return { status: 'READY', woke: true, runtime: 'telegram' } // field absent → assumed delivered
|
|
119
|
+
}
|
|
120
|
+
const r = await routeSend(caller, { personality: TARGET, message: marker }, { wake })
|
|
121
|
+
expect(r.ok).toBe(true)
|
|
122
|
+
if (!r.ok) return
|
|
123
|
+
expect(r.value.woke).toBe(true)
|
|
124
|
+
// assumed delivered as the boot first-message → routeSend must NOT double-deliver
|
|
125
|
+
expect(capturePane()).not.toContain(marker)
|
|
126
|
+
killTargetSession()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('taskDelivered:false but the session died right after wake → loud failure, not false ok', async () => {
|
|
130
|
+
const wake: WakeFn = async () => {
|
|
131
|
+
// READY without a live session: verify-before-act must catch it
|
|
132
|
+
return { status: 'READY', woke: false, runtime: 'telegram', taskDelivered: false }
|
|
133
|
+
}
|
|
134
|
+
const r = await routeSend(caller, { personality: TARGET, message: 'never lands' }, { wake })
|
|
135
|
+
expect(r.ok).toBe(false)
|
|
136
|
+
if (r.ok) return
|
|
137
|
+
expect(r.error.message).toMatch(/verify-before-act|not live/)
|
|
138
|
+
})
|
|
139
|
+
})
|