@agfpd/iapeer 0.4.48 → 0.4.50

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.4.48",
3
+ "version": "0.4.50",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -4,7 +4,7 @@
4
4
  // via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
5
5
  import { writeSync } from 'node:fs'
6
6
  import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
7
- import { pidStartToken, readPidFile, sockPath } from './paths.ts'
7
+ import { ownershipVerdict, pidStartToken, readPidFile, sockPath } from './paths.ts'
8
8
 
9
9
  const DETACH = 0x1d // Ctrl-]
10
10
 
@@ -32,8 +32,11 @@ export function sessionAlive(runDir: string, session: string): boolean {
32
32
  const now = Date.now()
33
33
  const cached = ownerCache.get(key)
34
34
  if (cached && cached.pid === rec.pid && cached.token === rec.token && now < cached.until) return cached.verdict
35
+ // Fail-SAFE on a token-READ failure (split-brain live incident 03.07): a transient `ps` spawn error
36
+ // returns null; treating that as "dead" let one hiccup reap a LIVE session and spawn a duplicate per
37
+ // message. kill-0 above already proved the pid live — only a PROVEN mismatch (a reused pid) reads dead.
35
38
  const live = pidStartToken(rec.pid)
36
- const verdict = live !== null && live === rec.token // same process instance, not a reused pid
39
+ const verdict = ownershipVerdict(live, rec.token) // same process instance unless proven otherwise
37
40
  ownerCache.set(key, { pid: rec.pid, token: rec.token, verdict, until: now + OWNER_TTL_MS })
38
41
  return verdict
39
42
  }
