@agfpd/iapeer 0.4.7 → 0.4.9
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/cli/index.ts +13 -80
- package/src/cli/listTui.ts +4 -14
- package/src/launch/cmdlog.ts +7 -110
- package/src/launch/index.ts +14 -314
- package/src/launch/launchdRun.ts +9 -52
- package/src/launch/ptyHost.ts +0 -12
- package/src/launch/readyGateModel.ts +9 -80
- package/src/lifecycle/index.ts +25 -101
- package/src/supervisor/daemon.ts +35 -4
- package/src/{shadow → supervisor}/render.ts +10 -10
- package/src/transport/index.ts +41 -400
- package/src/update/index.ts +0 -9
- package/src/launch/canary.ts +0 -239
- package/src/shadow/gate.ts +0 -396
- package/src/shadow/index.ts +0 -174
- package/src/shadow/install.ts +0 -256
package/src/lifecycle/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from '../core/constants.ts'
|
|
27
27
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
28
28
|
import { err, ok, type Result } from '../core/errors.ts'
|
|
29
|
-
import {
|
|
29
|
+
import { capPaneLogs } from '../launch/cmdlog.ts'
|
|
30
30
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
31
31
|
import { readPeerProfile, resolveIdentity } from '../identity/index.ts'
|
|
32
32
|
import {
|
|
@@ -47,8 +47,7 @@ import {
|
|
|
47
47
|
type LaunchSpec,
|
|
48
48
|
} from '../launch/index.ts'
|
|
49
49
|
import { composeSystemPrompt, gatherPromptInput } from '../launch/composeSystemPrompt.ts'
|
|
50
|
-
import {
|
|
51
|
-
import { hostSessionAlive, killPtyHost, ptyHostEnabled } from '../launch/ptyHost.ts'
|
|
50
|
+
import { hostSessionAlive, killPtyHost } from '../launch/ptyHost.ts'
|
|
52
51
|
import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
|
|
53
52
|
|
|
54
53
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -494,18 +493,6 @@ export function purgeIdentityState(cfg: LifecycleConfig, identity: string): stri
|
|
|
494
493
|
/* best-effort — a locked/vanished entry must not fail the remove */
|
|
495
494
|
}
|
|
496
495
|
}
|
|
497
|
-
// tmux command-log dir (0.2.40 observability) — identity-keyed like the state
|
|
498
|
-
// markers above, so it must die with the registry record too (the leak class
|
|
499
|
-
// noted at the 0.2.40 acceptance: a removed peer left its cmdlog dir forever).
|
|
500
|
-
try {
|
|
501
|
-
const cmdDir = cmdLogDirFor(cfg.eventLogDir, identity)
|
|
502
|
-
if (existsSync(cmdDir)) {
|
|
503
|
-
rmSync(cmdDir, { recursive: true, force: true })
|
|
504
|
-
removed.push(`tmux-cmdlog/${identity}`)
|
|
505
|
-
}
|
|
506
|
-
} catch {
|
|
507
|
-
/* best-effort */
|
|
508
|
-
}
|
|
509
496
|
return removed
|
|
510
497
|
}
|
|
511
498
|
|
|
@@ -724,52 +711,23 @@ export async function withWakeLock<T>(
|
|
|
724
711
|
}
|
|
725
712
|
|
|
726
713
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
727
|
-
//
|
|
714
|
+
// liveness
|
|
728
715
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
729
716
|
|
|
730
|
-
function
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
* SIGKILL-class and exits.log stayed empty, because the pane-died hook needs a
|
|
745
|
-
* living tmux event loop). Two distinguishable classes:
|
|
746
|
-
* - `session-gone` — the server on the socket still ANSWERS but the session is not
|
|
747
|
-
* there: a pane died inside a living server → the pane-died hook had its chance,
|
|
748
|
-
* exits.log should carry the cause.
|
|
749
|
-
* - `server-dead` — the server itself is gone: the socket file is missing, or it
|
|
750
|
-
* exists but nothing serves it (stale socket — the SIGKILL/OOM class). pane-died
|
|
751
|
-
* could never fire; the server-death canary (launch/canary.ts) is the recorder
|
|
752
|
-
* for this class (`ev=server-exit` + forensics), this line is the backstop.
|
|
753
|
-
* `hosted=true` (pty-supervisor peer) reframes ONLY the absent-socket reason: a hosted peer
|
|
754
|
-
* has NO tmux socket, so that branch would otherwise misattribute its death to a tmux server
|
|
755
|
-
* that never existed. Its real cause lives in the supervisor exits.log. A socket that EXISTS
|
|
756
|
-
* is a genuine tmux server, so `hosted` never overrides the session-gone / stale-socket arm. */
|
|
757
|
-
export function classifyGoneSession(sock: string, hosted = false): { death: 'server-dead' | 'session-gone'; reason: string } {
|
|
758
|
-
if (!existsSync(sock)) {
|
|
759
|
-
// No socket file. For a pty-supervisor-hosted peer this is EXPECTED — it never had a tmux
|
|
760
|
-
// socket; its death is the supervisor session ending (cause in the supervisor exits.log
|
|
761
|
-
// death-EVENT), NOT a tmux server vanishing. For a tmux peer it IS a dead tmux server.
|
|
762
|
-
// The check is gated on the absent socket on purpose: a socket that EXISTS (below) means a
|
|
763
|
-
// real tmux server, so `hosted` (pty-enabled-by-default) must not override genuine tmux.
|
|
764
|
-
return hosted
|
|
765
|
-
? { death: 'server-dead', reason: 'pty host session ended (supervisor) — cause in exits.log (death-EVENT)' }
|
|
766
|
-
: { death: 'server-dead', reason: 'tmux server gone (socket file missing)' }
|
|
767
|
-
}
|
|
768
|
-
// Ask the SERVER, not the session: any server-level command answering (exit 0)
|
|
769
|
-
// proves the server is alive and merely lost this session.
|
|
770
|
-
return tmux(sock, 'list-sessions').ok
|
|
771
|
-
? { death: 'session-gone', reason: 'session gone, tmux server alive (exit cause should be in exits.log)' }
|
|
772
|
-
: { death: 'server-dead', reason: 'tmux server dead — stale socket (SIGKILL class — OOM only if a matching JetsamEvent report exists; check exits.log ev=server-exit canary record)' }
|
|
717
|
+
function sessionAlive(_sock: string, identity: string): boolean {
|
|
718
|
+
// pty-only: a peer is alive iff its supervisor session is live (a cheap pid-file check). Routing
|
|
719
|
+
// liveness on the live session (not the `.pty-host` spawn-intent marker) keeps a marker rollback safe
|
|
720
|
+
// on a still-running session. (`_sock` retained for call-site compatibility; no tmux sockets exist.)
|
|
721
|
+
return hostSessionAlive(identity)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Death-class tag for a gone session (pty-only). A gone peer is a supervisor session that ended; its
|
|
725
|
+
* real cause lives in the supervisor exits.log (death-EVENT). There is no tmux server to distinguish
|
|
726
|
+
* `session-gone` (pane died, server alive) from `server-dead` (server gone). This tag is
|
|
727
|
+
* observability-only (lifecycle.log trace) and drives no decision. The `death`/`hosted` shape is kept
|
|
728
|
+
* for the trace schema + call-site compatibility. */
|
|
729
|
+
export function classifyGoneSession(_sock: string, _hosted = false): { death: 'server-dead' | 'session-gone'; reason: string } {
|
|
730
|
+
return { death: 'server-dead', reason: 'pty host session ended (supervisor) — cause in exits.log (death-EVENT)' }
|
|
773
731
|
}
|
|
774
732
|
|
|
775
733
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1149,26 +1107,11 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1149
1107
|
// Reap — kill a session (used by idle-reap / supervise; H4-guarded by callers)
|
|
1150
1108
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1151
1109
|
|
|
1152
|
-
export function killSession(
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1157
|
-
tmux(sock, 'kill-session', '-t', identity)
|
|
1158
|
-
const sessions = tmux(sock, 'list-sessions', '-F', '#{session_name}').out
|
|
1159
|
-
if (!sessions.trim()) {
|
|
1160
|
-
// Deliberate server teardown → silence the canary EXPLICITLY first: signal
|
|
1161
|
-
// the channel (client exits 0) AND dismiss the sh recorder (canary v2 no
|
|
1162
|
-
// longer reads a signaled client alone as deliberate — see canary.ts).
|
|
1163
|
-
signalCanaryClean(sock, identity)
|
|
1164
|
-
dismissCanary(identity)
|
|
1165
|
-
tmux(sock, 'kill-server')
|
|
1166
|
-
try {
|
|
1167
|
-
rmSync(sock, { force: true })
|
|
1168
|
-
} catch {
|
|
1169
|
-
/* best-effort */
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1110
|
+
export function killSession(_sock: string, identity: string): void {
|
|
1111
|
+
// pty-only: SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and removes the
|
|
1112
|
+
// sock/pid/serve-spec/markers. Idempotent if the session is already gone. (`_sock` retained for
|
|
1113
|
+
// call-site compatibility; no tmux sockets exist.)
|
|
1114
|
+
killPtyHost(identity)
|
|
1172
1115
|
}
|
|
1173
1116
|
|
|
1174
1117
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1212,11 +1155,6 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1212
1155
|
const activityMtime = deps.newestActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).newestActivityMtime(c))
|
|
1213
1156
|
const isAlive = deps.sessionAlive ?? sessionAlive
|
|
1214
1157
|
const verbose = superviseLogVerbose(env)
|
|
1215
|
-
// tmux command-log volume cap (killer-hunt socket-command layer, launch/cmdlog.ts):
|
|
1216
|
-
// a -v server log under a busy TUI grows ~7 KB/s, so each tick tail-keeps any log
|
|
1217
|
-
// over the cap. Best-effort by construction; the killer's command is the LAST
|
|
1218
|
-
// line of a dying log, so capping a LIVE peer's log never costs the death tail.
|
|
1219
|
-
capCmdLogs(cfg.eventLogDir)
|
|
1220
1158
|
// Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
|
|
1221
1159
|
// (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
|
|
1222
1160
|
// append for a session's whole life — it had NO bound and grew to hundreds of MB
|
|
@@ -1283,7 +1221,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1283
1221
|
// distinguishable in lifecycle.log: `session-gone` (pane died, server alive →
|
|
1284
1222
|
// exits.log should have the cause) vs `server-dead` (whole tmux server died →
|
|
1285
1223
|
// exits.log structurally empty; this line is the only durable trace).
|
|
1286
|
-
const gone = classifyGoneSession(sock
|
|
1224
|
+
const gone = classifyGoneSession(sock)
|
|
1287
1225
|
out.push({ identity: s.identity, action: 'reaped-gone', reason: gone.reason })
|
|
1288
1226
|
trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
|
|
1289
1227
|
continue
|
|
@@ -1366,22 +1304,8 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1366
1304
|
// every ALIVE daemon-owned server — covers a fleet launched by older code
|
|
1367
1305
|
// within one tick of a deploy, no session restarts. Idempotent (pgrep on
|
|
1368
1306
|
// the per-identity wait-for channel), best-effort, pure observability.
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1371
|
-
// alive server means the previous canary VANISHED mid-watch (the death-#4
|
|
1372
|
-
// blind spot was exactly this churn being invisible); 'failed' means the
|
|
1373
|
-
// server is currently UNWATCHED. At most one line per loss, not per tick.
|
|
1374
|
-
// SPAWN-FLIP Ф0b-3: the tmux server-death canary is meaningless for a supervisor-HOSTED session
|
|
1375
|
-
// (no tmux server — the retrofit arms a watcher that immediately fires a spurious
|
|
1376
|
-
// `server-vanished`). A hosted death is recorded by the supervisor's exits.log (host=supervisor,
|
|
1377
|
-
// resilience-b), so skip the canary retrofit for hosted peers (mirror of the observer .pty-host
|
|
1378
|
-
// skip — tmux-assuming infra must not run against a hosted session).
|
|
1379
|
-
if (!hostSessionAlive(s.identity)) {
|
|
1380
|
-
const canaryState = ensureServerCanary({ identity: s.identity, sock, exitLogDir: cfg.eventLogDir, env })
|
|
1381
|
-
if (canaryState !== 'already') {
|
|
1382
|
-
trace({ identity: s.identity, action: 'canary', state: canaryState, origin: 'retrofit' })
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1307
|
+
// pty-only: no tmux server-death canary — a hosted death is recorded by the supervisor's
|
|
1308
|
+
// exits.log (host=supervisor, resilience-b), classified by classifyGoneSession on the next sweep.
|
|
1385
1309
|
out.push({ identity: s.identity, action: 'alive' })
|
|
1386
1310
|
if (verbose) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s` })
|
|
1387
1311
|
}
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// @xterm/headless bundles under `bun build --compile` (pure JS; the Bun-native pty has no native addon).
|
|
13
13
|
import { Terminal } from '@xterm/headless'
|
|
14
14
|
import { SerializeAddon } from '@xterm/addon-serialize'
|
|
15
|
-
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
|
|
15
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
|
|
16
16
|
import { dirname } from 'node:path'
|
|
17
17
|
import {
|
|
18
18
|
BackpressureWriter,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from './protocol.ts'
|
|
27
27
|
import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
|
|
28
28
|
import { nextBootAction, type BootPredicates } from './boot.ts'
|
|
29
|
-
import { modelToPlainText } from '
|
|
29
|
+
import { modelToPlainText } from './render.ts'
|
|
30
30
|
import { codexAdapter } from '../launch/adapters/codex.ts'
|
|
31
31
|
import { claudeAdapter } from '../launch/adapters/claude.ts'
|
|
32
32
|
|
|
@@ -157,14 +157,43 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
157
157
|
// corrupts multibyte glyphs like ❯/›; the canonical latin1/Buffer grabli). Parent dir 0700, matching
|
|
158
158
|
// launch's mkdir before pipe-pane. Unset paneLogPath → no log (throwaway/tick).
|
|
159
159
|
let paneLogFd = -1
|
|
160
|
-
|
|
160
|
+
let paneLogIno = -1 // inode the fd points at — to detect an out-from-under-us unlink/replace
|
|
161
|
+
let paneLogCheckedMs = 0
|
|
162
|
+
const PANELOG_HEAL_MS = 1000 // throttle the path-existence re-check to ≤1/sec regardless of output volume
|
|
163
|
+
const openPaneLog = (): void => {
|
|
164
|
+
if (!opts.paneLogPath) return
|
|
161
165
|
try {
|
|
162
166
|
mkdirSync(dirname(opts.paneLogPath), { recursive: true, mode: 0o700 })
|
|
167
|
+
if (paneLogFd >= 0) try { closeSync(paneLogFd) } catch { /* */ }
|
|
163
168
|
paneLogFd = openSync(opts.paneLogPath, 'a')
|
|
169
|
+
paneLogIno = fstatSync(paneLogFd).ino
|
|
164
170
|
} catch {
|
|
165
171
|
paneLogFd = -1 // best-effort: a log open hiccup never blocks serving the session
|
|
172
|
+
paneLogIno = -1
|
|
166
173
|
}
|
|
167
174
|
}
|
|
175
|
+
// SELF-HEAL (Defect 2): the pane-log path can be unlinked out from under a live session (observed on
|
|
176
|
+
// the fleet: long-lived sessions' lifecycle pane-logs vanished while the supervisor ran). The fd then
|
|
177
|
+
// keeps writing to a now-unlinked inode — the path stays gone and every occupancy reader of
|
|
178
|
+
// `<logDir>/<identity>.log` goes blind: composer-busy delivery detection AND telegram-runtime's
|
|
179
|
+
// mtime-based typing/activity indicator (shared file). Throttled re-check before a write: if the path
|
|
180
|
+
// vanished or now maps to a DIFFERENT inode than the fd, reopen 'a'. capPaneLogs truncates IN PLACE
|
|
181
|
+
// (same inode) so a normal cap never triggers a spurious reopen. Tied to output (no idle wakeups) —
|
|
182
|
+
// exactly when the log matters.
|
|
183
|
+
const healPaneLog = (): void => {
|
|
184
|
+
if (!opts.paneLogPath || paneLogFd < 0) return
|
|
185
|
+
const now = Date.now()
|
|
186
|
+
if (now - paneLogCheckedMs < PANELOG_HEAL_MS) return
|
|
187
|
+
paneLogCheckedMs = now
|
|
188
|
+
let ino = -1
|
|
189
|
+
try {
|
|
190
|
+
ino = statSync(opts.paneLogPath).ino
|
|
191
|
+
} catch {
|
|
192
|
+
ino = -1 // path gone
|
|
193
|
+
}
|
|
194
|
+
if (ino !== paneLogIno) openPaneLog()
|
|
195
|
+
}
|
|
196
|
+
openPaneLog()
|
|
168
197
|
|
|
169
198
|
const clients = new Set<unknown>()
|
|
170
199
|
const writers = new WeakMap<object, BackpressureWriter>()
|
|
@@ -234,12 +263,14 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
234
263
|
const buf = Buffer.isBuffer(d) ? d : typeof d === 'string' ? Buffer.from(d, 'utf8') : Buffer.from(d)
|
|
235
264
|
// PANE-LOG: the RAW child bytes, exactly as pipe-pane writes them (before any query-stripping
|
|
236
265
|
// for clients) — written as a Buffer, never a string. The `s` below is for query DETECTION only.
|
|
237
|
-
if (paneLogFd >= 0)
|
|
266
|
+
if (paneLogFd >= 0) {
|
|
267
|
+
healPaneLog() // reopen if the path was unlinked/replaced out from under us (Defect 2)
|
|
238
268
|
try {
|
|
239
269
|
writeSync(paneLogFd, buf)
|
|
240
270
|
} catch {
|
|
241
271
|
/* best-effort */
|
|
242
272
|
}
|
|
273
|
+
}
|
|
243
274
|
const s = buf.toString('latin1')
|
|
244
275
|
writeSeq++ // a new write arrived (lets the attach drain detect bytes that land mid-await)
|
|
245
276
|
writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Leaf render helper: @xterm model → plain text
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Leaf render helper: @xterm model → plain text + composer-occupancy verdict. A shared LIVE leaf used
|
|
2
|
+
// by the Ф1 ready-gate flip (launch/readyGateModel) and the supervisor pane model. Type-only @xterm
|
|
3
|
+
// import → no runtime dependency beyond the Terminal instance the caller already holds; hermetically
|
|
4
|
+
// testable. (Formerly src/shadow/render.ts — kept after the tmux→pty cutover retired the shadow
|
|
5
|
+
// burn-in observer; only the observer modules were removed, this leaf survives.)
|
|
5
6
|
import type { Terminal } from '@xterm/headless'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -10,7 +11,7 @@ import type { Terminal } from '@xterm/headless'
|
|
|
10
11
|
* whitespace per line is trimmed (capture-pane does the same), cells coalesced left-to-right.
|
|
11
12
|
*
|
|
12
13
|
* 0-divergence-validated vs capture-pane on READY screens by the ready-gate burn-in; the supervisor
|
|
13
|
-
* boot-driver additionally
|
|
14
|
+
* boot-driver additionally validated it on BOOT-DIALOG screens (a different screen state the
|
|
14
15
|
* ready-gate observation never saw).
|
|
15
16
|
*/
|
|
16
17
|
export function modelToPlainText(term: Terminal, cols: number, rows: number): string {
|
|
@@ -40,11 +41,10 @@ const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
|
40
41
|
/**
|
|
41
42
|
* PTY composer-occupancy verdict: getCell on the model's viewport composer row (the row whose first
|
|
42
43
|
* visible cell is the prompt glyph); human input = a non-ghost (non-dim, non-grey246) visible cell
|
|
43
|
-
* after it. Mirrors
|
|
44
|
-
* in this leaf (type-only @xterm) so the
|
|
45
|
-
* pulling
|
|
46
|
-
*
|
|
47
|
-
* shadow/index.ts so the observer's existing importers keep resolving.
|
|
44
|
+
* after it. Mirrors the prior tmux capture-pane occupancy check (validated 0-divergence vs it by the
|
|
45
|
+
* burn-in). Lives in this leaf (type-only @xterm) so the warm-deliver hosted occupancy guard can reuse
|
|
46
|
+
* it WITHOUT pulling an @xterm-loading graph into the warm-deliver path; the Terminal instance is built
|
|
47
|
+
* by the caller (paneLogComposerOccupied dynamic-imports @xterm).
|
|
48
48
|
*/
|
|
49
49
|
export function composerOccupancyFromModel(term: Terminal, cols: number, rows: number, runtime: string): boolean {
|
|
50
50
|
const b = term.buffer.active, top = b.baseY, bot = b.baseY + rows, g = promptGlyph(runtime)
|