@agfpd/iapeer 0.4.25 → 0.4.27
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/README.md +15 -9
- package/package.json +13 -7
- package/src/daemon/index.ts +13 -5
- package/src/daemon/main.ts +9 -0
- package/src/launch/adapters/claude.ts +28 -0
- package/src/launch/adapters/codex.ts +22 -3
- package/src/launch/adapters/notifier.ts +4 -0
- package/src/launch/adapters/telegram.ts +4 -0
- package/src/launch/adapters/transcriptTail.ts +64 -0
- package/src/launch/adapters/voicetalk.ts +4 -0
- package/src/launch/types.ts +9 -2
- package/src/lifecycle/index.ts +175 -179
- package/src/transport/index.ts +123 -70
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# iapeer
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.**
|
|
4
4
|
|
|
5
5
|
[](https://github.com/agfpd/iapeer/actions/workflows/ci.yml)
|
|
6
6
|
[](https://www.npmjs.com/package/@agfpd/iapeer)
|
|
@@ -32,7 +32,7 @@ recipient peer — asleep? the daemon wakes it and delivers on wake
|
|
|
32
32
|
## What makes it different
|
|
33
33
|
|
|
34
34
|
- **Real interactive CLI sessions, not headless one-shots.** Each AI peer is a full Claude Code or Codex CLI session, not a disposable `claude -p` / `codex exec` call.
|
|
35
|
-
- **Warm
|
|
35
|
+
- **Warm on demand, always reachable.** A live session answers fast; when it goes idle the daemon parks it and brings it back — context resumed — on the next message. Availability never depends on a human keeping the session open. [Lifecycle and daemon](docs/04-lifecycle-and-daemon.md).
|
|
36
36
|
- **One identity across runtimes, with one memory.** A peer's `personality` is decoupled from its runtime: the same peer runs on Claude or Codex, switched with a command. By default (set up by `iapeer onboard`) it carries one shared memory (iapeer-memory) keyed to the personality, not the runtime — one personality, one memory, whatever runtime it runs on.
|
|
37
37
|
- **Heterogeneous peers, one protocol.** AI agents, humans (Telegram), and services (timer = cron, watcher = event) are all first-class, addressed the same way over an always-on MCP daemon that exposes exactly one tool, `send_to_peer`.
|
|
38
38
|
|
|
@@ -58,16 +58,22 @@ iapeer onboard
|
|
|
58
58
|
|
|
59
59
|
Either path runs `onboard` interactively: it surfaces the security checks (runtime sign-in, macOS Full Disk Access) and asks before installing the memory provider — nothing is auto-approved behind your back. You can read [`install.sh`](./install.sh) before running it, or preview every step with `IAPEER_INSTALL_DRYRUN=1`.
|
|
60
60
|
|
|
61
|
-
**Then create your
|
|
61
|
+
**Then create your agents and put them in touch.** A Claude peer and a Codex peer talk as equals — create one on each runtime:
|
|
62
62
|
|
|
63
63
|
```sh
|
|
64
|
-
# create
|
|
65
|
-
iapeer create
|
|
64
|
+
# create two peers, one per runtime
|
|
65
|
+
iapeer create architect --runtime claude
|
|
66
|
+
iapeer create implementer --runtime codex
|
|
66
67
|
|
|
67
|
-
#
|
|
68
|
-
iapeer
|
|
68
|
+
# message one from your terminal
|
|
69
|
+
iapeer send implementer --message "Draft the parser API; the architect will review."
|
|
70
|
+
|
|
71
|
+
# or join a peer's live session — detach with Ctrl-] (or just close it; the agent keeps running)
|
|
72
|
+
iapeer attach architect
|
|
69
73
|
```
|
|
70
74
|
|
|
75
|
+
Inside a peer's own session the agent reaches others with `send_to_peer(<name>, <text>)`; `iapeer send` is the terminal equivalent.
|
|
76
|
+
|
|
71
77
|
**Already have a project folder?** In step 3, use `init` instead of `create`: `cd` into the project and register that folder itself as a peer.
|
|
72
78
|
|
|
73
79
|
```sh
|
|
@@ -79,11 +85,11 @@ The peer's name is the folder name, normalized (`myagent`). `create` sets up a f
|
|
|
79
85
|
|
|
80
86
|
Two ways to work with an agent locally in your terminal:
|
|
81
87
|
|
|
82
|
-
- **Join its live session** — `iapeer attach
|
|
88
|
+
- **Join its live session** — `iapeer attach architect`. The daemon wakes the peer first if it is asleep. Detach with `Ctrl-]` — or just close the terminal — and the session keeps running in the background; reattach with `iapeer attach` whenever you need it.
|
|
83
89
|
- **Open a fresh session in the peer's folder** — go to the peer's directory and launch its runtime:
|
|
84
90
|
|
|
85
91
|
```sh
|
|
86
|
-
cd ~/.iapeer/peers/
|
|
92
|
+
cd ~/.iapeer/peers/architect
|
|
87
93
|
iapeer claude
|
|
88
94
|
```
|
|
89
95
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.27",
|
|
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",
|
|
7
7
|
"homepage": "https://github.com/agfpd/iapeer#readme",
|
|
@@ -13,12 +13,18 @@
|
|
|
13
13
|
"url": "https://github.com/agfpd/iapeer/issues"
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
|
-
"
|
|
17
|
-
"
|
|
16
|
+
"claude-code",
|
|
17
|
+
"codex-cli",
|
|
18
|
+
"codex",
|
|
18
19
|
"mcp",
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
20
|
+
"shared-memory",
|
|
21
|
+
"peer-to-peer",
|
|
22
|
+
"background-agents",
|
|
23
|
+
"ai-agents",
|
|
24
|
+
"multi-agent",
|
|
25
|
+
"telegram",
|
|
26
|
+
"macos",
|
|
27
|
+
"launchd"
|
|
22
28
|
],
|
|
23
29
|
"type": "module",
|
|
24
30
|
"bin": {
|
package/src/daemon/index.ts
CHANGED
|
@@ -353,7 +353,7 @@ export interface StartDaemonOptions extends StorageOptions {
|
|
|
353
353
|
* owns fleet supervision (idle-reap / zombie-sweep) instead of launchd. The
|
|
354
354
|
* caller wires `tick: () => superviseTick(cfg)` (H4-guarded inside).
|
|
355
355
|
*/
|
|
356
|
-
supervise?: { intervalMs: number; tick: () => void | Promise<void
|
|
356
|
+
supervise?: { intervalMs: number; tick: () => void | Promise<void>; onError?: (err: unknown) => void }
|
|
357
357
|
/**
|
|
358
358
|
* Optional bearer token (H8). When set, EVERY request must carry
|
|
359
359
|
* `Authorization: Bearer <token>` or it is rejected 401 BEFORE any MCP dispatch.
|
|
@@ -456,12 +456,20 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
456
456
|
// timer regardless of how many listeners are bound).
|
|
457
457
|
let supervisor: ReturnType<typeof setInterval> | undefined
|
|
458
458
|
if (opts.supervise) {
|
|
459
|
-
const { intervalMs, tick } = opts.supervise
|
|
459
|
+
const { intervalMs, tick, onError } = opts.supervise
|
|
460
|
+
// A supervise tick must NEVER crash the daemon — but its error must NOT be SWALLOWED either: a
|
|
461
|
+
// silently-swallowed throw is a sweep that stops reaping INVISIBLY (no log, no stderr — the class
|
|
462
|
+
// that made a stuck reaper undiagnosable for hours). Route BOTH an async rejection and a sync throw
|
|
463
|
+
// to onError (the production main records ev=supervise-error in lifecycle.log); reporting itself
|
|
464
|
+
// never throws.
|
|
465
|
+
const report = (err: unknown): void => {
|
|
466
|
+
try { onError?.(err) } catch { /* a reporter must never crash the daemon */ }
|
|
467
|
+
}
|
|
460
468
|
supervisor = setInterval(() => {
|
|
461
469
|
try {
|
|
462
|
-
void Promise.resolve(tick()).catch(
|
|
463
|
-
} catch {
|
|
464
|
-
|
|
470
|
+
void Promise.resolve(tick()).catch(report)
|
|
471
|
+
} catch (err) {
|
|
472
|
+
report(err)
|
|
465
473
|
}
|
|
466
474
|
}, intervalMs)
|
|
467
475
|
supervisor.unref?.()
|
package/src/daemon/main.ts
CHANGED
|
@@ -355,6 +355,15 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
355
355
|
// head). Identities without a queue or with a live session are no-ops.
|
|
356
356
|
await drainAllEphemeralQueues(cfg, { env })
|
|
357
357
|
},
|
|
358
|
+
// Surface a supervise-tick throw that the daemon timer would otherwise SWALLOW (the class that
|
|
359
|
+
// hid a stuck reaper for hours): record ev=supervise-error in lifecycle.log, flattened to one
|
|
360
|
+
// logfmt line. Best-effort — a reporter must never fail the daemon.
|
|
361
|
+
onError: (err: unknown) => {
|
|
362
|
+
try {
|
|
363
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
|
364
|
+
appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise-error', error: detail.replace(/\s+/g, ' ').slice(0, 600) }, { env })
|
|
365
|
+
} catch { /* best-effort */ }
|
|
366
|
+
},
|
|
358
367
|
},
|
|
359
368
|
bearerToken,
|
|
360
369
|
port: opts.port ?? DEFAULT_DAEMON_PORT,
|
|
@@ -24,6 +24,7 @@ import { homedir } from 'os'
|
|
|
24
24
|
import { join } from 'path'
|
|
25
25
|
import { readdirSync, realpathSync, statSync } from 'fs'
|
|
26
26
|
import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
27
|
+
import { lastTimestampedEntryMs } from './transcriptTail.ts'
|
|
27
28
|
|
|
28
29
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
30
|
// Boot dialog + ready markers (lifecycle claudeBootDialog / claudeInputReady)
|
|
@@ -280,6 +281,33 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
280
281
|
return newest > 0 ? newest : null
|
|
281
282
|
},
|
|
282
283
|
|
|
284
|
+
/**
|
|
285
|
+
* IDLE-accounting signal: the content-timestamp of the last meaningful entry in the ACTIVE transcript
|
|
286
|
+
* (newest .jsonl by file mtime), read from its tail. Immune to the file re-save (file-mtime is fresh
|
|
287
|
+
* but the last ENTRY is not) and to the statusline pane-log tick (the transcript is untouched by a
|
|
288
|
+
* status redraw). null when the dir is absent/empty or no timestamped entry sits in the tail.
|
|
289
|
+
*/
|
|
290
|
+
lastTurnMtime(cwd: string): number | null {
|
|
291
|
+
let entries: string[]
|
|
292
|
+
try {
|
|
293
|
+
entries = readdirSync(transcriptDir(cwd))
|
|
294
|
+
} catch {
|
|
295
|
+
return null
|
|
296
|
+
}
|
|
297
|
+
let best = { path: '', mt: 0 }
|
|
298
|
+
for (const name of entries) {
|
|
299
|
+
if (!name.endsWith('.jsonl')) continue
|
|
300
|
+
try {
|
|
301
|
+
const path = join(transcriptDir(cwd), name)
|
|
302
|
+
const mt = statSync(path).mtimeMs
|
|
303
|
+
if (mt > best.mt) best = { path, mt }
|
|
304
|
+
} catch {
|
|
305
|
+
/* race */
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return best.path ? lastTimestampedEntryMs(best.path) : null
|
|
309
|
+
},
|
|
310
|
+
|
|
283
311
|
/**
|
|
284
312
|
* Resume preflight (fail-loud — never a silent fresh fallback): resolve the
|
|
285
313
|
* newest transcript uuid for cwd via the claude-slug dir scan (lifecycle.
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { homedir } from 'os'
|
|
39
39
|
import { join } from 'path'
|
|
40
40
|
import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
|
|
41
|
+
import { lastTimestampedEntryMs } from './transcriptTail.ts'
|
|
41
42
|
import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
42
43
|
|
|
43
44
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -114,9 +115,16 @@ function canonicalPath(p: string): string {
|
|
|
114
115
|
* (the model produced its first turn); idle accounting reads the same proxy.
|
|
115
116
|
*/
|
|
116
117
|
function newestCodexSessionMtime(cwd: string): number | null {
|
|
118
|
+
return newestCodexSession(cwd)?.mt ?? null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The newest ~/.codex/sessions/**\/*.jsonl whose recorded session cwd realpath-matches `cwd`, as
|
|
122
|
+
* {path, mt}, or null when none. Shared by newestActivityMtime (file-mtime) and lastTurnMtime (needs
|
|
123
|
+
* the PATH to read the tail). Codex nests its session logs in a date tree, so the scan recurses. */
|
|
124
|
+
function newestCodexSession(cwd: string): { path: string; mt: number } | null {
|
|
117
125
|
const root = join(homedir(), '.codex', 'sessions')
|
|
118
126
|
const target = canonicalPath(cwd)
|
|
119
|
-
let
|
|
127
|
+
let best = { path: '', mt: 0 }
|
|
120
128
|
function visit(dir: string): void {
|
|
121
129
|
let entries
|
|
122
130
|
try {
|
|
@@ -134,14 +142,14 @@ function newestCodexSessionMtime(cwd: string): number | null {
|
|
|
134
142
|
if (canonicalPath(codexSessionCwd(path) ?? '') !== target) continue
|
|
135
143
|
try {
|
|
136
144
|
const mt = statSync(path).mtimeMs
|
|
137
|
-
if (mt >
|
|
145
|
+
if (mt > best.mt) best = { path, mt }
|
|
138
146
|
} catch {
|
|
139
147
|
/* race — entry vanished between readdir and stat */
|
|
140
148
|
}
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
151
|
visit(root)
|
|
144
|
-
return
|
|
152
|
+
return best.path ? best : null
|
|
145
153
|
}
|
|
146
154
|
|
|
147
155
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -259,6 +267,17 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
259
267
|
return newestCodexSessionMtime(cwd)
|
|
260
268
|
},
|
|
261
269
|
|
|
270
|
+
/**
|
|
271
|
+
* IDLE-accounting signal: the content-timestamp of the last meaningful entry in the active codex
|
|
272
|
+
* session jsonl (newest cwd-matching file), read from its tail. codex entries (event_msg /
|
|
273
|
+
* response_item) carry a top-level `timestamp`, so this reflects real turn activity — immune to a
|
|
274
|
+
* file re-save and to a statusline pane-log tick. null when no cwd-matching session / no timestamp.
|
|
275
|
+
*/
|
|
276
|
+
lastTurnMtime(cwd: string): number | null {
|
|
277
|
+
const s = newestCodexSession(cwd)
|
|
278
|
+
return s ? lastTimestampedEntryMs(s.path) : null
|
|
279
|
+
},
|
|
280
|
+
|
|
262
281
|
/**
|
|
263
282
|
* Resume preflight (fail-loud — never a silent fresh fallback). Codex resumes
|
|
264
283
|
* via 'resume --last' with NO ref: it matches the newest session for cwd
|
|
@@ -68,6 +68,10 @@ export const notifierAdapter: RuntimeAdapter = {
|
|
|
68
68
|
return null
|
|
69
69
|
},
|
|
70
70
|
|
|
71
|
+
lastTurnMtime(_cwd: string): number | null {
|
|
72
|
+
return null // router — no transcript
|
|
73
|
+
},
|
|
74
|
+
|
|
71
75
|
/** Nothing to resume: a router does not replay a transcript. */
|
|
72
76
|
resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
|
|
73
77
|
return { ok: true }
|
|
@@ -98,6 +98,10 @@ export const telegramAdapter: RuntimeAdapter = {
|
|
|
98
98
|
return null
|
|
99
99
|
},
|
|
100
100
|
|
|
101
|
+
lastTurnMtime(_cwd: string): number | null {
|
|
102
|
+
return null // router — no transcript
|
|
103
|
+
},
|
|
104
|
+
|
|
101
105
|
/**
|
|
102
106
|
* Nothing to resume: a router does not replay a transcript, so resume cannot
|
|
103
107
|
* fail-loud the way claude/codex do — there is no prior session ref to resolve
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Shared transcript-tail reader for the idle-accounting signal.
|
|
2
|
+
//
|
|
3
|
+
// The RELIABLE "last real turn" time is the content-timestamp of the LAST meaningful transcript entry —
|
|
4
|
+
// NOT the file mtime (a live claude session RE-SAVES its .jsonl without a new entry, so file-mtime is
|
|
5
|
+
// falsely fresh at idle) and NOT the pane-log mtime (a statusline / footer re-render writes pty bytes at
|
|
6
|
+
// idle, so the pane-log is falsely fresh too). The transcript ENTRY stream only advances on actual turn
|
|
7
|
+
// activity (user/assistant/tool/turn_duration for claude; event_msg/response_item for codex), so the
|
|
8
|
+
// last entry that carries a `timestamp` is the true idle signal — immune to both noise sources.
|
|
9
|
+
|
|
10
|
+
import { openSync, readSync, closeSync, statSync } from 'fs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Content-timestamp (epoch ms) of the LAST jsonl entry carrying a `timestamp` field, read from the
|
|
14
|
+
* file's TAIL (cheap — only the last `tailBytes` are read). Metadata records without a `timestamp`
|
|
15
|
+
* (claude `mode` / `ai-title` / `bridge-session`) are skipped. Returns null when the file is
|
|
16
|
+
* unreadable or no timestamped entry sits in the tail (the caller then floors at wokeAt). A partial
|
|
17
|
+
* FIRST line in the tail buffer is harmless: we scan from the END and return on the first complete
|
|
18
|
+
* timestamped entry, so a truncated leading line is only ever reached (and skipped) when nothing
|
|
19
|
+
* matched.
|
|
20
|
+
*/
|
|
21
|
+
export function lastTimestampedEntryMs(path: string, tailBytes = 65536): number | null {
|
|
22
|
+
let fd: number
|
|
23
|
+
try {
|
|
24
|
+
fd = openSync(path, 'r')
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const size = statSync(path).size
|
|
30
|
+
const start = Math.max(0, size - tailBytes)
|
|
31
|
+
const len = size - start
|
|
32
|
+
if (len <= 0) return null
|
|
33
|
+
const buf = Buffer.allocUnsafe(len)
|
|
34
|
+
let read = 0
|
|
35
|
+
while (read < len) {
|
|
36
|
+
const n = readSync(fd, buf, read, len - read, start + read)
|
|
37
|
+
if (n <= 0) break
|
|
38
|
+
read += n
|
|
39
|
+
}
|
|
40
|
+
const lines = buf.subarray(0, read).toString('utf8').split(/\r?\n/)
|
|
41
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
42
|
+
const line = lines[i].trim()
|
|
43
|
+
if (!line) continue
|
|
44
|
+
let obj: unknown
|
|
45
|
+
try {
|
|
46
|
+
obj = JSON.parse(line)
|
|
47
|
+
} catch {
|
|
48
|
+
continue // partial leading line / non-json → skip
|
|
49
|
+
}
|
|
50
|
+
const ts = (obj as { timestamp?: unknown })?.timestamp
|
|
51
|
+
if (typeof ts === 'string') {
|
|
52
|
+
const ms = Date.parse(ts)
|
|
53
|
+
if (!Number.isNaN(ms)) return ms
|
|
54
|
+
} else if (typeof ts === 'number' && Number.isFinite(ts)) {
|
|
55
|
+
return ts
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
} finally {
|
|
62
|
+
closeSync(fd)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -81,6 +81,10 @@ export const voicetalkAdapter: RuntimeAdapter = {
|
|
|
81
81
|
return null
|
|
82
82
|
},
|
|
83
83
|
|
|
84
|
+
lastTurnMtime(_cwd: string): number | null {
|
|
85
|
+
return null // router — no transcript
|
|
86
|
+
},
|
|
87
|
+
|
|
84
88
|
/** Nothing to resume: a router does not replay a transcript, so resume cannot
|
|
85
89
|
* fail-loud the way claude/codex do. Always {ok:true}. */
|
|
86
90
|
resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
|
package/src/launch/types.ts
CHANGED
|
@@ -263,10 +263,17 @@ export interface RuntimeAdapter {
|
|
|
263
263
|
* AND startup dialogs gone). Router runtimes return true (no input surface). */
|
|
264
264
|
isInputReady(pane: string): boolean
|
|
265
265
|
|
|
266
|
-
/** Newest activity-proxy mtime
|
|
267
|
-
*
|
|
266
|
+
/** Newest activity-proxy FILE mtime — used by the READY-GATE (it waits for ANY transcript write past
|
|
267
|
+
* baseline = "the model produced its first turn"). NOT for idle accounting: a live session re-saves
|
|
268
|
+
* this file without a new entry, so it is falsely fresh at idle. null for a router (no proxy). */
|
|
268
269
|
newestActivityMtime(cwd: string): number | null
|
|
269
270
|
|
|
271
|
+
/** Content-timestamp (epoch ms) of the LAST meaningful transcript ENTRY — the reliable IDLE-accounting
|
|
272
|
+
* signal. Unlike newestActivityMtime (file mtime, re-touched at idle) and the pane-log mtime (a
|
|
273
|
+
* statusline re-render ticks it at idle), the entry stream only advances on real turn activity.
|
|
274
|
+
* superviseTick uses THIS for idle/quiet age. null for a router (no transcript). */
|
|
275
|
+
lastTurnMtime(cwd: string): number | null
|
|
276
|
+
|
|
270
277
|
/** Resume preflight — validate a resume request fail-loud (never silent fresh).
|
|
271
278
|
* Returns the resolved ref (claude uuid) or ok:false with a reason. */
|
|
272
279
|
resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string }
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -344,21 +344,6 @@ export function clearEphemeralArmed(cfg: LifecycleConfig, identity: string): voi
|
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
/** mtime (ms) of a hosted session's pane-log (`<logDir>/<identity>.log`) — the RAW TUI
|
|
348
|
-
* render byte-stream the supervisor appends for the session's whole life. It advances
|
|
349
|
-
* ~1s while the TUI is rendering a turn (the "esc to interrupt" elapsed counter ticks
|
|
350
|
-
* during BOTH model generation AND a tool run) and goes quiet at the prompt. So it is
|
|
351
|
-
* the truest active-turn liveness signal — the SAME mtime telegram-runtime reads for its
|
|
352
|
-
* typing/activity indicator — and unlike the transcript proxy it does NOT go quiet during
|
|
353
|
-
* a long model generation (no JSONL write until the message completes). null if absent. */
|
|
354
|
-
function paneLogMtimeMs(cfg: LifecycleConfig, identity: string): number | null {
|
|
355
|
-
try {
|
|
356
|
-
return statSync(join(cfg.logDir, `${identity}.log`)).mtimeMs
|
|
357
|
-
} catch {
|
|
358
|
-
return null
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
347
|
function deathsPath(cfg: LifecycleConfig, identity: string): string {
|
|
363
348
|
return join(cfg.stateDir, `${identity}.deaths`)
|
|
364
349
|
}
|
|
@@ -1144,7 +1129,7 @@ export function killSession(_sock: string, identity: string): void {
|
|
|
1144
1129
|
|
|
1145
1130
|
export interface SuperviseOutcome {
|
|
1146
1131
|
identity: string
|
|
1147
|
-
action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'skipped-stopped' | 'alive' | 'needs-eager-fresh'
|
|
1132
|
+
action: 'reaped-idle' | 'reaped-gone' | 'reaped-ephemeral' | 'skipped-launchd' | 'skipped-stopped' | 'alive' | 'needs-eager-fresh' | 'skipped-error'
|
|
1148
1133
|
reason?: string
|
|
1149
1134
|
/** For 'needs-eager-fresh': the peer to EAGERLY re-launch fresh (its session died
|
|
1150
1135
|
* carrying a .new-eager mark). The daemon timer drives the async relaunch.
|
|
@@ -1157,23 +1142,176 @@ export interface SuperviseOutcome {
|
|
|
1157
1142
|
export interface SuperviseDeps {
|
|
1158
1143
|
env?: NodeJS.ProcessEnv
|
|
1159
1144
|
nowMs?: number
|
|
1160
|
-
/**
|
|
1161
|
-
*
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1145
|
+
/** IDLE-proxy seam (default: the runtime adapter's `lastTurnMtime` = content-ts of the last meaningful
|
|
1146
|
+
* transcript entry). This is the RELIABLE idle signal — NOT the transcript FILE mtime (re-saved at
|
|
1147
|
+
* idle) and NOT the pane-log mtime (a statusline re-render ticks it at idle); both falsely keep an
|
|
1148
|
+
* idle session young. Tests inject a controlled value to exercise the idle/quiet age hermetically. */
|
|
1149
|
+
lastTurnMtime?: (runtime: string, cwd: string) => number | null
|
|
1150
|
+
/** Liveness seam (default: the real `sessionAlive` = pty-host pid-file check). Tests inject a fixed
|
|
1151
|
+
* verdict so the idle path is exercised hermetically — no live pty session needed. */
|
|
1165
1152
|
sessionAlive?: (sock: string, identity: string) => boolean
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Resolved per-tick context threaded into superviseOnePeer (deps already defaulted by superviseTick). */
|
|
1156
|
+
interface SuperviseCtx {
|
|
1157
|
+
cfg: LifecycleConfig
|
|
1158
|
+
env: NodeJS.ProcessEnv
|
|
1159
|
+
nowMs: number
|
|
1160
|
+
lastTurnMt: (runtime: string, cwd: string) => number | null
|
|
1161
|
+
isAlive: (sock: string, identity: string) => boolean
|
|
1162
|
+
verbose: boolean
|
|
1163
|
+
trace: (fields: Record<string, string | number | undefined>) => void
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Evaluate ONE daemon-owned session and take its at-most-one reap action, returning the outcome.
|
|
1168
|
+
* Extracted from superviseTick so a throw on a SINGLE peer can be ISOLATED by the caller: a malformed
|
|
1169
|
+
* peer-state must never abort the whole sweep (that is the silent fleet-wide reap-outage class — one bad
|
|
1170
|
+
* peer and NO peer gets reaped, the error swallowed by the daemon's supervise-tick catch). Every branch
|
|
1171
|
+
* returns exactly one outcome; the trace side-effects mirror the prior inline behaviour.
|
|
1172
|
+
*/
|
|
1173
|
+
function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
|
|
1174
|
+
const { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace } = ctx
|
|
1175
|
+
// H4 — FIRST, before any reap. A launchd-managed peer is read-only.
|
|
1176
|
+
if (isLaunchdManaged(s.personality, env)) {
|
|
1177
|
+
if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4' })
|
|
1178
|
+
return { identity: s.identity, action: 'skipped-launchd' }
|
|
1179
|
+
}
|
|
1180
|
+
const sock = buildSocketPath(s.runtime, s.personality, cfg.sockDir)
|
|
1181
|
+
if (!isAlive(sock, s.identity)) {
|
|
1182
|
+
// A DELIBERATE stop is not a death: the stop verb parks clean
|
|
1183
|
+
// (.stopped + .idle-reaped) and kills the session itself. Catch-up branch for
|
|
1184
|
+
// a stop that raced this tick (or a pre-fix stop): drop the state quietly —
|
|
1185
|
+
// no crash-loop entry, no death class — and ensure the clean-park marker so
|
|
1186
|
+
// the post-`start` wake RESUMES (stop→start must survive ≥ idle-reap).
|
|
1187
|
+
if (isStopped(cfg, s.identity)) {
|
|
1188
|
+
removeSessionState(cfg, s.identity)
|
|
1189
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1190
|
+
setIdleReaped(cfg, s.identity) // idempotent with the stop verb's own park
|
|
1191
|
+
trace({ identity: s.identity, action: 'skipped-stopped', outcome: 'resume-on-start' })
|
|
1192
|
+
return { identity: s.identity, action: 'skipped-stopped', reason: 'deliberate stop — parked clean, resumes on start' }
|
|
1193
|
+
}
|
|
1194
|
+
// A dead session: record a death for crash-loop accounting, then branch on the
|
|
1195
|
+
// .new-eager mark. This death was NOT daemon-initiated (the daemon only initiates
|
|
1196
|
+
// the idle-reap below) → it died on its own → do NOT write .idle-reaped here.
|
|
1197
|
+
recordDeath(cfg, s.identity, nowMs)
|
|
1198
|
+
removeSessionState(cfg, s.identity)
|
|
1199
|
+
// The .ephemeral-armed mark belongs to THIS (now dead) session — it armed on its
|
|
1200
|
+
// outbound reply. Clear it with the session, so a successor session can never be
|
|
1201
|
+
// quiet-reaped on a stale mark before answering its own task. No-op otherwise.
|
|
1202
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1203
|
+
// A session that died carrying a .new-eager mark is an owner /new: re-launch
|
|
1204
|
+
// EAGERLY as fresh (not lazily on the next message). The mark is LEFT for the
|
|
1205
|
+
// eager relaunch (processEagerRelaunches) to consume; the daemon timer drives it.
|
|
1206
|
+
if (hasNewEager(cfg, s.identity)) {
|
|
1207
|
+
trace({ identity: s.identity, action: 'needs-eager-fresh', reason: '/new eager mark', outcome: 'eager-fresh' })
|
|
1208
|
+
return {
|
|
1209
|
+
identity: s.identity,
|
|
1210
|
+
action: 'needs-eager-fresh',
|
|
1211
|
+
reason: '/new eager mark — eager fresh re-launch',
|
|
1212
|
+
personality: s.personality,
|
|
1213
|
+
runtime: s.runtime,
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
// Crash / self-close: NO marker written, NO eager relaunch — the peer stays
|
|
1217
|
+
// asleep and wakes FRESH lazily on the next message (resolveWakeMode branch 3a).
|
|
1218
|
+
// The death-class tag (classifyGoneSession) makes the two gone-classes
|
|
1219
|
+
// distinguishable in lifecycle.log: `session-gone` (pane died, server alive →
|
|
1220
|
+
// exits.log should have the cause) vs `server-dead` (whole tmux server died →
|
|
1221
|
+
// exits.log structurally empty; this line is the only durable trace).
|
|
1222
|
+
const gone = classifyGoneSession(sock)
|
|
1223
|
+
trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
|
|
1224
|
+
return { identity: s.identity, action: 'reaped-gone', reason: gone.reason }
|
|
1225
|
+
}
|
|
1226
|
+
// Idle accounting via the LAST MEANINGFUL TRANSCRIPT ENTRY's content-timestamp (adapter.lastTurnMtime)
|
|
1227
|
+
// — the only RELIABLE idle signal. The two raw mtimes both report FALSE freshness for an idle session:
|
|
1228
|
+
// • pane-log mtime — a statusline / footer re-render writes pty bytes at idle (the clock/usage/ctx%
|
|
1229
|
+
// redraw + Claude Code's own update-check/token-counter), so the pane-log ticks at the prompt. The
|
|
1230
|
+
// 0.4.16 invariant "the pane-log goes quiet ONLY at the prompt" was broken by the statusline.
|
|
1231
|
+
// • transcript FILE mtime — a live claude session RE-SAVES its .jsonl without appending a new entry,
|
|
1232
|
+
// so the file is fresh while the last real turn was hours ago (the 0.4.16 re-touch).
|
|
1233
|
+
// Both made real peers live idle 3-5h UNREAPED (incident 23.06: perplex/mrwriter/doc). The transcript
|
|
1234
|
+
// ENTRY stream only advances on actual turn activity, so the last timestamped entry is the true last
|
|
1235
|
+
// turn. The launch ready-gate keeps newestActivityMtime (file-mtime advance = "a turn produced
|
|
1236
|
+
// output"). FLOORED at wokeAt: a freshly-woken session that produced no turn yet (lastTurn from a PRIOR
|
|
1237
|
+
// session is old) must not be reaped out from under an attaching op — max(lastTurn, wokeAt) reaps it
|
|
1238
|
+
// idleSecs after the wake, never before. null lastTurn (unreadable / churned out of the tail) → wokeAt
|
|
1239
|
+
// governs (an old wokeAt = woken-long-ago = idle → reaped; a recent wokeAt = just-woken → protected).
|
|
1240
|
+
const ephemeral = isEphemeralPeer(s.cwd)
|
|
1241
|
+
let lt = 0
|
|
1242
|
+
try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
|
|
1243
|
+
const mt = Math.max(lt, s.wokeAt)
|
|
1244
|
+
const ageSecs = Math.floor((nowMs - mt) / 1000)
|
|
1245
|
+
// wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
|
|
1246
|
+
// daemon routed its outbound reply) is reaped after a QUIET window, checked
|
|
1247
|
+
// BEFORE the idle branch (quiet ≪ idle). Quiet = the pane-log silent for
|
|
1248
|
+
// ephemeralQuietSecs (the reliable render signal above) — a still-rendering turn
|
|
1249
|
+
// keeps it fresh, so the worker finishes its turn first; post-reply housekeeping
|
|
1250
|
+
// (operative-note writes) also advances it.
|
|
1251
|
+
// NOT armed (still mid-task) → the ordinary idle bound below is its only reaper.
|
|
1252
|
+
// Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
|
|
1253
|
+
// and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
|
|
1254
|
+
if (ephemeral && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
|
|
1255
|
+
killSession(sock, s.identity)
|
|
1256
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1257
|
+
removeSessionState(cfg, s.identity)
|
|
1258
|
+
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
|
|
1259
|
+
return {
|
|
1260
|
+
identity: s.identity,
|
|
1261
|
+
action: 'reaped-ephemeral',
|
|
1262
|
+
reason: `armed, quiet ${ageSecs}s`,
|
|
1263
|
+
personality: s.personality,
|
|
1264
|
+
runtime: s.runtime,
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
// UNARMED ephemeral idle bound (live case): a worker that ended
|
|
1268
|
+
// SILENTLY (no final outbound → never armed; or its arm was lost to a CLI/
|
|
1269
|
+
// daemon-restart window) used to wait out the FULL generic idleSecs (1 h) —
|
|
1270
|
+
// and the M3 serial drain waits for the session's death, so ONE silent worker
|
|
1271
|
+
// stalled its whole conveyor. This bound is the defense-in-depth backstop:
|
|
1272
|
+
// ≫ the legitimate silent-tool case (sleep-180), ≪ idleSecs. The ШТАТНЫЙ
|
|
1273
|
+
// silent-finish path is `iapeer self-done` (arm without waking anyone —
|
|
1274
|
+
// the invariant «нет пустых пробуждений» stays intact); this branch only
|
|
1275
|
+
// bounds the damage when a worker does neither. Policy reap: NO .idle-reaped
|
|
1276
|
+
// (ephemeral never resumes), NO recordDeath (the ring counts faults). The pane-log
|
|
1277
|
+
// fold-in above also covers this branch: a STILL-WORKING unarmed worker (a silent
|
|
1278
|
+
// tool, no reply yet) keeps the pane-log fresh via the TUI render, so it is no longer
|
|
1279
|
+
// reaped mid-task — only a genuinely idle/wedged unarmed session crosses this bound.
|
|
1280
|
+
if (ephemeral && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
|
|
1281
|
+
killSession(sock, s.identity)
|
|
1282
|
+
clearEphemeralArmed(cfg, s.identity)
|
|
1283
|
+
removeSessionState(cfg, s.identity)
|
|
1284
|
+
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
|
|
1285
|
+
return {
|
|
1286
|
+
identity: s.identity,
|
|
1287
|
+
action: 'reaped-ephemeral',
|
|
1288
|
+
reason: `unarmed idle ${ageSecs}s (silent-finish backstop; штатный путь — iapeer self-done)`,
|
|
1289
|
+
personality: s.personality,
|
|
1290
|
+
runtime: s.runtime,
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
if (ageSecs > cfg.idleSecs) {
|
|
1294
|
+
// THE ONLY place .idle-reaped is written: this is the one death the daemon
|
|
1295
|
+
// INITIATES. Its presence on the next wake = the session was parked cleanly =
|
|
1296
|
+
// RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
|
|
1297
|
+
// branch above) never writes it → that wakes FRESH (branch 3a).
|
|
1298
|
+
killSession(sock, s.identity)
|
|
1299
|
+
setIdleReaped(cfg, s.identity)
|
|
1300
|
+
removeSessionState(cfg, s.identity)
|
|
1301
|
+
trace({ identity: s.identity, action: 'reaped-idle', age: `${ageSecs}s`, outcome: 'resume-eligible' })
|
|
1302
|
+
return { identity: s.identity, action: 'reaped-idle', reason: `idle ${ageSecs}s` }
|
|
1303
|
+
}
|
|
1304
|
+
// ALIVE and not idle. (pty-only: no tmux server-death canary — a hosted death is recorded by the
|
|
1305
|
+
// supervisor's exits.log and classified by classifyGoneSession on the next sweep.)
|
|
1306
|
+
if (verbose) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s` })
|
|
1307
|
+
return { identity: s.identity, action: 'alive' }
|
|
1169
1308
|
}
|
|
1170
1309
|
|
|
1171
1310
|
export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
|
|
1172
1311
|
const env = deps.env ?? process.env
|
|
1173
1312
|
const nowMs = deps.nowMs ?? Date.now()
|
|
1174
|
-
const
|
|
1313
|
+
const lastTurnMt = deps.lastTurnMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).lastTurnMtime(c))
|
|
1175
1314
|
const isAlive = deps.sessionAlive ?? sessionAlive
|
|
1176
|
-
const paneLogMt = deps.paneLogMtime ?? ((identity: string) => paneLogMtimeMs(cfg, identity))
|
|
1177
1315
|
const verbose = superviseLogVerbose(env)
|
|
1178
1316
|
// Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
|
|
1179
1317
|
// (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
|
|
@@ -1190,160 +1328,18 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1190
1328
|
const trace = (fields: Record<string, string | number | undefined>): void =>
|
|
1191
1329
|
appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise', ...fields }, { env, nowMs })
|
|
1192
1330
|
const out: SuperviseOutcome[] = []
|
|
1331
|
+
const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace }
|
|
1193
1332
|
for (const s of readSessionStates(cfg)) {
|
|
1194
|
-
//
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
// a stop that raced this tick (or a pre-fix stop): drop the state quietly —
|
|
1205
|
-
// no crash-loop entry, no death class — and ensure the clean-park marker so
|
|
1206
|
-
// the post-`start` wake RESUMES (stop→start must survive ≥ idle-reap).
|
|
1207
|
-
if (isStopped(cfg, s.identity)) {
|
|
1208
|
-
removeSessionState(cfg, s.identity)
|
|
1209
|
-
clearEphemeralArmed(cfg, s.identity)
|
|
1210
|
-
setIdleReaped(cfg, s.identity) // idempotent with the stop verb's own park
|
|
1211
|
-
out.push({ identity: s.identity, action: 'skipped-stopped', reason: 'deliberate stop — parked clean, resumes on start' })
|
|
1212
|
-
trace({ identity: s.identity, action: 'skipped-stopped', outcome: 'resume-on-start' })
|
|
1213
|
-
continue
|
|
1214
|
-
}
|
|
1215
|
-
// A dead session: record a death for crash-loop accounting, then branch on the
|
|
1216
|
-
// .new-eager mark. This death was NOT daemon-initiated (the daemon only initiates
|
|
1217
|
-
// the idle-reap below) → it died on its own → do NOT write .idle-reaped here.
|
|
1218
|
-
recordDeath(cfg, s.identity, nowMs)
|
|
1219
|
-
removeSessionState(cfg, s.identity)
|
|
1220
|
-
// The .ephemeral-armed mark belongs to THIS (now dead) session — it armed on its
|
|
1221
|
-
// outbound reply. Clear it with the session, so a successor session can never be
|
|
1222
|
-
// quiet-reaped on a stale mark before answering its own task. No-op otherwise.
|
|
1223
|
-
clearEphemeralArmed(cfg, s.identity)
|
|
1224
|
-
// A session that died carrying a .new-eager mark is an owner /new: re-launch
|
|
1225
|
-
// EAGERLY as fresh (not lazily on the next message). The mark is LEFT for the
|
|
1226
|
-
// eager relaunch (processEagerRelaunches) to consume; the daemon timer drives it.
|
|
1227
|
-
if (hasNewEager(cfg, s.identity)) {
|
|
1228
|
-
out.push({
|
|
1229
|
-
identity: s.identity,
|
|
1230
|
-
action: 'needs-eager-fresh',
|
|
1231
|
-
reason: '/new eager mark — eager fresh re-launch',
|
|
1232
|
-
personality: s.personality,
|
|
1233
|
-
runtime: s.runtime,
|
|
1234
|
-
})
|
|
1235
|
-
trace({ identity: s.identity, action: 'needs-eager-fresh', reason: '/new eager mark', outcome: 'eager-fresh' })
|
|
1236
|
-
continue
|
|
1237
|
-
}
|
|
1238
|
-
// Crash / self-close: NO marker written, NO eager relaunch — the peer stays
|
|
1239
|
-
// asleep and wakes FRESH lazily on the next message (resolveWakeMode branch 3a).
|
|
1240
|
-
// The death-class tag (classifyGoneSession) makes the two gone-classes
|
|
1241
|
-
// distinguishable in lifecycle.log: `session-gone` (pane died, server alive →
|
|
1242
|
-
// exits.log should have the cause) vs `server-dead` (whole tmux server died →
|
|
1243
|
-
// exits.log structurally empty; this line is the only durable trace).
|
|
1244
|
-
const gone = classifyGoneSession(sock)
|
|
1245
|
-
out.push({ identity: s.identity, action: 'reaped-gone', reason: gone.reason })
|
|
1246
|
-
trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
|
|
1247
|
-
continue
|
|
1248
|
-
}
|
|
1249
|
-
// Idle accounting via the PANE-LOG (TUI render-stream) mtime — the RELIABLE idle
|
|
1250
|
-
// signal: it advances ~1s while the TUI renders ANY turn (model generation OR a tool
|
|
1251
|
-
// run — the "esc to interrupt" elapsed counter ticks) and goes quiet ONLY at the
|
|
1252
|
-
// prompt. NOT the transcript file mtime: a live claude session RE-TOUCHES its session
|
|
1253
|
-
// .jsonl ~every 30-45min WITHOUT a real turn (a system/bridge re-save), so the
|
|
1254
|
-
// transcript proxy is perpetually < idleSecs and a claude peer NEVER idle-reaps — the
|
|
1255
|
-
// fleet-reap incident: a warm claude pile, last real turn hours ago, never reaped;
|
|
1256
|
-
// codex's session file is NOT re-touched, so codex reaps (the claude-vs-codex split).
|
|
1257
|
-
// The transcript stays the launch ready-gate's signal (it needs "a real turn produced
|
|
1258
|
-
// output"); only this idle proxy moves to the pane-log. FLOORED at wokeAt: a session
|
|
1259
|
-
// cannot be idle longer than it has been awake (a wake that produced no turn →
|
|
1260
|
-
// mt=wokeAt → reaped idleSecs after the wake, never out from under an attaching op).
|
|
1261
|
-
const ephemeral = isEphemeralPeer(s.cwd)
|
|
1262
|
-
const plMt = paneLogMt(s.identity)
|
|
1263
|
-
let mt: number
|
|
1264
|
-
if (plMt !== null) {
|
|
1265
|
-
mt = Math.max(plMt, s.wokeAt)
|
|
1266
|
-
} else {
|
|
1267
|
-
// Pane-log MISSING (a legacy pre-pane-log supervisor) → fall back to the transcript
|
|
1268
|
-
// proxy, NEVER to wokeAt-as-activity: a wokeAt-only age would read an ACTIVE legacy
|
|
1269
|
-
// session's idle from its hours-old wokeAt and reap it MID-WORK. The transcript is
|
|
1270
|
-
// false-fresh at idle (so an IDLE legacy peer lingers warm until its next respawn
|
|
1271
|
-
// earns a pane-log — bounded, self-heals), but an ACTIVE legacy peer keeps a
|
|
1272
|
-
// genuinely-fresh transcript and is correctly protected. Safe direction preserved.
|
|
1273
|
-
let tx = 0
|
|
1274
|
-
try { tx = activityMtime(s.runtime, s.cwd) ?? 0 } catch { /* no adapter → wokeAt */ }
|
|
1275
|
-
mt = Math.max(tx, s.wokeAt)
|
|
1276
|
-
}
|
|
1277
|
-
const ageSecs = Math.floor((nowMs - mt) / 1000)
|
|
1278
|
-
// wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
|
|
1279
|
-
// daemon routed its outbound reply) is reaped after a QUIET window, checked
|
|
1280
|
-
// BEFORE the idle branch (quiet ≪ idle). Quiet = the pane-log silent for
|
|
1281
|
-
// ephemeralQuietSecs (the reliable render signal above) — a still-rendering turn
|
|
1282
|
-
// keeps it fresh, so the worker finishes its turn first; post-reply housekeeping
|
|
1283
|
-
// (operative-note writes) also advances it.
|
|
1284
|
-
// NOT armed (still mid-task) → the ordinary idle bound below is its only reaper.
|
|
1285
|
-
// Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
|
|
1286
|
-
// and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
|
|
1287
|
-
if (ephemeral && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
|
|
1288
|
-
killSession(sock, s.identity)
|
|
1289
|
-
clearEphemeralArmed(cfg, s.identity)
|
|
1290
|
-
removeSessionState(cfg, s.identity)
|
|
1291
|
-
out.push({
|
|
1292
|
-
identity: s.identity,
|
|
1293
|
-
action: 'reaped-ephemeral',
|
|
1294
|
-
reason: `armed, quiet ${ageSecs}s`,
|
|
1295
|
-
personality: s.personality,
|
|
1296
|
-
runtime: s.runtime,
|
|
1297
|
-
})
|
|
1298
|
-
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
|
|
1299
|
-
continue
|
|
1300
|
-
}
|
|
1301
|
-
// UNARMED ephemeral idle bound (live case): a worker that ended
|
|
1302
|
-
// SILENTLY (no final outbound → never armed; or its arm was lost to a CLI/
|
|
1303
|
-
// daemon-restart window) used to wait out the FULL generic idleSecs (1 h) —
|
|
1304
|
-
// and the M3 serial drain waits for the session's death, so ONE silent worker
|
|
1305
|
-
// stalled its whole conveyor. This bound is the defense-in-depth backstop:
|
|
1306
|
-
// ≫ the legitimate silent-tool case (sleep-180), ≪ idleSecs. The ШТАТНЫЙ
|
|
1307
|
-
// silent-finish path is `iapeer self-done` (arm without waking anyone —
|
|
1308
|
-
// the invariant «нет пустых пробуждений» stays intact); this branch only
|
|
1309
|
-
// bounds the damage when a worker does neither. Policy reap: NO .idle-reaped
|
|
1310
|
-
// (ephemeral never resumes), NO recordDeath (the ring counts faults). The pane-log
|
|
1311
|
-
// fold-in above also covers this branch: a STILL-WORKING unarmed worker (a silent
|
|
1312
|
-
// tool, no reply yet) keeps the pane-log fresh via the TUI render, so it is no longer
|
|
1313
|
-
// reaped mid-task — only a genuinely idle/wedged unarmed session crosses this bound.
|
|
1314
|
-
if (ephemeral && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
|
|
1315
|
-
killSession(sock, s.identity)
|
|
1316
|
-
clearEphemeralArmed(cfg, s.identity)
|
|
1317
|
-
removeSessionState(cfg, s.identity)
|
|
1318
|
-
out.push({
|
|
1319
|
-
identity: s.identity,
|
|
1320
|
-
action: 'reaped-ephemeral',
|
|
1321
|
-
reason: `unarmed idle ${ageSecs}s (silent-finish backstop; штатный путь — iapeer self-done)`,
|
|
1322
|
-
personality: s.personality,
|
|
1323
|
-
runtime: s.runtime,
|
|
1324
|
-
})
|
|
1325
|
-
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
|
|
1326
|
-
continue
|
|
1327
|
-
}
|
|
1328
|
-
if (ageSecs > cfg.idleSecs) {
|
|
1329
|
-
// THE ONLY place .idle-reaped is written: this is the one death the daemon
|
|
1330
|
-
// INITIATES. Its presence on the next wake = the session was parked cleanly =
|
|
1331
|
-
// RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
|
|
1332
|
-
// branch above) never writes it → that wakes FRESH (branch 3a).
|
|
1333
|
-
killSession(sock, s.identity)
|
|
1334
|
-
setIdleReaped(cfg, s.identity)
|
|
1335
|
-
removeSessionState(cfg, s.identity)
|
|
1336
|
-
out.push({ identity: s.identity, action: 'reaped-idle', reason: `idle ${ageSecs}s` })
|
|
1337
|
-
trace({ identity: s.identity, action: 'reaped-idle', age: `${ageSecs}s`, outcome: 'resume-eligible' })
|
|
1338
|
-
} else {
|
|
1339
|
-
// Server-death canary retrofit (canary.ts): ensure a canary is watching
|
|
1340
|
-
// every ALIVE daemon-owned server — covers a fleet launched by older code
|
|
1341
|
-
// within one tick of a deploy, no session restarts. Idempotent (pgrep on
|
|
1342
|
-
// the per-identity wait-for channel), best-effort, pure observability.
|
|
1343
|
-
// pty-only: no tmux server-death canary — a hosted death is recorded by the supervisor's
|
|
1344
|
-
// exits.log (host=supervisor, resilience-b), classified by classifyGoneSession on the next sweep.
|
|
1345
|
-
out.push({ identity: s.identity, action: 'alive' })
|
|
1346
|
-
if (verbose) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s` })
|
|
1333
|
+
// Per-peer ISOLATION: a throw while evaluating ONE peer must NOT abort the whole sweep — that is
|
|
1334
|
+
// the silent fleet-wide reap-outage class (one malformed peer-state and NO peer gets reaped, the
|
|
1335
|
+
// error swallowed by the daemon's supervise-tick catch). Catch it, log it loudly to lifecycle.log,
|
|
1336
|
+
// and continue to the next peer — the rest of the fleet is still swept.
|
|
1337
|
+
try {
|
|
1338
|
+
out.push(superviseOnePeer(s, ctx))
|
|
1339
|
+
} catch (e) {
|
|
1340
|
+
const reason = e instanceof Error ? e.message : String(e)
|
|
1341
|
+
out.push({ identity: s.identity, action: 'skipped-error', reason })
|
|
1342
|
+
trace({ identity: s.identity, action: 'skipped-error', reason, outcome: 'peer-error-isolated' })
|
|
1347
1343
|
}
|
|
1348
1344
|
}
|
|
1349
1345
|
return out
|
package/src/transport/index.ts
CHANGED
|
@@ -152,28 +152,25 @@ export function resolvePeerDeliveryTarget(
|
|
|
152
152
|
return resolveDeliveryTarget({ personality })
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// is not rendered). Env-tunable for LIVE calibration (busy/idle/cold). Conservative
|
|
158
|
-
// default — long enough that a busy-but-alive session reliably shows an advance,
|
|
159
|
-
// short enough that a genuinely dead session is failed promptly. Prefer a false-
|
|
160
|
-
// FAIL (sender retries) over a false-OK (silent loss, which the contract forbids).
|
|
155
|
+
// Confirm poll interval — how often deliverViaHost re-reads the transcript for a NEW record carrying
|
|
156
|
+
// our envelope while waiting out the grace.
|
|
161
157
|
const LIVENESS_POLL_MS = 100
|
|
162
|
-
function livenessGraceMs(): number {
|
|
163
|
-
const raw = process.env.IAP_LIVENESS_GRACE_MS
|
|
164
|
-
const n = raw === undefined ? NaN : Number(raw)
|
|
165
|
-
return Number.isFinite(n) && n >= 0 ? n : 3000
|
|
166
|
-
}
|
|
167
158
|
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
159
|
+
// Confirm grace — how long deliverViaHost polls for a transcript record CARRYING our envelope before
|
|
160
|
+
// declaring a false-FAIL. Per-RUNTIME because the runtimes log an accepted injection at different
|
|
161
|
+
// times: claude writes the queue-operation (busy) / user-turn (idle) record PROMPTLY (sub-second), so
|
|
162
|
+
// a short grace suffices; codex writes its session-jsonl user-input only at model-turn-START (TTFT
|
|
163
|
+
// measured ~4-6s after submit), so it needs a longer window or every codex delivery would false-FAIL
|
|
164
|
+
// before the first write. `IAP_HOST_LIVENESS_GRACE_MS` overrides BOTH (operator calibration; tests set
|
|
165
|
+
// 0 for an immediate deterministic fail). Prefer a false-FAIL (sender retries) over a false-OK (silent
|
|
166
|
+
// loss, which the contract forbids).
|
|
167
|
+
const CONFIRM_GRACE_CLAUDE_MS = 4000
|
|
168
|
+
const CONFIRM_GRACE_CODEX_MS = 8000
|
|
169
|
+
function confirmGraceMs(runtime: string): number {
|
|
174
170
|
const raw = process.env.IAP_HOST_LIVENESS_GRACE_MS
|
|
175
171
|
const n = raw === undefined ? NaN : Number(raw)
|
|
176
|
-
|
|
172
|
+
if (Number.isFinite(n) && n >= 0) return n
|
|
173
|
+
return runtime === 'codex' ? CONFIRM_GRACE_CODEX_MS : CONFIRM_GRACE_CLAUDE_MS
|
|
177
174
|
}
|
|
178
175
|
|
|
179
176
|
// Launchd-revive retry — a MISS on a launchd-managed target the daemon can't wake (H4)
|
|
@@ -189,28 +186,31 @@ function launchdReviveGraceMs(): number {
|
|
|
189
186
|
|
|
190
187
|
// ─── Warm delivery (pty-only — supervisor socket) ────────────────────────────────────────────────
|
|
191
188
|
|
|
192
|
-
/** Test seam for `deliverWarm`. Production defaults route on the real supervisor
|
|
193
|
-
*
|
|
194
|
-
* Omitting all fields = pure production
|
|
189
|
+
/** Test seam for `deliverWarm`. Production defaults route on the real supervisor + the real
|
|
190
|
+
* message-specific transcript confirm; tests inject a fake host-deliver + a synthetic landed-confirm,
|
|
191
|
+
* so the deliver path is covered with no live daemon. Omitting all fields = pure production. */
|
|
195
192
|
export interface WarmDeliverSeam {
|
|
196
193
|
/** Deliver to the hosted session over its socket. Default: the real `deliverHosted` (Ф0a leaf). */
|
|
197
194
|
deliverHosted?: (identity: string, envelope: string) => Promise<DeliverResult>
|
|
198
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
*
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
*
|
|
205
|
-
|
|
195
|
+
/** MESSAGE-SPECIFIC landed-confirm: did a NEW transcript/session-jsonl record CARRYING this envelope
|
|
196
|
+
* appear since the pre-deliver baseline? Default: `transcriptCarriesEnvelope` over a file-size
|
|
197
|
+
* baseline captured before the send. A bare mtime bump (the receiver's OWN turn) is NOT a confirm —
|
|
198
|
+
* that was the false-OK class this replaces. */
|
|
199
|
+
confirmLanded?: (envelope: string) => boolean
|
|
200
|
+
/** Injectable async sleep for the confirm poll (tests). Default: setTimeout — it YIELDS the event
|
|
201
|
+
* loop (the grace is up to several seconds for codex, so a sync block would stall the whole router
|
|
202
|
+
* daemon for the duration of one delivery). */
|
|
203
|
+
sleep?: (ms: number) => Promise<void>
|
|
206
204
|
}
|
|
207
205
|
|
|
208
206
|
/**
|
|
209
207
|
* Warm-deliver `envelope` to a LIVE local target over its supervisor socket (pty-only). Landing is
|
|
210
|
-
* CONFIRMED by a transcript
|
|
211
|
-
* CR proves the bytes left us,
|
|
212
|
-
*
|
|
213
|
-
*
|
|
208
|
+
* CONFIRMED MESSAGE-SPECIFICALLY — by a NEW transcript record CARRYING this envelope appearing past the
|
|
209
|
+
* pre-deliver baseline, NOT the socket-ack (a flushed CR proves the bytes left us, not that the session
|
|
210
|
+
* accepted them) and NOT a bare mtime advance (the receiver's OWN concurrent turn bumps mtime even when
|
|
211
|
+
* our paste was swallowed — the false-OK class the contract forbids). A router (telegram/notifier) has
|
|
212
|
+
* no transcript → confirmed by the socket-ack (liveness is structural via launchd KeepAlive). See
|
|
213
|
+
* `deliverViaHost`.
|
|
214
214
|
*/
|
|
215
215
|
export async function deliverWarm(
|
|
216
216
|
target: DeliveryTarget,
|
|
@@ -228,13 +228,10 @@ async function deliverViaHost(
|
|
|
228
228
|
seam: WarmDeliverSeam,
|
|
229
229
|
): Promise<Result<void>> {
|
|
230
230
|
const adapter = getAdapter(target.runtime)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
// proves the session is alive and took our message.
|
|
236
|
-
const baseline = cwd ? mtimeOf(cwd) ?? 0 : 0
|
|
237
|
-
const paneBaseline = paneMtimeOf(target.address) ?? 0
|
|
231
|
+
// Baseline the transcript/session-jsonl file SIZES BEFORE delivery (per-runtime candidate files).
|
|
232
|
+
// The landed-confirm reads ONLY the bytes appended past this baseline, so a record carrying our
|
|
233
|
+
// envelope can only be one written in RESPONSE to this delivery — never a pre-existing copy.
|
|
234
|
+
const baseline = cwd ? compactDoneBaseline(target.runtime, cwd) : null
|
|
238
235
|
const send = seam.deliverHosted ?? deliverHosted
|
|
239
236
|
const sent = await send(target.address, envelope)
|
|
240
237
|
if (!sent.ok) {
|
|
@@ -243,45 +240,42 @@ async function deliverViaHost(
|
|
|
243
240
|
`message NOT delivered`,
|
|
244
241
|
)
|
|
245
242
|
}
|
|
246
|
-
// Router (telegram/notifier): no transcript proxy
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
// delivery-level confirm; a router's liveness is structural via launchd KeepAlive, not a model
|
|
250
|
-
// turn). See deliverViaTmux router branch (paste-buffer -r + C-j, no transcript confirm) + its
|
|
251
|
-
// header note "router … liveness is structural".
|
|
243
|
+
// Router (telegram/notifier): no transcript proxy → confirmed by the socket-ack just obtained. A
|
|
244
|
+
// router's liveness is structural via launchd KeepAlive (not a model turn), so the ack IS the
|
|
245
|
+
// delivery-level confirm (parity with the legacy tmux router C-j path).
|
|
252
246
|
if (adapter.kind === 'router') return ok(undefined)
|
|
253
|
-
// socket-ack ≠ landed. With no
|
|
254
|
-
//
|
|
255
|
-
if (!cwd) return ok(undefined)
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// during a turn). Prefer a false-FAIL (sender retries) over a false-OK (silent loss).
|
|
269
|
-
const graceMs = hostLivenessGraceMs()
|
|
247
|
+
// socket-ack ≠ landed. With no transcript (direct callers/tests without a cwd) we can only trust the
|
|
248
|
+
// flushed submit — confirmed-only.
|
|
249
|
+
if (!cwd || !baseline) return ok(undefined)
|
|
250
|
+
// MESSAGE-SPECIFIC landed-confirm — the false-OK killer. A bare transcript/pane-log mtime BUMP is
|
|
251
|
+
// NOT proof the session took THIS message: a receiver in an active turn advances both mtimes with
|
|
252
|
+
// its OWN rendering even when our paste was swallowed at the turn boundary (incident 2026-06-23:
|
|
253
|
+
// ok=true, message gone). The ONLY proof is a NEW transcript record CARRYING our envelope —
|
|
254
|
+
// claude: a queue-operation `content` (busy → enqueued) or the user-turn message (idle),
|
|
255
|
+
// codex: a user-input response_item (written at model-turn-start, ~4-6s — hence the codex grace).
|
|
256
|
+
// The receiver's own assistant/tool turn never reproduces the `<iap from-personality=…>` wrapper, so
|
|
257
|
+
// it cannot forge a confirm. No such record within the grace → false-FAIL (the sender retries) — NOT
|
|
258
|
+
// a false-OK (silent loss the contract forbids).
|
|
259
|
+
const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload))
|
|
260
|
+
const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
|
|
261
|
+
const graceMs = confirmGraceMs(target.runtime)
|
|
270
262
|
const graceDeadline = monotonicMs() + graceMs
|
|
271
|
-
|
|
272
|
-
if ((
|
|
273
|
-
if ((
|
|
274
|
-
|
|
275
|
-
}
|
|
263
|
+
while (true) {
|
|
264
|
+
if (confirm(envelope)) return ok(undefined)
|
|
265
|
+
if (monotonicMs() >= graceDeadline) break
|
|
266
|
+
await sleep(LIVENESS_POLL_MS)
|
|
267
|
+
}
|
|
276
268
|
return err(
|
|
277
269
|
`hosted peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
|
|
278
|
-
`(no transcript
|
|
270
|
+
`(no transcript record carrying the message within ${graceMs}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
|
|
279
271
|
)
|
|
280
272
|
}
|
|
281
273
|
|
|
282
274
|
/** Pane-log (TUI render-stream) mtime for a process address, or null when absent/unreadable.
|
|
283
275
|
* The pane-log is the canonical `<root>/logs/lifecycle/<address>.log` the supervisor appends raw
|
|
284
|
-
* pty bytes to (§1 contract). Used
|
|
276
|
+
* pty bytes to (§1 contract). Used by `resolveLiveRuntime` (freshest-pane-log among alive sessions).
|
|
277
|
+
* NB: deliberately NOT used to confirm delivery — a pane-log mtime bump is not message-specific (the
|
|
278
|
+
* receiver's own turn bumps it too), which was exactly the false-OK class deliverViaHost now avoids. */
|
|
285
279
|
function defaultPaneLogMtime(address: string): number | null {
|
|
286
280
|
try {
|
|
287
281
|
return statSync(join(resolveGlobalRoot(), 'logs', 'lifecycle', `${address}.log`)).mtimeMs
|
|
@@ -492,6 +486,65 @@ export function compactTranscriptHasDone(
|
|
|
492
486
|
return false
|
|
493
487
|
}
|
|
494
488
|
|
|
489
|
+
/** Deep-search every string value of a parsed JSON record for `needle` (CR-stripped on the haystack
|
|
490
|
+
* side; the needle is pre-stripped by the caller). */
|
|
491
|
+
function jsonStringValuesContain(value: unknown, needle: string): boolean {
|
|
492
|
+
if (typeof value === 'string') return value.replace(/\r/g, '').includes(needle)
|
|
493
|
+
if (Array.isArray(value)) return value.some(v => jsonStringValuesContain(v, needle))
|
|
494
|
+
if (value && typeof value === 'object') return Object.values(value).some(v => jsonStringValuesContain(v, needle))
|
|
495
|
+
return false
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Did a NEW transcript/session-jsonl record CARRYING `envelope` appear since `baseline` (the
|
|
500
|
+
* per-runtime absolute file paths + SIZES captured immediately before delivery)? This is the
|
|
501
|
+
* MESSAGE-SPECIFIC landed-confirm `deliverViaHost` uses to kill the false-OK class — a bare mtime
|
|
502
|
+
* advance from the receiver's OWN turn is NOT a confirm; only a record echoing our envelope is.
|
|
503
|
+
*
|
|
504
|
+
* Reads ONLY the bytes appended past each baseline offset, JSON-parses each new jsonl line, and
|
|
505
|
+
* deep-searches its string values for the envelope (CR-normalized on both sides — a stored line may
|
|
506
|
+
* carry CR the envelope never had; CR is never semantically meaningful inside an IAP envelope). A
|
|
507
|
+
* match means the SESSION recorded our injection:
|
|
508
|
+
* claude — busy: a `queue-operation` whose `content` is the pasted envelope verbatim; idle: the
|
|
509
|
+
* user-turn message.
|
|
510
|
+
* codex — a user-input `response_item` (written at model-turn-start).
|
|
511
|
+
* The receiver's own assistant/tool records never reproduce the `<iap from-personality=…>` wrapper, so
|
|
512
|
+
* the full envelope is UNFORGEABLE by a concurrent turn — no false-OK. Because only bytes PAST the
|
|
513
|
+
* baseline offset are read, a pre-existing copy of an identical earlier message cannot pass either.
|
|
514
|
+
*
|
|
515
|
+
* Iterates the baseline file LIST verbatim — it does NOT re-scan the runtime's session dir each poll
|
|
516
|
+
* (unlike the minutes-long compact-done gate). A delivery's confirm window is seconds, so a brand-new
|
|
517
|
+
* session file appearing mid-delivery (a resume mid-flight) is negligible, and missing it fails in the
|
|
518
|
+
* SAFE direction (false-FAIL → the sender retries, never a silent loss). For codex this also avoids a
|
|
519
|
+
* full recursive `~/.codex/sessions` re-scan on every ~100ms poll (the active session file is already
|
|
520
|
+
* in the baseline), keeping the hot delivery path cheap.
|
|
521
|
+
*/
|
|
522
|
+
export function transcriptCarriesEnvelope(baseline: CompactDoneBaseline, envelope: string): boolean {
|
|
523
|
+
const needle = envelope.replace(/\r/g, '')
|
|
524
|
+
if (!needle) return false
|
|
525
|
+
for (const { path, size: offset } of baseline.files) {
|
|
526
|
+
let buf: Buffer
|
|
527
|
+
try {
|
|
528
|
+
buf = readFileSync(path)
|
|
529
|
+
} catch {
|
|
530
|
+
continue
|
|
531
|
+
}
|
|
532
|
+
const start = Math.min(Math.max(0, offset), buf.length)
|
|
533
|
+
const tail = buf.subarray(start).toString('utf8')
|
|
534
|
+
for (const line of tail.split(/\r?\n/)) {
|
|
535
|
+
if (!line.trim()) continue
|
|
536
|
+
let obj: unknown
|
|
537
|
+
try {
|
|
538
|
+
obj = JSON.parse(line)
|
|
539
|
+
} catch {
|
|
540
|
+
continue
|
|
541
|
+
}
|
|
542
|
+
if (jsonStringValuesContain(obj, needle)) return true
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return false
|
|
546
|
+
}
|
|
547
|
+
|
|
495
548
|
function compactDoneTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
|
|
496
549
|
const raw = env?.IAPEER_COMPACT_DONE_TIMEOUT_MS
|
|
497
550
|
const n = raw === undefined ? NaN : Number(raw)
|