@agfpd/iapeer 0.4.34 → 0.4.36
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/lifecycle/index.ts +81 -5
- package/src/registry/index.ts +6 -5
- package/src/storage/atomicWrite.ts +60 -0
- package/src/storage/index.ts +4 -37
- package/src/storage/rotatelog.ts +0 -0
package/package.json
CHANGED
package/src/lifecycle/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// before any wake or reap. wakeOrSpawn refuses a launchd peer; superviseTick and
|
|
13
13
|
// sweepZombies skip it. Only daemon-owned (no-plist) peers are managed here.
|
|
14
14
|
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
16
16
|
import { homedir } from 'os'
|
|
17
17
|
import { join } from 'path'
|
|
18
18
|
import { spawnSync } from 'child_process'
|
|
@@ -90,6 +90,10 @@ export interface LifecycleConfig {
|
|
|
90
90
|
* A still-WORKING silent-tool worker keeps the pane-log fresh via the TUI render, so it
|
|
91
91
|
* is no longer reaped mid-task — only a genuinely idle/wedged session crosses this. */
|
|
92
92
|
ephemeralUnarmedIdleSecs: number
|
|
93
|
+
/** Wake-lock acquisition retries (proper-lockfile). The budget is intentionally MODEST (a full
|
|
94
|
+
* boot can exceed it) — an exhausted budget surfaces as a RETRYABLE FAILED, not a raw ELOCKED, so
|
|
95
|
+
* the sender retries and routeSend logs the outcome. Tunable via IAPEER_WAKE_LOCK_RETRIES. */
|
|
96
|
+
wakeLockRetries: number
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): LifecycleConfig {
|
|
@@ -113,6 +117,10 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
|
|
|
113
117
|
crashLoopWindowSecs: num(env.IAPEER_CRASHLOOP_WINDOW_SECS, 300),
|
|
114
118
|
ephemeralQuietSecs: num(env.IAPEER_EPHEMERAL_QUIET_SECS, 20),
|
|
115
119
|
ephemeralUnarmedIdleSecs: num(env.IAPEER_EPHEMERAL_UNARMED_IDLE_SECS, 600),
|
|
120
|
+
wakeLockRetries: (() => {
|
|
121
|
+
const n = parseInt(env.IAPEER_WAKE_LOCK_RETRIES ?? '', 10)
|
|
122
|
+
return Number.isFinite(n) && n >= 0 ? n : 30
|
|
123
|
+
})(),
|
|
116
124
|
}
|
|
117
125
|
}
|
|
118
126
|
|
|
@@ -171,6 +179,22 @@ export function removeSessionState(cfg: LifecycleConfig, identity: string): void
|
|
|
171
179
|
}
|
|
172
180
|
}
|
|
173
181
|
|
|
182
|
+
/** Atomically CLAIM a dead session for reaping: rename `.session` → `.session.reaping`. Returns true
|
|
183
|
+
* iff THIS caller won the rename. superviseTick runs from BOTH the daemon timer AND the prelude of
|
|
184
|
+
* every wakeOrSpawn (in whatever process called it), so two passes can see the same dead `.session`
|
|
185
|
+
* in the same sub-second window; without a claim BOTH would recordDeath, and TWO deaths for ONE real
|
|
186
|
+
* death can trip the crash-loop guard prematurely (a peer refused to wake for the whole window). The
|
|
187
|
+
* rename is atomic on a single filesystem — the loser gets ENOENT → false and skips recordDeath/eager.
|
|
188
|
+
* A leftover `.reaping` (crash mid-reap) is ignored by readSessionStates (it filters `.session`). */
|
|
189
|
+
function claimDeadSession(cfg: LifecycleConfig, identity: string): boolean {
|
|
190
|
+
try {
|
|
191
|
+
renameSync(sessionStatePath(cfg, identity), `${sessionStatePath(cfg, identity)}.reaping`)
|
|
192
|
+
return true
|
|
193
|
+
} catch {
|
|
194
|
+
return false // ENOENT — another pass already claimed this death (or the file vanished)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
174
198
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
175
199
|
// C1 — durable stopped flag (warm-on-demand stop/start; contract ЖЦ §stop/start,
|
|
176
200
|
// Демон §stopped). `stop <peer>` on a warm runtime kills the session AND drops this
|
|
@@ -702,7 +726,7 @@ export async function withWakeLock<T>(
|
|
|
702
726
|
realpath: false,
|
|
703
727
|
stale: 60_000,
|
|
704
728
|
update: 5_000,
|
|
705
|
-
retries: { retries:
|
|
729
|
+
retries: { retries: cfg.wakeLockRetries, factor: 1.3, minTimeout: 100, maxTimeout: 1_000 },
|
|
706
730
|
})
|
|
707
731
|
try {
|
|
708
732
|
return await fn()
|
|
@@ -953,7 +977,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
953
977
|
}
|
|
954
978
|
}
|
|
955
979
|
|
|
956
|
-
|
|
980
|
+
const runWake = async (): Promise<WakeResult> => {
|
|
957
981
|
// Re-check the refusal gates INSIDE the lock (audit #3/#11): a `stop` (C1 flag) or a
|
|
958
982
|
// plist install that completed AFTER the pre-lock check but before the lock was
|
|
959
983
|
// acquired must still be honored — else a concurrently stopped / launchd-claimed peer
|
|
@@ -1074,6 +1098,14 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1074
1098
|
try {
|
|
1075
1099
|
writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
|
|
1076
1100
|
} catch { /* keep the original FAILED reason — enroll is best-effort */ }
|
|
1101
|
+
} else {
|
|
1102
|
+
// В14 — the launch FAILED and left NO live remnant → a genuine boot-crash (broken binary /
|
|
1103
|
+
// doctrine, process died before READY). It writes NO .session, so superviseTick never sees it
|
|
1104
|
+
// and never records the death → countRecentDeaths stays 0 and the crash-loop guard NEVER fires.
|
|
1105
|
+
// Every next message / ephemeral-drain then re-runs the doomed launch (up to bootDeadline each)
|
|
1106
|
+
// forever. Count the fault here so the guard bounds the loop after crashLoopMax in the window.
|
|
1107
|
+
// (No claim needed — there is no .session for a concurrent pass to race on.)
|
|
1108
|
+
recordDeath(cfg, identity, Date.now())
|
|
1077
1109
|
}
|
|
1078
1110
|
return { status: 'FAILED', woke: false, runtime, reason: result.reason }
|
|
1079
1111
|
}
|
|
@@ -1102,7 +1134,29 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1102
1134
|
addTopic(cfg, identity, args.topic?.trim() ?? '')
|
|
1103
1135
|
trimDeaths(cfg, identity, cfg.crashLoopWindowSecs, Date.now())
|
|
1104
1136
|
return { status: 'READY', woke: true, runtime, process_address: identity, taskDelivered: true }
|
|
1105
|
-
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
try {
|
|
1140
|
+
return await withWakeLock(cfg, identity, runWake)
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
// В15 — the wake-lock retry budget (~25s) is shorter than a legitimate boot (bootDeadline +
|
|
1143
|
+
// readyGate, up to minutes). A concurrent send to a WAKING peer can exhaust it → proper-lockfile
|
|
1144
|
+
// throws ELOCKED, which otherwise escaped through routeSend as a raw protocol error with NO
|
|
1145
|
+
// delivery.log line (a hole in the very log that reconstructs a suspected loss). Convert it to a
|
|
1146
|
+
// RETRYABLE FAILED: the sender retries, the fast path then finds the now-live session, and routeSend
|
|
1147
|
+
// records the outcome. re-resolving each retry = verify-before-act (no false-OK).
|
|
1148
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
1149
|
+
if ((e as NodeJS.ErrnoException).code === 'ELOCKED' || /\bELOCKED\b|lock.*already.*held/i.test(msg)) {
|
|
1150
|
+
logWake({ identity, runtime, mode: 'refused', cause: 'wake-lock-held' })
|
|
1151
|
+
return {
|
|
1152
|
+
status: 'FAILED',
|
|
1153
|
+
woke: false,
|
|
1154
|
+
runtime,
|
|
1155
|
+
reason: `"${args.personality}" (${runtime}) wake already in progress (another wake holds the lock) — retry shortly`,
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
throw e
|
|
1159
|
+
}
|
|
1106
1160
|
}
|
|
1107
1161
|
|
|
1108
1162
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1200,8 +1254,15 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1200
1254
|
// A dead session: record a death for crash-loop accounting, then branch on the
|
|
1201
1255
|
// .new-eager mark. This death was NOT daemon-initiated (the daemon only initiates
|
|
1202
1256
|
// the idle-reap below) → it died on its own → do NOT write .idle-reaped here.
|
|
1257
|
+
// ATOMIC CLAIM (В16): a concurrent supervise pass (daemon timer + a CLI-process
|
|
1258
|
+
// wakeOrSpawn prelude) must not BOTH recordDeath — two deaths for one real death can
|
|
1259
|
+
// trip the crash-loop guard prematurely. The rename-claim makes recordDeath + eager
|
|
1260
|
+
// happen exactly once; the loser reports a benign concurrently-reaped outcome.
|
|
1261
|
+
if (!claimDeadSession(cfg, s.identity)) {
|
|
1262
|
+
return { identity: s.identity, action: 'reaped-gone', reason: 'concurrently reaped by another supervise pass' }
|
|
1263
|
+
}
|
|
1203
1264
|
recordDeath(cfg, s.identity, nowMs)
|
|
1204
|
-
|
|
1265
|
+
rmSync(`${sessionStatePath(cfg, s.identity)}.reaping`, { force: true }) // claim token consumed
|
|
1205
1266
|
// The .ephemeral-armed mark belongs to THIS (now dead) session — it armed on its
|
|
1206
1267
|
// outbound reply. Clear it with the session, so a successor session can never be
|
|
1207
1268
|
// quiet-reaped on a stale mark before answering its own task. No-op otherwise.
|
|
@@ -1229,6 +1290,21 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1229
1290
|
trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
|
|
1230
1291
|
return { identity: s.identity, action: 'reaped-gone', reason: gone.reason }
|
|
1231
1292
|
}
|
|
1293
|
+
// В17 — a LIVE session carrying the .stopped flag is an illegitimate state: `stop` takes no wake-lock,
|
|
1294
|
+
// so a stop that raced an in-flight wake killed the pre-pidfile session as a no-op and the wake then
|
|
1295
|
+
// drove a NEW session to READY → alive + .stopped. The live path (routeSend) does not re-check
|
|
1296
|
+
// .stopped, so this peer keeps accepting messages until idle-reap (up to 1h) — the operator halt
|
|
1297
|
+
// silently failed. Reap it now (catch-up for the window the wake-lock cannot cover). H4 is already
|
|
1298
|
+
// cleared above; no legitimate alive+stopped state exists. Park like the stop verb (.idle-reaped is
|
|
1299
|
+
// idempotent with it) — .stopped keeps the peer down until `start`, .idle-reaped makes it resume-clean.
|
|
1300
|
+
if (isStopped(cfg, s.identity)) {
|
|
1301
|
+
kill(sock, s.identity)
|
|
1302
|
+
setIdleReaped(cfg, s.identity)
|
|
1303
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1304
|
+
removeSessionState(cfg, s.identity)
|
|
1305
|
+
trace({ identity: s.identity, action: 'reaped-idle', reason: 'alive-but-stopped', outcome: 'operator-halt-enforced' })
|
|
1306
|
+
return { identity: s.identity, action: 'reaped-idle', reason: 'live session carried .stopped — operator halt enforced (stop-vs-wake race)' }
|
|
1307
|
+
}
|
|
1232
1308
|
// Idle accounting via the LAST MEANINGFUL TRANSCRIPT ENTRY's content-timestamp (adapter.lastTurnMtime)
|
|
1233
1309
|
// — the only RELIABLE idle signal. The two raw mtimes both report FALSE freshness for an idle session:
|
|
1234
1310
|
// • pane-log mtime — a statusline / footer re-render writes pty bytes at idle (the clock/usage/ctx%
|
package/src/registry/index.ts
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
// is no unlocked path to the registry file anywhere in the package.
|
|
10
10
|
|
|
11
11
|
import * as lockfile from 'proper-lockfile'
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
13
13
|
import { homedir } from 'os'
|
|
14
14
|
import { join } from 'path'
|
|
15
|
-
import {
|
|
15
|
+
import { writeFileAtomicRaw } from '../storage/atomicWrite.ts'
|
|
16
16
|
import {
|
|
17
17
|
IAPEER_DIR,
|
|
18
18
|
MAX_DESCRIPTION_LEN,
|
|
@@ -313,7 +313,6 @@ export async function withPeersLock<T>(
|
|
|
313
313
|
* sanctioned write path for the registry file (#3).
|
|
314
314
|
*/
|
|
315
315
|
function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
|
|
316
|
-
const tmp = join(paths.tmpDir, `.${PEERS_PROFILES_FILE}.${process.pid}.${randomUUID()}.tmp`)
|
|
317
316
|
// Persist the RAW intelligence verbatim (legacy-safe round-trip) and DROP the
|
|
318
317
|
// intelligenceRaw shadow field from the on-disk shape — the file carries the
|
|
319
318
|
// legacy vocab the live IAP reads, never the foundation's in-memory normalization.
|
|
@@ -333,8 +332,10 @@ function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
|
|
|
333
332
|
...(peer.interfaces ? { interfaces: peer.interfaces } : {}),
|
|
334
333
|
}))
|
|
335
334
|
const normalized = { version: PEERS_SCHEMA_VERSION, peers: peersForDisk }
|
|
336
|
-
|
|
337
|
-
|
|
335
|
+
// Route through the SHARED atomic-write core (fsync-before-rename + looped writeSync) — the private
|
|
336
|
+
// registry writer no longer keeps its own copy that drifted off the durability protocol. tmpDir is
|
|
337
|
+
// paths.tmpDir (same filesystem as peersFile → the rename stays atomic).
|
|
338
|
+
writeFileAtomicRaw(paths.peersFile, `${JSON.stringify(normalized, null, 2)}\n`, 0o600, paths.tmpDir)
|
|
338
339
|
}
|
|
339
340
|
|
|
340
341
|
export async function updatePeersIndex(
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The guard-FREE atomic-write CORE — the single durability implementation shared by
|
|
2
|
+
// storage.writeFileAtomic (public, basename-guarded) and registry's private
|
|
3
|
+
// peers-profiles writer (which bypasses that guard legitimately: it is the sole writer
|
|
4
|
+
// under withPeersLock). Factored out because the registry kept its OWN copy of the
|
|
5
|
+
// atomic-rename, and that copy DRIFTED — it lost the fsync the storage primitive has,
|
|
6
|
+
// re-opening the power-loss hole on the most critical file of the host (the fleet
|
|
7
|
+
// registry). One implementation → the durability protocol can never drift again.
|
|
8
|
+
|
|
9
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeSync } from 'node:fs'
|
|
10
|
+
import { basename, dirname, join } from 'node:path'
|
|
11
|
+
import { randomUUID } from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
const DIR_MODE = 0o700
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Atomically write `data` to `target`:
|
|
17
|
+
* 1. write to a unique tmp in `tmpDir` (default: the target's directory — which MUST be the same
|
|
18
|
+
* filesystem as `target`, or the rename is not atomic),
|
|
19
|
+
* 2. fsync the tmp BEFORE the rename — rename is atomic on one fs, but without flushing the bytes
|
|
20
|
+
* first a power-loss can publish a zero-length / partial file,
|
|
21
|
+
* 3. rename the tmp over `target`.
|
|
22
|
+
*
|
|
23
|
+
* writeSync is LOOPED over a Buffer: a single writeSync can write FEWER bytes than requested (on
|
|
24
|
+
* ENOSPC / a signal, write(2) returns a partial count WITHOUT throwing), which would otherwise publish
|
|
25
|
+
* a truncated file — the exact failure the atomic primitive exists to prevent. The tmp is always
|
|
26
|
+
* cleaned up on failure so a partial write never leaks.
|
|
27
|
+
*/
|
|
28
|
+
export function writeFileAtomicRaw(
|
|
29
|
+
target: string,
|
|
30
|
+
data: string,
|
|
31
|
+
mode: number,
|
|
32
|
+
tmpDir: string = dirname(target),
|
|
33
|
+
): void {
|
|
34
|
+
mkdirSync(dirname(target), { recursive: true, mode: DIR_MODE })
|
|
35
|
+
if (tmpDir !== dirname(target)) mkdirSync(tmpDir, { recursive: true, mode: DIR_MODE })
|
|
36
|
+
const tmp = join(tmpDir, `.${basename(target)}.${process.pid}.${randomUUID()}.tmp`)
|
|
37
|
+
try {
|
|
38
|
+
const fd = openSync(tmp, 'w', mode)
|
|
39
|
+
try {
|
|
40
|
+
const buf = Buffer.from(data, 'utf8')
|
|
41
|
+
let written = 0
|
|
42
|
+
while (written < buf.length) {
|
|
43
|
+
const n = writeSync(fd, buf, written, buf.length - written)
|
|
44
|
+
if (n <= 0) throw new Error(`writeSync made no progress writing ${target} (${written}/${buf.length} bytes)`)
|
|
45
|
+
written += n
|
|
46
|
+
}
|
|
47
|
+
fsyncSync(fd)
|
|
48
|
+
} finally {
|
|
49
|
+
closeSync(fd)
|
|
50
|
+
}
|
|
51
|
+
renameSync(tmp, target)
|
|
52
|
+
} catch (e) {
|
|
53
|
+
try {
|
|
54
|
+
if (existsSync(tmp)) unlinkSync(tmp)
|
|
55
|
+
} catch {
|
|
56
|
+
/* best-effort cleanup */
|
|
57
|
+
}
|
|
58
|
+
throw e
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/storage/index.ts
CHANGED
|
@@ -10,21 +10,10 @@
|
|
|
10
10
|
// reaching for the generic atomic-write primitive. The registry keeps its own
|
|
11
11
|
// private writer (see registry/index.ts) used only inside withPeersLock.
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
closeSync,
|
|
15
|
-
existsSync,
|
|
16
|
-
fsyncSync,
|
|
17
|
-
mkdirSync,
|
|
18
|
-
openSync,
|
|
19
|
-
readFileSync,
|
|
20
|
-
readdirSync,
|
|
21
|
-
renameSync,
|
|
22
|
-
unlinkSync,
|
|
23
|
-
writeSync,
|
|
24
|
-
} from 'fs'
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'fs'
|
|
25
14
|
import { homedir } from 'os'
|
|
26
15
|
import { basename, dirname, join } from 'path'
|
|
27
|
-
import {
|
|
16
|
+
import { writeFileAtomicRaw } from './atomicWrite.ts'
|
|
28
17
|
import {
|
|
29
18
|
CACHE_DIR,
|
|
30
19
|
IAPEER_DIR,
|
|
@@ -304,28 +293,6 @@ export function writeFileAtomic(path: string, data: string, mode: number = FILE_
|
|
|
304
293
|
`refusing to write ${PEERS_PROFILES_FILE} via storage.writeFileAtomic — it is the registry's locked write target; use registry.upsertPeer/removePeer/renamePeer`,
|
|
305
294
|
)
|
|
306
295
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const tmp = join(dir, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`)
|
|
310
|
-
try {
|
|
311
|
-
// fsync the tmp file BEFORE the rename: rename is atomic on a single fs, but
|
|
312
|
-
// without flushing the bytes first a power-loss can publish a zero-length /
|
|
313
|
-
// partial shared file (codex config.toml, .mcp.json, router.json, plists).
|
|
314
|
-
const fd = openSync(tmp, 'w', mode)
|
|
315
|
-
try {
|
|
316
|
-
writeSync(fd, data)
|
|
317
|
-
fsyncSync(fd)
|
|
318
|
-
} finally {
|
|
319
|
-
closeSync(fd)
|
|
320
|
-
}
|
|
321
|
-
renameSync(tmp, path)
|
|
322
|
-
} catch (e) {
|
|
323
|
-
// Never leak the tmp file when the write/rename fails (#audit).
|
|
324
|
-
try {
|
|
325
|
-
if (existsSync(tmp)) unlinkSync(tmp)
|
|
326
|
-
} catch {
|
|
327
|
-
/* best-effort cleanup */
|
|
328
|
-
}
|
|
329
|
-
throw e
|
|
330
|
-
}
|
|
296
|
+
// The durability core (fsync-before-rename + looped writeSync) lives in ONE place — see atomicWrite.ts.
|
|
297
|
+
writeFileAtomicRaw(path, data, mode)
|
|
331
298
|
}
|
package/src/storage/rotatelog.ts
CHANGED
|
Binary file
|