@agfpd/iapeer 0.4.41 → 0.4.43
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
|
@@ -201,7 +201,17 @@ export function migrateProfileRuntimeField(cwd: string): boolean {
|
|
|
201
201
|
* profile. Used by verify (compare against the live index) and reindex (rewrite it).
|
|
202
202
|
*/
|
|
203
203
|
export function projectProfileToRecord(cwd: string): PeerRecord | null {
|
|
204
|
-
|
|
204
|
+
// В32 — a CORRUPT local profile (invalid JSON / invalid stored field) makes readPeerProfile THROW.
|
|
205
|
+
// Unguarded, one bad profile aborts the whole reconcile/reindex map → verify crashes for all ~30 peers
|
|
206
|
+
// (with a message that does not even name the peer), and verify --fix never reaches reindex. Treat a
|
|
207
|
+
// corrupt profile like a missing one: reconcileIndex reports null drift, and reindexFromLocals PRESERVES
|
|
208
|
+
// the existing registry record (its `if (!projected) return rec`), so nothing is crashed or removed.
|
|
209
|
+
let profile
|
|
210
|
+
try {
|
|
211
|
+
profile = readPeerProfile(cwd)
|
|
212
|
+
} catch {
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
205
215
|
if (!profile) return null
|
|
206
216
|
return {
|
|
207
217
|
personality: profile.personality,
|
|
@@ -345,6 +345,55 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
345
345
|
return best.path ? lastTimestampedEntryMs(best.path) : null
|
|
346
346
|
},
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* В58 — newest FILE mtime among the session's CHILD workflow/subagent transcripts, or null when there
|
|
350
|
+
* are none. Claude writes them under `~/.claude/projects/<slug>/<session-uuid>/subagents/**\/agent-*.jsonl`
|
|
351
|
+
* (a plain subagent) and `<session-uuid>/subagents/workflows/<wf>/agent-*.jsonl` (a workflow subagent),
|
|
352
|
+
* appending continuously while the child runs — so a fresh mtime here means the peer is actively
|
|
353
|
+
* driving a workflow even though it makes no turns of its OWN (lastTurnMtime is stale). superviseTick
|
|
354
|
+
* folds this into the idle proxy so a peer on a long (>1h) workflow is not idle-reaped mid-work. Bounded
|
|
355
|
+
* recursive walk; a super-fresh file (< 60s) short-circuits so the common active-workflow case is cheap.
|
|
356
|
+
*/
|
|
357
|
+
childActivityMtime(cwd: string): number | null {
|
|
358
|
+
const root = transcriptDir(cwd)
|
|
359
|
+
let sessions: string[]
|
|
360
|
+
try {
|
|
361
|
+
sessions = readdirSync(root)
|
|
362
|
+
} catch {
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
let newest = 0
|
|
366
|
+
const freshFloor = Date.now() - 60_000 // any file newer than this is "definitely active" → stop early
|
|
367
|
+
const walk = (dir: string, depth: number): boolean => {
|
|
368
|
+
if (depth > 4) return false // <session>/subagents/workflows/<wf>/agent-*.jsonl — bounded
|
|
369
|
+
let items: import('fs').Dirent[]
|
|
370
|
+
try {
|
|
371
|
+
items = readdirSync(dir, { withFileTypes: true })
|
|
372
|
+
} catch {
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
375
|
+
for (const it of items) {
|
|
376
|
+
const p = join(dir, it.name)
|
|
377
|
+
if (it.isDirectory()) {
|
|
378
|
+
if (walk(p, depth + 1)) return true
|
|
379
|
+
} else if (it.isFile() && it.name.endsWith('.jsonl')) {
|
|
380
|
+
try {
|
|
381
|
+
const mt = statSync(p).mtimeMs
|
|
382
|
+
if (mt > newest) newest = mt
|
|
383
|
+
if (mt > freshFloor) return true // active work found — no need to scan the rest
|
|
384
|
+
} catch {
|
|
385
|
+
/* race — entry vanished */
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return false
|
|
390
|
+
}
|
|
391
|
+
for (const s of sessions) {
|
|
392
|
+
if (walk(join(root, s, 'subagents'), 0)) break
|
|
393
|
+
}
|
|
394
|
+
return newest > 0 ? newest : null
|
|
395
|
+
},
|
|
396
|
+
|
|
348
397
|
/**
|
|
349
398
|
* Resume preflight (fail-loud — never a silent fresh fallback): resolve the
|
|
350
399
|
* newest transcript uuid for cwd via the claude-slug dir scan (lifecycle.
|
package/src/launch/types.ts
CHANGED
|
@@ -314,6 +314,13 @@ export interface RuntimeAdapter {
|
|
|
314
314
|
* superviseTick uses THIS for idle/quiet age. null for a router (no transcript). */
|
|
315
315
|
lastTurnMtime(cwd: string): number | null
|
|
316
316
|
|
|
317
|
+
/** В58 — newest FILE mtime among the session's CHILD workflow/subagent transcripts (a running workflow
|
|
318
|
+
* writes them continuously). A peer that launched a long workflow (>1h) makes NO turns of its own, so
|
|
319
|
+
* lastTurnMtime is stale and the idle-reap would cut the busy session mid-work. superviseTick folds
|
|
320
|
+
* THIS into the idle proxy so live child work keeps the session alive. Optional — null / undefined for
|
|
321
|
+
* runtimes without a subagent layout (routers, codex). */
|
|
322
|
+
childActivityMtime?(cwd: string): number | null
|
|
323
|
+
|
|
317
324
|
/** Resume preflight — validate a resume request fail-loud (never silent fresh).
|
|
318
325
|
* Returns the resolved ref (claude uuid) or ok:false with a reason. */
|
|
319
326
|
resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string }
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -1237,6 +1237,9 @@ export interface SuperviseDeps {
|
|
|
1237
1237
|
* idle) and NOT the pane-log mtime (a statusline re-render ticks it at idle); both falsely keep an
|
|
1238
1238
|
* idle session young. Tests inject a controlled value to exercise the idle/quiet age hermetically. */
|
|
1239
1239
|
lastTurnMtime?: (runtime: string, cwd: string) => number | null
|
|
1240
|
+
/** В58 — child workflow/subagent activity seam (default: the adapter's childActivityMtime). Folded into
|
|
1241
|
+
* the idle proxy so a peer driving a long workflow (no turns of its own) is not reaped mid-work. */
|
|
1242
|
+
childActivityMtime?: (runtime: string, cwd: string) => number | null
|
|
1240
1243
|
/** Liveness seam (default: the real `sessionAlive` = pty-host pid-file check). Tests inject a fixed
|
|
1241
1244
|
* verdict so the idle path is exercised hermetically — no live pty session needed. */
|
|
1242
1245
|
sessionAlive?: (sock: string, identity: string) => boolean
|
|
@@ -1251,6 +1254,7 @@ interface SuperviseCtx {
|
|
|
1251
1254
|
env: NodeJS.ProcessEnv
|
|
1252
1255
|
nowMs: number
|
|
1253
1256
|
lastTurnMt: (runtime: string, cwd: string) => number | null
|
|
1257
|
+
childActivityMt: (runtime: string, cwd: string) => number | null
|
|
1254
1258
|
isAlive: (sock: string, identity: string) => boolean
|
|
1255
1259
|
kill: (sock: string, identity: string) => void
|
|
1256
1260
|
verbose: boolean
|
|
@@ -1265,7 +1269,7 @@ interface SuperviseCtx {
|
|
|
1265
1269
|
* returns exactly one outcome; the trace side-effects mirror the prior inline behaviour.
|
|
1266
1270
|
*/
|
|
1267
1271
|
function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
|
|
1268
|
-
const { cfg, env, nowMs, lastTurnMt, isAlive, kill, verbose, trace } = ctx
|
|
1272
|
+
const { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace } = ctx
|
|
1269
1273
|
// H4 — FIRST, before any reap. A launchd-managed peer is read-only.
|
|
1270
1274
|
if (isLaunchdManaged(s.personality, env)) {
|
|
1271
1275
|
if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4' })
|
|
@@ -1356,11 +1360,18 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1356
1360
|
const ephemeral = isEphemeralPeer(s.cwd)
|
|
1357
1361
|
let lt = 0
|
|
1358
1362
|
try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
|
|
1363
|
+
// В58 — fold in CHILD workflow/subagent activity. A peer driving a long (>1h) workflow makes NO turns
|
|
1364
|
+
// of its own, so lt is stale; without this the busy-with-workflow session is idle-reaped mid-work (live
|
|
1365
|
+
// incident: iapeer-memory cut mid-audit). A running workflow writes its subagent transcripts
|
|
1366
|
+
// continuously → their fresh mtime is genuine activity. Only RAISES the floor (never keeps a truly idle
|
|
1367
|
+
// session alive: a finished workflow's files are old).
|
|
1368
|
+
let childMt = 0
|
|
1369
|
+
try { childMt = childActivityMt(s.runtime, s.cwd) ?? 0 } catch { /* no subagent layout → ignored */ }
|
|
1359
1370
|
// В7 — fold in the last CONFIRMED live delivery. A message delivered just as the session crosses
|
|
1360
1371
|
// idleSecs (codex acks BEFORE it writes the turn record) would otherwise be reaped out from under the
|
|
1361
1372
|
// just-delivered message before lt catches up — a silent loss behind ok:true. lastDelivered only ever
|
|
1362
1373
|
// 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))
|
|
1374
|
+
const mt = Math.max(lt, s.wokeAt, childMt, readLastDelivered(cfg, s.identity))
|
|
1364
1375
|
const ageSecs = Math.floor((nowMs - mt) / 1000)
|
|
1365
1376
|
// wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
|
|
1366
1377
|
// daemon routed its outbound reply) is reaped after a QUIET window, checked
|
|
@@ -1431,6 +1442,8 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1431
1442
|
const env = deps.env ?? process.env
|
|
1432
1443
|
const nowMs = deps.nowMs ?? Date.now()
|
|
1433
1444
|
const lastTurnMt = deps.lastTurnMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).lastTurnMtime(c))
|
|
1445
|
+
const childActivityMt =
|
|
1446
|
+
deps.childActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).childActivityMtime?.(c) ?? null)
|
|
1434
1447
|
// Default seams bind the injected env so a sandboxed tick never reads/kills the real fleet.
|
|
1435
1448
|
const isAlive = deps.sessionAlive ?? ((sock: string, identity: string) => sessionAlive(sock, identity, env))
|
|
1436
1449
|
const kill = deps.killSession ?? ((sock: string, identity: string) => killSession(sock, identity, env))
|
|
@@ -1450,7 +1463,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1450
1463
|
const trace = (fields: Record<string, string | number | undefined>): void =>
|
|
1451
1464
|
appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise', ...fields }, { env, nowMs })
|
|
1452
1465
|
const out: SuperviseOutcome[] = []
|
|
1453
|
-
const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, isAlive, kill, verbose, trace }
|
|
1466
|
+
const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace }
|
|
1454
1467
|
for (const s of readSessionStates(cfg)) {
|
|
1455
1468
|
// Per-peer ISOLATION: a throw while evaluating ONE peer must NOT abort the whole sweep — that is
|
|
1456
1469
|
// the silent fleet-wide reap-outage class (one malformed peer-state and NO peer gets reaped, the
|
package/src/registry/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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, writeFileSync } from 'fs'
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
|
|
13
13
|
import { homedir } from 'os'
|
|
14
14
|
import { join } from 'path'
|
|
15
15
|
import { writeFileAtomicRaw } from '../storage/atomicWrite.ts'
|
|
@@ -345,6 +345,24 @@ function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
|
|
|
345
345
|
writeFileAtomicRaw(paths.peersFile, `${JSON.stringify(normalized, null, 2)}\n`, 0o600, paths.tmpDir)
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/** В33 — assert the READ invariants BEFORE publishing. writePeersIndexAtomic does no validation, so a
|
|
349
|
+
* duplicate personality (e.g. a partial renamePeer + create, or reindex projecting two same-named local
|
|
350
|
+
* profiles) or an empty cwd would write SUCCESSFULLY and then make EVERY readPeersIndex throw — the whole
|
|
351
|
+
* fleet stops resolving until the file is hand-repaired. Fail LOUD here (the single-writer chokepoint) so
|
|
352
|
+
* the on-disk file is never left unreadable. */
|
|
353
|
+
function assertWritableIndex(index: PeersIndex): void {
|
|
354
|
+
const seen = new Set<string>()
|
|
355
|
+
for (const p of index.peers) {
|
|
356
|
+
if (!p.cwd || !p.cwd.trim()) {
|
|
357
|
+
throw new IapError(`refusing to write peers-profiles.json: peer "${p.personality}" has an empty cwd (would be unreadable)`)
|
|
358
|
+
}
|
|
359
|
+
if (seen.has(p.personality)) {
|
|
360
|
+
throw new IapError(`refusing to write peers-profiles.json: duplicate peer "${p.personality}" (would make the registry unreadable for the whole fleet)`)
|
|
361
|
+
}
|
|
362
|
+
seen.add(p.personality)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
348
366
|
export async function updatePeersIndex(
|
|
349
367
|
updater: (index: PeersIndex) => PeersIndex,
|
|
350
368
|
options: PeersUpdateOptions = {},
|
|
@@ -352,6 +370,7 @@ export async function updatePeersIndex(
|
|
|
352
370
|
return withPeersLock(options, paths => {
|
|
353
371
|
const current = readPeersIndex({ ...options, rootDir: paths.rootDir })
|
|
354
372
|
const next = updater(current)
|
|
373
|
+
assertWritableIndex(next) // В33 — fail before publish; the file stays readable
|
|
355
374
|
writePeersIndexAtomic(paths, next)
|
|
356
375
|
return next
|
|
357
376
|
})
|
|
@@ -371,6 +390,17 @@ export interface UpsertPeerArgs {
|
|
|
371
390
|
cwd: string
|
|
372
391
|
}
|
|
373
392
|
|
|
393
|
+
/** Two cwds refer to the SAME folder (realpath-tolerant so a symlinked path compares equal to its
|
|
394
|
+
* target). Falls back to a literal compare when a path can't be resolved (e.g. not yet created). */
|
|
395
|
+
function sameCwd(a: string, b: string): boolean {
|
|
396
|
+
if (a === b) return true
|
|
397
|
+
try {
|
|
398
|
+
return realpathSync(a) === realpathSync(b)
|
|
399
|
+
} catch {
|
|
400
|
+
return a === b
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
374
404
|
export async function upsertPeer(
|
|
375
405
|
args: UpsertPeerArgs,
|
|
376
406
|
options: PeersUpdateOptions = {},
|
|
@@ -411,6 +441,18 @@ export async function upsertPeer(
|
|
|
411
441
|
return updatePeersIndex(index => {
|
|
412
442
|
const existing = index.peers.find(peer => peer.personality === args.personality)
|
|
413
443
|
|
|
444
|
+
// В35 — re-check the personality↔cwd collision UNDER THE LOCK (closing the TOCTOU: the pre-lock
|
|
445
|
+
// snapshot check in provisionPeer can be stale by the seconds-long birth-hook window). An existing
|
|
446
|
+
// peer at a DIFFERENT folder must NOT be silently re-pointed — that splits one identity across two
|
|
447
|
+
// folders (routing goes to whichever the entry point resolves). Legitimate folder moves are
|
|
448
|
+
// fail-closed in ensurePeerProfile; renames go through renamePeer — neither re-points via upsert.
|
|
449
|
+
if (existing && !sameCwd(existing.cwd, args.cwd)) {
|
|
450
|
+
throw new IapError(
|
|
451
|
+
`peer "${args.personality}" already exists at ${existing.cwd}; refusing to re-point it to ${args.cwd} ` +
|
|
452
|
+
`(one personality = one folder — use 'iapeer rename' to move it)`,
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
414
456
|
// H1 merge-with-existing: a field ABSENT from args inherits from `existing`,
|
|
415
457
|
// NOT from a runtime default. The runtime default applies ONLY to a brand-new
|
|
416
458
|
// peer (no existing). This is what stops a claude-boot upsert (no intelligence)
|