@@ -8,7 +8,7 @@ import { spawn as nodeSpawn } from 'node:child_process'
8
8
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { runSupervisorClient, sessionAlive } from './client.ts'
11
- import { defaultRunDir, logPath, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
11
+ import { defaultRunDir, logPath, ownershipVerdict, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
12
12
 
13
13
  // daemon.ts loads @xterm. It is dynamic-imported ONLY in the 'daemon' CLI case below (the detached
14
14
  // daemon process, where @xterm belongs), so importing THIS module — e.g. launch resolving
@@ -66,6 +66,46 @@ export interface StartDaemonResult {
66
66
  detail?: string
67
67
  }
68
68
 
69
+ /** Does a unix socket at `path` ACCEPT connections right now? A dead leftover sock file refuses
70
+ * instantly (ECONNREFUSED → false); the timeout only bounds a pathologically wedged listener. */
71
+ async function sockAccepts(path: string, timeoutMs = 400): Promise<boolean> {
72
+ return await new Promise<boolean>(resolve => {
73
+ let done = false
74
+ const finish = (v: boolean): void => {
75
+ if (done) return
76
+ done = true
77
+ clearTimeout(timer)
78
+ resolve(v)
79
+ }
80
+ const timer = setTimeout(() => finish(false), timeoutMs)
81
+ Bun.connect({
82
+ unix: path,
83
+ socket: {
84
+ open(s) {
85
+ // finish BEFORE end(): under Bun, end() re-enters close() SYNCHRONOUSLY, and a
86
+ // close()-first would read an accepting listener as dead (verified by probe).
87
+ finish(true)
88
+ try {
89
+ s.end()
90
+ } catch {
91
+ /* probe only */
92
+ }
93
+ },
94
+ data() {},
95
+ close() {
96
+ finish(false) // closed before open fired → not accepting
97
+ },
98
+ error() {
99
+ finish(false)
100
+ },
101
+ connectError() {
102
+ finish(false) // dead leftover sock file → connect refused
103
+ },
104
+ },
105
+ }).catch(() => finish(false))
106
+ })
107
+ }
108
+
69
109
  /**
70
110
  * Re-invoke THIS module as the detached daemon (spawn-flip Ф1 serving-wiring). From SOURCE (bun) the
71
111
  * module is a real file on disk → run it directly (`bun <self> daemon …` → import.meta.main →
@@ -93,6 +133,15 @@ export async function startSupervisorDaemon(opts: StartDaemonOptions): Promise<S
93
133
  const { session, runtime, runDir } = opts
94
134
  if (sessionAlive(runDir, session)) return { state: 'already-running' }
95
135
  mkdirSync(runDir, { recursive: true })
136
+ // Second-instance guard (В28-class, peer-session form — split-brain live incident 03.07): NEVER unlink
137
+ // a sock that still ACCEPTS connections. sessionAlive above keys on the pidfile, which a transient
138
+ // token-read failure or a clobbered pidfile can falsify — but a live supervisor HOLDS its unix socket
139
+ // open, so a successful connect is proof a session exists. Spawning here would create a duplicate
140
+ // session (split-brain: two agents answering as one peer) and orphan the live one by overwriting its
141
+ // pidfile. The probe client sends no FRAME_RESIZE, so it never counts as an attached operator (В23).
142
+ if (existsSync(sockPath(runDir, session)) && (await sockAccepts(sockPath(runDir, session)))) {
143
+ return { state: 'already-running' }
144
+ }
96
145
  try {
97
146
  if (existsSync(sockPath(runDir, session))) unlinkSync(sockPath(runDir, session))
98
147
  } catch {
@@ -174,9 +223,10 @@ export function killSession(runDir: string, session: string): boolean {
174
223
  // В22 — verify the pid is STILL our daemon before SIGTERM. After an abnormal death + pid reuse, a bare
175
224
  // kill here would SIGTERM the INNOCENT process that inherited the pid. Skip the kill when the start-token
176
225
  // no longer matches (a legacy tokenless pidfile keeps the old behavior — no regression during rollout).
177
- if (rec.token) {
178
- const live = pidStartToken(rec.pid)
179
- if (live !== null && live !== rec.token) return false // reused pid — NOT our session; do not signal it
226
+ // Same ownershipVerdict primitive as sessionAlive: refuse ONLY on a PROVEN mismatch (null = unreadable
227
+ // token, still ours — kill must stay possible when `ps` hiccups, or a stuck session becomes unstoppable).
228
+ if (rec.token && !ownershipVerdict(pidStartToken(rec.pid), rec.token)) {
229
+ return false // reused pid — NOT our session; do not signal it
180
230
  }
181
231
  try {
182
232
  process.kill(rec.pid, 'SIGTERM')
@@ -24,11 +24,22 @@ export const pidPath = (runDir: string, session: string): string => join(runDir,
24
24
 
25
25
  /** The OS-reported start time of `pid` (a stable per-process-instance token), or null if it is not
26
26
  * running / unreadable. `ps -o lstart=` is a single spawn — callers bound its frequency (see the
27
- * liveness cache in client.ts). */
27
+ * liveness cache in client.ts).
28
+ *
29
+ * LC_ALL=C is PINNED (split-brain live incident 03.07): `lstart` output is LOCALE-DEPENDENT, and the
30
+ * token is compared as a STRING between processes with different env — the writer (a supervisor
31
+ * inheriting the spawning terminal's locale, e.g. an operator's ru_RU `iapeer attach`) rendered
32
+ * «пятница, 3 июля …» while the verifying daemon (launchd env, C locale) rendered "Fri Jul 3 …"
33
+ * for the SAME start time → a PROVEN-looking mismatch → the live session read as a reused pid,
34
+ * got reaped, and the next message spawned a duplicate. Pinning the locale on BOTH the write and
35
+ * the verify path makes the token format process-env-independent. */
28
36
  export function pidStartToken(pid: number): string | null {
29
37
  if (!Number.isInteger(pid) || pid <= 0) return null
30
38
  try {
31
- const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' })
39
+ const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
40
+ encoding: 'utf8',
41
+ env: { ...process.env, LC_ALL: 'C' },
42
+ })
32
43
  const t = (r.stdout ?? '').trim()
33
44
  return t.length > 0 ? t : null
34
45
  } catch {
@@ -36,6 +47,20 @@ export function pidStartToken(pid: number): string | null {
36
47
  }
37
48
  }
38
49
 
50
+ /**
51
+ * Ownership verdict from a live-read token vs the recorded one — the ONE semantics both liveness
52
+ * (`sessionAlive`) and kill (`killSession`) key on: the pid is ours unless a PROVEN mismatch.
53
+ * `live === null` means the token could not be READ (a transient `ps` spawn failure — EAGAIN under a
54
+ * post-reboot fork-storm, live incident 03.07: split-brain boris), NOT that the pid was reused; kill-0
55
+ * has already proven a live pid, so an unverifiable token must stay fail-SAFE (alive / still ours).
56
+ * Before this primitive, sessionAlive required `live !== null && live === token` (fail-open: a tool
57
+ * hiccup read a LIVE supervisor as dead → every wake spawned a duplicate session and orphaned the
58
+ * previous one by overwriting the pidfile), while killSession already treated null as "ours".
59
+ */
60
+ export function ownershipVerdict(live: string | null, recorded: string): boolean {
61
+ return live === null ? true : live === recorded
62
+ }
63
+
39
64
  /** Write the pidfile as `<pid> <startToken>` (В22). Falls back to bare `<pid>` if the token is
40
65
  * unavailable — a tokenless pidfile is treated as legacy (owner-check skipped, kill-0 only). */
41
66
  export function writePidFile(runDir: string, session: string, pid: number): void {