@agfpd/iapeer 0.4.43 → 0.4.44

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.43",
3
+ "version": "0.4.44",
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",
@@ -6,7 +6,7 @@
6
6
  // wipe description without an explicit new value). Path helpers moved to storage.
7
7
 
8
8
  import { basename, join, resolve } from 'path'
9
- import { existsSync, readFileSync, realpathSync } from 'fs'
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmdirSync, statSync } from 'fs'
10
10
  import {
11
11
  IAPEER_DIR,
12
12
  PEER_PROFILE_FILE,
@@ -251,10 +251,64 @@ export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null
251
251
  * The profile file (basename peer-profile.json) is allowed through
252
252
  * storage.writeFileAtomic; only peers-profiles.json is guarded there.
253
253
  */
254
+ const PROFILE_LOCK_WAIT_MS = 2000
255
+ const PROFILE_LOCK_STALE_MS = 10_000
256
+
257
+ /** В31 — serialize the read-merge-write of a peer's local profile so concurrent FOUNDATION writers
258
+ * (verify --fix, the `default-runtime` verb, ensurePeerProfile at re-provision) don't lose each other's
259
+ * owner sections via a read-modify-write race. A sync mkdir-lock: writePeerProfileAtomic is sync and off
260
+ * the delivery hot path (rare operator calls). Best-effort — a stale lock is reclaimed, and on timeout it
261
+ * proceeds WITHOUT the lock so it can never deadlock. CROSS-PACKAGE CAVEAT: telegram-runtime writes this
262
+ * file with its OWN code and does not take this lock; full safety needs it to adopt the same lock (a
263
+ * separate cross-package contract — this closes the foundation-vs-foundation window). */
264
+ function withProfileLockSync<T>(cwd: string, fn: () => T): T {
265
+ const lockDir = join(cwd, '.iapeer', '.peer-profile.lock')
266
+ const deadline = Date.now() + PROFILE_LOCK_WAIT_MS
267
+ let held = false
268
+ for (;;) {
269
+ try {
270
+ mkdirSync(lockDir)
271
+ held = true
272
+ break
273
+ } catch (e) {
274
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') break // fs issue → proceed unlocked (best-effort)
275
+ try {
276
+ if (Date.now() - statSync(lockDir).mtimeMs > PROFILE_LOCK_STALE_MS) {
277
+ rmdirSync(lockDir)
278
+ continue // reclaimed a crashed writer's stale lock → retry immediately
279
+ }
280
+ } catch {
281
+ /* the lock vanished between checks — retry */
282
+ }
283
+ if (Date.now() > deadline) break // give up waiting → proceed unlocked (never deadlock)
284
+ try {
285
+ ;(globalThis as { Bun?: { sleepSync(ms: number): void } }).Bun?.sleepSync(15)
286
+ } catch {
287
+ /* */
288
+ }
289
+ }
290
+ }
291
+ try {
292
+ return fn()
293
+ } finally {
294
+ if (held)
295
+ try {
296
+ rmdirSync(lockDir)
297
+ } catch {
298
+ /* */
299
+ }
300
+ }
301
+ }
302
+
254
303
  export function writePeerProfileAtomic(cwd: string, profile: PeerProfileWrite): void {
255
304
  ensureLocalIapScaffold(cwd)
256
305
  const path = peerProfilePath(cwd)
306
+ // В31 — the ENTIRE read-merge-write runs under the per-cwd lock so a concurrent foundation writer's
307
+ // owner sections are never clobbered by a stale-read merge.
308
+ withProfileLockSync(cwd, () => writePeerProfileMerged(path, profile))
309
+ }
257
310
 
311
+ function writePeerProfileMerged(path: string, profile: PeerProfileWrite): void {
258
312
  let existing: Record<string, unknown> = {}
259
313
  try {
260
314
  const parsed = JSON.parse(readFileSync(path, 'utf8'))
@@ -490,7 +490,13 @@ export async function upsertPeer(
490
490
 
491
491
  const record: PeerRecord = {
492
492
  personality: args.personality,
493
- runtime, // args.runtime wins — caller knows the current runtime
493
+ // В34 — default_runtime is an H1 field: it INHERITS from existing, it does NOT flip to args.runtime.
494
+ // A re-provision / `init --runtime codex` on an existing claude-default peer ADDS codex to runtimes
495
+ // (below) but must NOT silently make codex the default — that mis-wakes the peer on the wrong
496
+ // runtime until the next reindex heals it (which never runs when addRuntime returns 'already'). The
497
+ // default changes ONLY via the explicit `default-runtime` verb (writes the local profile +
498
+ // reindexFromLocals). A brand-new peer (no existing) naturally takes args.runtime as its default.
499
+ runtime: existing ? existing.runtime : runtime,
494
500
  runtimes,
495
501
  description,
496
502
  intelligence,