@agfpd/iapeer 0.2.59 → 0.2.60
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/shadow/gate.test.ts +143 -0
- package/src/shadow/gate.ts +306 -0
- package/src/shadow/index.ts +47 -28
package/package.json
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
GateTracker,
|
|
4
|
+
classifyDirection,
|
|
5
|
+
classifySampleState,
|
|
6
|
+
type PeerSample,
|
|
7
|
+
} from './gate.ts'
|
|
8
|
+
|
|
9
|
+
// SYNTHETIC ACCEPTANCE (boris's Block-1 gate): prove the instrument classifies known-behind vs
|
|
10
|
+
// known-ahead and records coverage CORRECTLY — the live proof that it measures right before we
|
|
11
|
+
// trust any pty verdict for a flip. Pure: no tmux, no @xterm, booleans in, gate stats out.
|
|
12
|
+
|
|
13
|
+
const mkSample = (peer: string, runtime: string, over: Partial<PeerSample> = {}): PeerSample => ({
|
|
14
|
+
peer,
|
|
15
|
+
runtime,
|
|
16
|
+
occupancy: { pty: false, tmux: false },
|
|
17
|
+
readyGate: { pty: false, tmux: false },
|
|
18
|
+
liveness: { pty: true, tmux: true },
|
|
19
|
+
bytesFed: 0,
|
|
20
|
+
hasHistory: false,
|
|
21
|
+
...over,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('classifyDirection (tmux authoritative; true = active/ready/alive pole)', () => {
|
|
25
|
+
test('equal → agree', () => {
|
|
26
|
+
expect(classifyDirection(true, true)).toBe('agree')
|
|
27
|
+
expect(classifyDirection(false, false)).toBe('agree')
|
|
28
|
+
})
|
|
29
|
+
test('pty under-reports (false vs true) → pty-behind (the stale class)', () => {
|
|
30
|
+
expect(classifyDirection(false, true)).toBe('pty-behind')
|
|
31
|
+
})
|
|
32
|
+
test('pty over-reports (true vs false) → pty-ahead', () => {
|
|
33
|
+
expect(classifyDirection(true, false)).toBe('pty-ahead')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('classifySampleState (coverage regimes — idle must be distinguishable, false-green lesson)', () => {
|
|
38
|
+
test('tmux occupancy → composer-busy (regardless of bytes)', () => {
|
|
39
|
+
expect(classifySampleState(true, 0)).toBe('composer-busy')
|
|
40
|
+
expect(classifySampleState(true, 999)).toBe('composer-busy')
|
|
41
|
+
})
|
|
42
|
+
test('no occupancy + output churn → model-advancing (non-trivial regime)', () => {
|
|
43
|
+
expect(classifySampleState(false, 4096)).toBe('model-advancing')
|
|
44
|
+
})
|
|
45
|
+
test('no occupancy + no churn → idle', () => {
|
|
46
|
+
expect(classifySampleState(false, 0)).toBe('idle')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('GateTracker — direction is scored hard vs benign per the adjudicated criterion', () => {
|
|
51
|
+
test('pty-BEHIND is ALWAYS hard (zero tolerance) and closes the verdict gate', () => {
|
|
52
|
+
const g = new GateTracker()
|
|
53
|
+
g.sample(0, mkSample('p', 'claude', { occupancy: { pty: false, tmux: true } }))
|
|
54
|
+
const s = g.summary()
|
|
55
|
+
expect(s.verdicts.occupancy.behind).toBe(1)
|
|
56
|
+
expect(s.verdicts.occupancy.gateClean).toBe(false)
|
|
57
|
+
expect(s.hardViolations.some(v => v.kind === 'behind' && v.verdict === 'occupancy')).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('boot-window pty-AHEAD that self-heals within budget → BENIGN (latency recorded)', () => {
|
|
61
|
+
const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
|
|
62
|
+
// tick0: readyGate ahead (pty ready, tmux not) — peer just born → boot window
|
|
63
|
+
g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: false } }))
|
|
64
|
+
// tick1: readyGate agrees (both ready) → transient closes, latency 1, boot+within budget → benign
|
|
65
|
+
g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
|
|
66
|
+
const rg = g.summary().verdicts.readyGate
|
|
67
|
+
expect(rg.aheadBenign).toBe(1)
|
|
68
|
+
expect(rg.aheadHard).toBe(0)
|
|
69
|
+
expect(rg.gateClean).toBe(true)
|
|
70
|
+
expect(rg.selfHeal.count).toBe(1)
|
|
71
|
+
expect(rg.selfHeal.max).toBe(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('READY-GATE non-converging pty-AHEAD is HARD, never benign (refinement #3: false-ready = msg-loss)', () => {
|
|
75
|
+
const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
|
|
76
|
+
// hold readyGate ahead (pty ready, tmux NEVER ready → stays in boot window, never converges)
|
|
77
|
+
for (let t = 0; t <= 5; t++) g.sample(t, mkSample('p', 'codex', { readyGate: { pty: true, tmux: false } }))
|
|
78
|
+
const rg = g.summary().verdicts.readyGate
|
|
79
|
+
expect(rg.aheadHard).toBeGreaterThanOrEqual(1)
|
|
80
|
+
expect(rg.aheadBenign).toBe(0)
|
|
81
|
+
expect(rg.gateClean).toBe(false)
|
|
82
|
+
expect(g.summary().hardViolations.some(v => v.kind === 'ahead-nonconverged' && v.verdict === 'readyGate')).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('STEADY-STATE pty-AHEAD is HARD immediately (cannot be a boot self-heal)', () => {
|
|
86
|
+
const g = new GateTracker({ settleTicks: 2, convergeBudgetTicks: 3 })
|
|
87
|
+
// boot the peer: readyGate true for settleTicks
|
|
88
|
+
g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
|
|
89
|
+
g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } })) // bootDone now
|
|
90
|
+
// steady-state occupancy ahead → hard 'ahead-steady'
|
|
91
|
+
g.sample(2, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true }, occupancy: { pty: true, tmux: false } }))
|
|
92
|
+
const occ = g.summary().verdicts.occupancy
|
|
93
|
+
expect(occ.aheadHard).toBeGreaterThanOrEqual(1)
|
|
94
|
+
expect(g.summary().hardViolations.some(v => v.kind === 'ahead-steady' && v.verdict === 'occupancy')).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('full agreement → clean gate, zero hard violations', () => {
|
|
98
|
+
const g = new GateTracker()
|
|
99
|
+
for (let t = 0; t < 5; t++) g.sample(t, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
|
|
100
|
+
const s = g.summary()
|
|
101
|
+
expect(s.hardViolations).toHaveLength(0)
|
|
102
|
+
expect(s.verdicts.readyGate.gateClean).toBe(true)
|
|
103
|
+
expect(s.verdicts.occupancy.gateClean).toBe(true)
|
|
104
|
+
expect(s.verdicts.liveness.gateClean).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('GateTracker — coverage matrix (clean counts only over traversed cells)', () => {
|
|
109
|
+
test('births split fresh vs wake-resume by history-at-first-sight', () => {
|
|
110
|
+
const g = new GateTracker()
|
|
111
|
+
g.tickStart(1)
|
|
112
|
+
g.sample(0, mkSample('fresh-peer', 'claude', { hasHistory: false }))
|
|
113
|
+
g.tickStart(2)
|
|
114
|
+
g.sample(1, mkSample('woke-peer', 'codex', { hasHistory: true })) // history at first sight = wake-resume
|
|
115
|
+
const cov = g.summary().coverage
|
|
116
|
+
expect(cov.births['claude/fresh']).toBe(1)
|
|
117
|
+
expect(cov.births['codex/wake-resume']).toBe(1)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('a peer that died and reappeared is a restart', () => {
|
|
121
|
+
const g = new GateTracker()
|
|
122
|
+
g.sample(0, mkSample('p', 'claude'))
|
|
123
|
+
g.peerGone('p')
|
|
124
|
+
g.sample(5, mkSample('p', 'claude'))
|
|
125
|
+
expect(g.summary().coverage.restarts['claude']).toBe(1)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('state cells, fleet width, and history samples are recorded', () => {
|
|
129
|
+
const g = new GateTracker()
|
|
130
|
+
g.tickStart(3) // fleet width 3 this tick
|
|
131
|
+
g.sample(0, mkSample('p', 'claude', { occupancy: { pty: true, tmux: true } })) // composer-busy
|
|
132
|
+
g.sample(1, mkSample('p', 'claude', { bytesFed: 4096 })) // model-advancing
|
|
133
|
+
g.sample(2, mkSample('p', 'claude', { bytesFed: 0 })) // idle
|
|
134
|
+
g.sample(3, mkSample('h', 'codex', { hasHistory: true }))
|
|
135
|
+
const cov = g.summary().coverage
|
|
136
|
+
expect(cov.fleetWidthMax).toBe(3)
|
|
137
|
+
expect(cov.byRuntimeState['claude/composer-busy']).toBe(1)
|
|
138
|
+
expect(cov.byRuntimeState['claude/model-advancing']).toBe(1)
|
|
139
|
+
expect(cov.byRuntimeState['claude/idle']).toBe(1)
|
|
140
|
+
expect(cov.historySamples['codex']).toBe(1)
|
|
141
|
+
expect(cov.runtimes).toEqual(['claude', 'codex'])
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Gate-grade instrumentation for the tmux→pty fidelity burn-in (cutover Block 1).
|
|
2
|
+
//
|
|
3
|
+
// PURE classifiers + one stateful tracker, with NO tmux and NO @xterm import — the observer
|
|
4
|
+
// loop feeds it booleans, so all classification is hermetically unit-testable. That is exactly
|
|
5
|
+
// the synthetic acceptance boris requires: PROVE the instrument classifies known-behind vs
|
|
6
|
+
// known-ahead and records coverage correctly BEFORE we trust any pty verdict for a flip.
|
|
7
|
+
//
|
|
8
|
+
// It operationalizes the adjudicated gate criterion (NOT a flat sample count):
|
|
9
|
+
// • DIRECTION — pty-BEHIND (pty=False,tmux=True: a stale frame while live tmux moved on) is
|
|
10
|
+
// the reattach-stale class → ZERO tolerance, always hard. pty-AHEAD (pty=True,tmux=False)
|
|
11
|
+
// is benign ONLY when it (a) opened in the boot window, (b) self-heals within the converge
|
|
12
|
+
// budget, and (c) is not steady-state. Anything else → hard. For READY-GATE a non-converging
|
|
13
|
+
// ahead is a FALSE-ready (early-delivery → msg-loss class), so it is hard, never benign.
|
|
14
|
+
// • COVERAGE — which matrix cells the burn-in actually traversed {runtime × {idle,
|
|
15
|
+
// model-advancing, composer-busy}} + births (fresh vs wake-resume-with-history) + restarts +
|
|
16
|
+
// history-bearing samples + fleet width. "Clean" counts only over traversed cells (idle
|
|
17
|
+
// alone is a false green).
|
|
18
|
+
|
|
19
|
+
export type Verdict = 'occupancy' | 'readyGate' | 'liveness'
|
|
20
|
+
export const VERDICTS: Verdict[] = ['occupancy', 'readyGate', 'liveness']
|
|
21
|
+
|
|
22
|
+
export type Direction = 'agree' | 'pty-behind' | 'pty-ahead'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Direction of a (pty, tmux) verdict pair. tmux is AUTHORITATIVE; `true` is the
|
|
26
|
+
* active/ready/alive pole. pty UNDER-reporting it (pty=false, tmux=true) is BEHIND — the
|
|
27
|
+
* stale-frame class (zero tolerance). pty OVER-reporting (pty=true, tmux=false) is AHEAD.
|
|
28
|
+
*/
|
|
29
|
+
export function classifyDirection(ptyVal: boolean, tmuxVal: boolean): Direction {
|
|
30
|
+
if (ptyVal === tmuxVal) return 'agree'
|
|
31
|
+
return tmuxVal ? 'pty-behind' : 'pty-ahead'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SampleState = 'idle' | 'model-advancing' | 'composer-busy'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-sample fleet state for the coverage matrix. composer-busy when tmux (authoritative) sees
|
|
38
|
+
* human occupancy; else model-advancing when output churned this tick (the model is actively
|
|
39
|
+
* rendering — the NON-trivial fidelity regime, not at rest); else idle. The split is what keeps
|
|
40
|
+
* an idle burn-in from reading as a false green (boris's acceptance lesson).
|
|
41
|
+
*/
|
|
42
|
+
export function classifySampleState(occTmux: boolean, bytesFedThisTick: number): SampleState {
|
|
43
|
+
if (occTmux) return 'composer-busy'
|
|
44
|
+
if (bytesFedThisTick > 0) return 'model-advancing'
|
|
45
|
+
return 'idle'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface VerdictSample {
|
|
49
|
+
/** null when the tmux side was unavailable this tick (capture failed) — skipped, not scored. */
|
|
50
|
+
pty: boolean
|
|
51
|
+
tmux: boolean | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PeerSample {
|
|
55
|
+
peer: string
|
|
56
|
+
runtime: string
|
|
57
|
+
occupancy: VerdictSample
|
|
58
|
+
readyGate: VerdictSample
|
|
59
|
+
liveness: VerdictSample
|
|
60
|
+
/** Bytes appended to this peer's pane-log since the previous tick (drives state coverage). */
|
|
61
|
+
bytesFed: number
|
|
62
|
+
/** The model already carries scrollback (transcript taller than the pane) — a history-bearing
|
|
63
|
+
* peer; at first sight this means it woke WITH resume-history (vs a fresh empty birth). */
|
|
64
|
+
hasHistory: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface GateConfig {
|
|
68
|
+
/** Consecutive tmux-ready ticks after which a peer is considered booted (out of the boot
|
|
69
|
+
* window). Default 3. */
|
|
70
|
+
settleTicks?: number
|
|
71
|
+
/** Max ticks an ahead-transient may stay open and still count as a benign self-heal.
|
|
72
|
+
* Default 3. */
|
|
73
|
+
convergeBudgetTicks?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HardViolation {
|
|
77
|
+
peer: string
|
|
78
|
+
runtime: string
|
|
79
|
+
verdict: Verdict
|
|
80
|
+
tick: number
|
|
81
|
+
kind: 'behind' | 'ahead-steady' | 'ahead-nonconverged'
|
|
82
|
+
detail?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface OpenAhead {
|
|
86
|
+
startTick: number
|
|
87
|
+
bootWindow: boolean
|
|
88
|
+
flaggedNonConverged: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface PeerState {
|
|
92
|
+
runtime: string
|
|
93
|
+
firstTick: number
|
|
94
|
+
readyStreak: number
|
|
95
|
+
bootDone: boolean
|
|
96
|
+
open: Record<Verdict, OpenAhead | null>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface VerdictTally {
|
|
100
|
+
samples: number
|
|
101
|
+
agree: number
|
|
102
|
+
behind: number
|
|
103
|
+
aheadBenign: number
|
|
104
|
+
aheadHard: number
|
|
105
|
+
aheadLatencies: number[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class GateTracker {
|
|
109
|
+
private readonly settleTicks: number
|
|
110
|
+
private readonly budget: number
|
|
111
|
+
private peers = new Map<string, PeerState>()
|
|
112
|
+
private goneOnce = new Set<string>()
|
|
113
|
+
private tally: Record<Verdict, VerdictTally>
|
|
114
|
+
private hard: HardViolation[] = []
|
|
115
|
+
// coverage
|
|
116
|
+
private fleetWidthMax = 0
|
|
117
|
+
private stateCells = new Map<string, number>() // `${runtime}/${state}` → count
|
|
118
|
+
private births = new Map<string, number>() // `${runtime}/${fresh|wake-resume}` → count
|
|
119
|
+
private restarts = new Map<string, number>() // runtime → count
|
|
120
|
+
private historySamples = new Map<string, number>() // runtime → count
|
|
121
|
+
private runtimesSeen = new Set<string>()
|
|
122
|
+
|
|
123
|
+
constructor(cfg: GateConfig = {}) {
|
|
124
|
+
this.settleTicks = cfg.settleTicks ?? 3
|
|
125
|
+
this.budget = cfg.convergeBudgetTicks ?? 3
|
|
126
|
+
const mk = (): VerdictTally => ({ samples: 0, agree: 0, behind: 0, aheadBenign: 0, aheadHard: 0, aheadLatencies: [] })
|
|
127
|
+
this.tally = { occupancy: mk(), readyGate: mk(), liveness: mk() }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Once per tick, before the per-peer samples — records the concurrent fleet width. */
|
|
131
|
+
tickStart(nPeers: number): void {
|
|
132
|
+
if (nPeers > this.fleetWidthMax) this.fleetWidthMax = nPeers
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The observer dropped a peer (session gone) — a later reappearance is a restart. */
|
|
136
|
+
peerGone(addr: string): void {
|
|
137
|
+
if (this.peers.delete(addr)) this.goneOnce.add(addr)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private inc(map: Map<string, number>, key: string): void {
|
|
141
|
+
map.set(key, (map.get(key) ?? 0) + 1)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Record one peer's sample at `tick`. Updates boot state, classifies every verdict's
|
|
145
|
+
* direction, manages ahead-transients, and accumulates the coverage matrix. */
|
|
146
|
+
sample(tick: number, s: PeerSample): void {
|
|
147
|
+
this.runtimesSeen.add(s.runtime)
|
|
148
|
+
let ps = this.peers.get(s.peer)
|
|
149
|
+
if (!ps) {
|
|
150
|
+
// First sight this lifetime → a birth (or a restart if we saw it die before).
|
|
151
|
+
const birthType = s.hasHistory ? 'wake-resume' : 'fresh'
|
|
152
|
+
this.inc(this.births, `${s.runtime}/${birthType}`)
|
|
153
|
+
if (this.goneOnce.has(s.peer)) {
|
|
154
|
+
this.inc(this.restarts, s.runtime)
|
|
155
|
+
this.goneOnce.delete(s.peer)
|
|
156
|
+
}
|
|
157
|
+
ps = { runtime: s.runtime, firstTick: tick, readyStreak: 0, bootDone: false, open: { occupancy: null, readyGate: null, liveness: null } }
|
|
158
|
+
this.peers.set(s.peer, ps)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Boot-window: a peer is booted once tmux-ready has held true for settleTicks in a row.
|
|
162
|
+
if (s.readyGate.tmux === true) {
|
|
163
|
+
ps.readyStreak++
|
|
164
|
+
if (ps.readyStreak >= this.settleTicks) ps.bootDone = true
|
|
165
|
+
} else if (s.readyGate.tmux === false) {
|
|
166
|
+
ps.readyStreak = 0
|
|
167
|
+
}
|
|
168
|
+
const bootWindow = !ps.bootDone
|
|
169
|
+
|
|
170
|
+
// Coverage.
|
|
171
|
+
const state = classifySampleState(s.occupancy.tmux === true, s.bytesFed)
|
|
172
|
+
this.inc(this.stateCells, `${s.runtime}/${state}`)
|
|
173
|
+
if (s.hasHistory) this.inc(this.historySamples, s.runtime)
|
|
174
|
+
|
|
175
|
+
// Per-verdict direction + transient bookkeeping.
|
|
176
|
+
this.scoreVerdict('occupancy', tick, ps, bootWindow, s.runtime, s.peer, s.occupancy)
|
|
177
|
+
this.scoreVerdict('readyGate', tick, ps, bootWindow, s.runtime, s.peer, s.readyGate)
|
|
178
|
+
this.scoreVerdict('liveness', tick, ps, bootWindow, s.runtime, s.peer, s.liveness)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private scoreVerdict(
|
|
182
|
+
verdict: Verdict,
|
|
183
|
+
tick: number,
|
|
184
|
+
ps: PeerState,
|
|
185
|
+
bootWindow: boolean,
|
|
186
|
+
runtime: string,
|
|
187
|
+
peer: string,
|
|
188
|
+
vs: VerdictSample,
|
|
189
|
+
): void {
|
|
190
|
+
if (vs.tmux === null) return // tmux side unavailable → not scored
|
|
191
|
+
const t = this.tally[verdict]
|
|
192
|
+
t.samples++
|
|
193
|
+
const dir = classifyDirection(vs.pty, vs.tmux)
|
|
194
|
+
|
|
195
|
+
// An open ahead-transient that exceeds the converge budget is a hard violation the moment
|
|
196
|
+
// it overruns (logged once), regardless of how it later resolves.
|
|
197
|
+
const open = ps.open[verdict]
|
|
198
|
+
if (open && !open.flaggedNonConverged && tick - open.startTick > this.budget) {
|
|
199
|
+
open.flaggedNonConverged = true
|
|
200
|
+
t.aheadHard++
|
|
201
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-nonconverged', detail: `ahead open ${tick - open.startTick} ticks > budget ${this.budget}` })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (dir === 'agree') {
|
|
205
|
+
t.agree++
|
|
206
|
+
if (open) {
|
|
207
|
+
// transient closed → measure self-heal latency; benign iff it opened in the boot
|
|
208
|
+
// window AND healed within budget (and was not already flagged non-converged).
|
|
209
|
+
const latency = tick - open.startTick
|
|
210
|
+
t.aheadLatencies.push(latency)
|
|
211
|
+
if (!open.flaggedNonConverged) {
|
|
212
|
+
if (open.bootWindow && latency <= this.budget) t.aheadBenign++
|
|
213
|
+
else {
|
|
214
|
+
t.aheadHard++
|
|
215
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: open.bootWindow ? 'ahead-nonconverged' : 'ahead-steady', detail: `latency ${latency}, boot=${open.bootWindow}` })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
ps.open[verdict] = null
|
|
219
|
+
}
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (dir === 'pty-behind') {
|
|
224
|
+
// The stale-frame class — always hard, zero tolerance. A behind also closes any open
|
|
225
|
+
// ahead-transient as an abnormal resolution (not a benign heal).
|
|
226
|
+
t.behind++
|
|
227
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: 'behind' })
|
|
228
|
+
ps.open[verdict] = null
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// pty-ahead: open a transient if none is open. Steady-state ahead is hard immediately
|
|
233
|
+
// (it cannot be a boot self-heal); boot-window ahead waits to see if it converges.
|
|
234
|
+
if (!open) {
|
|
235
|
+
if (!bootWindow) {
|
|
236
|
+
t.aheadHard++
|
|
237
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-steady' })
|
|
238
|
+
// still open it so we can measure when it clears, but it is already counted hard
|
|
239
|
+
ps.open[verdict] = { startTick: tick, bootWindow: false, flaggedNonConverged: true }
|
|
240
|
+
} else {
|
|
241
|
+
ps.open[verdict] = { startTick: tick, bootWindow: true, flaggedNonConverged: false }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** True iff no hard divergence has been recorded for `verdict` (the per-verdict gate bar). */
|
|
247
|
+
verdictGateClean(verdict: Verdict): boolean {
|
|
248
|
+
const t = this.tally[verdict]
|
|
249
|
+
return t.behind === 0 && t.aheadHard === 0
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
summary(): GateSummary {
|
|
253
|
+
const latStats = (xs: number[]): { count: number; max: number; mean: number } =>
|
|
254
|
+
xs.length ? { count: xs.length, max: Math.max(...xs), mean: +(xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(2) } : { count: 0, max: 0, mean: 0 }
|
|
255
|
+
const verdicts = {} as Record<Verdict, VerdictGateStat>
|
|
256
|
+
for (const v of VERDICTS) {
|
|
257
|
+
const t = this.tally[v]
|
|
258
|
+
verdicts[v] = {
|
|
259
|
+
samples: t.samples,
|
|
260
|
+
agree: t.agree,
|
|
261
|
+
behind: t.behind,
|
|
262
|
+
aheadBenign: t.aheadBenign,
|
|
263
|
+
aheadHard: t.aheadHard,
|
|
264
|
+
selfHeal: latStats(t.aheadLatencies),
|
|
265
|
+
gateClean: this.verdictGateClean(v),
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
verdicts,
|
|
270
|
+
hardViolations: this.hard.slice(),
|
|
271
|
+
coverage: {
|
|
272
|
+
fleetWidthMax: this.fleetWidthMax,
|
|
273
|
+
runtimes: [...this.runtimesSeen].sort(),
|
|
274
|
+
byRuntimeState: Object.fromEntries([...this.stateCells].sort()),
|
|
275
|
+
births: Object.fromEntries([...this.births].sort()),
|
|
276
|
+
restarts: Object.fromEntries([...this.restarts].sort()),
|
|
277
|
+
historySamples: Object.fromEntries([...this.historySamples].sort()),
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface VerdictGateStat {
|
|
284
|
+
samples: number
|
|
285
|
+
agree: number
|
|
286
|
+
behind: number
|
|
287
|
+
aheadBenign: number
|
|
288
|
+
aheadHard: number
|
|
289
|
+
selfHeal: { count: number; max: number; mean: number }
|
|
290
|
+
gateClean: boolean
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface GateCoverage {
|
|
294
|
+
fleetWidthMax: number
|
|
295
|
+
runtimes: string[]
|
|
296
|
+
byRuntimeState: Record<string, number>
|
|
297
|
+
births: Record<string, number>
|
|
298
|
+
restarts: Record<string, number>
|
|
299
|
+
historySamples: Record<string, number>
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface GateSummary {
|
|
303
|
+
verdicts: Record<Verdict, VerdictGateStat>
|
|
304
|
+
hardViolations: HardViolation[]
|
|
305
|
+
coverage: GateCoverage
|
|
306
|
+
}
|
package/src/shadow/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transpor
|
|
|
19
19
|
import { getAdapter } from '../launch/index.ts'
|
|
20
20
|
import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
|
|
21
21
|
import type { Runtime } from '../core/constants.ts'
|
|
22
|
+
import { GateTracker, classifyDirection, VERDICTS } from './gate.ts'
|
|
22
23
|
|
|
23
24
|
const SEED_BYTES = 4 * 1024 * 1024
|
|
24
25
|
const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
@@ -72,16 +73,18 @@ export interface ShadowOptions {
|
|
|
72
73
|
log?: (s: string) => void
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
/** Feed the pane-log delta into the model; resolves with the byte count appended this tick
|
|
77
|
+
* (drives the coverage state classifier: >0 ⇒ the model is advancing, not idle). */
|
|
78
|
+
function feed(o: Obs): Promise<number> {
|
|
76
79
|
const sz = fstatSync(o.fd).size
|
|
77
|
-
if (sz <= o.offset) return Promise.resolve()
|
|
80
|
+
if (sz <= o.offset) return Promise.resolve(0)
|
|
78
81
|
const buf = Buffer.alloc(sz - o.offset)
|
|
79
82
|
readSync(o.fd, buf, 0, buf.length, o.offset)
|
|
80
83
|
o.offset = sz
|
|
81
|
-
return new Promise(res => o.term.write(buf, () => res()))
|
|
84
|
+
return new Promise(res => o.term.write(buf, () => res(buf.length)))
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
function enumerate(models: Map<string, Obs>, opts: ShadowOptions): void {
|
|
87
|
+
function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
|
|
85
88
|
let peers: { runtime: Runtime; personality: string }[] = []
|
|
86
89
|
try { peers = listOnlinePeers(opts.sockDir).filter(p => p.runtime === 'claude' || p.runtime === 'codex') } catch { return }
|
|
87
90
|
const live = new Set<string>()
|
|
@@ -98,7 +101,7 @@ function enumerate(models: Map<string, Obs>, opts: ShadowOptions): void {
|
|
|
98
101
|
models.set(addr, { addr, sock, runtime: p.runtime, cols, rows, term, fd, offset })
|
|
99
102
|
} catch { /* a peer racing spawn/teardown — skip this round */ }
|
|
100
103
|
}
|
|
101
|
-
for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; models.delete(addr) }
|
|
104
|
+
for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; tracker?.peerGone(addr); models.delete(addr) }
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
/** Run the read-only fidelity observer. Resolves when once/maxMinutes/STOP completes. */
|
|
@@ -110,19 +113,20 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
|
|
|
110
113
|
const interval = opts.intervalMs ?? 5000
|
|
111
114
|
const log = opts.log ?? (() => { /* */ })
|
|
112
115
|
const models = new Map<string, Obs>()
|
|
113
|
-
const
|
|
116
|
+
const tracker = new GateTracker()
|
|
114
117
|
const startedISO = new Date().toISOString(), t0 = Date.now()
|
|
115
|
-
enumerate(models, opts)
|
|
118
|
+
enumerate(models, opts, tracker)
|
|
116
119
|
for (const o of models.values()) await feed(o) // seed each model
|
|
117
120
|
log(`shadow-fidelity: ${models.size} warm peers, interval ${interval}ms\n`)
|
|
118
121
|
let i = 0
|
|
119
122
|
for (;;) {
|
|
120
123
|
if (opts.maxMinutes && Date.now() - t0 >= opts.maxMinutes * 60000) break
|
|
121
124
|
if (existsSync(stopFile)) break
|
|
122
|
-
if (i > 0 && i % 15 === 0) enumerate(models, opts)
|
|
125
|
+
if (i > 0 && i % 15 === 0) enumerate(models, opts, tracker)
|
|
126
|
+
tracker.tickStart(models.size)
|
|
123
127
|
for (const o of models.values()) {
|
|
124
128
|
try {
|
|
125
|
-
await feed(o)
|
|
129
|
+
const bytesFed = await feed(o)
|
|
126
130
|
const adapter = getAdapter(o.runtime)
|
|
127
131
|
const capE = tmux(o.sock, 'capture-pane', '-e', '-p', '-t', o.addr); if (!capE.ok) continue
|
|
128
132
|
const occTmux = composerCaptureHasHumanInput(capE.out, adapter.deliveryMarkers)
|
|
@@ -134,34 +138,49 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
|
|
|
134
138
|
const ppid = tmux(o.sock, 'display', '-p', '-t', o.addr, '#{pane_pid}')
|
|
135
139
|
const pid = ppid.ok ? Number(ppid.out.trim()) : NaN
|
|
136
140
|
const livPty = Number.isFinite(pid) ? pidAlive(pid) : false
|
|
141
|
+
const hasHistory = o.term.buffer.active.baseY > 0
|
|
137
142
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
// Feed the gate tracker — it scores DIRECTION (behind=hard / ahead=benign-if-boot-self-heal)
|
|
144
|
+
// and accumulates the COVERAGE matrix. The boolean booleans never leave this call.
|
|
145
|
+
tracker.sample(i, {
|
|
146
|
+
peer: o.addr, runtime: o.runtime,
|
|
147
|
+
occupancy: { pty: occPty, tmux: occTmux },
|
|
148
|
+
readyGate: { pty: rgPty, tmux: rgTmux },
|
|
149
|
+
liveness: { pty: livPty, tmux: livTmux },
|
|
150
|
+
bytesFed, hasHistory,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// DIRECTION-AWARE per-event divergence trail (gate scoring lives in the tracker; this is
|
|
154
|
+
// the forensic log). Occupancy carries the raw capture + model frames for root-cause.
|
|
155
|
+
const occDir = classifyDirection(occPty, occTmux)
|
|
156
|
+
const rgDir = rgTmux === null ? 'agree' : classifyDirection(rgPty, rgTmux)
|
|
157
|
+
const livDir = classifyDirection(livPty, livTmux)
|
|
158
|
+
if (occDir !== 'agree' || rgDir !== 'agree' || livDir !== 'agree') {
|
|
146
159
|
appendFileSync(jsonl, JSON.stringify({
|
|
147
|
-
ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`,
|
|
148
|
-
occupancy: { pty: occPty, tmux: occTmux,
|
|
149
|
-
readyGate: { pty: rgPty, tmux: rgTmux,
|
|
150
|
-
liveness: { pty: livPty, tmux: livTmux,
|
|
151
|
-
...(
|
|
160
|
+
ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`, hasHistory,
|
|
161
|
+
occupancy: { pty: occPty, tmux: occTmux, dir: occDir },
|
|
162
|
+
readyGate: { pty: rgPty, tmux: rgTmux, dir: rgDir },
|
|
163
|
+
liveness: { pty: livPty, tmux: livTmux, dir: livDir },
|
|
164
|
+
...(occDir !== 'agree' ? { capE: capE.out.slice(-2500), model: modelToPlainText(o.term, o.cols, o.rows).slice(-2500) } : {}),
|
|
152
165
|
}) + '\n')
|
|
153
|
-
log(`DIVERGE ${o.addr} occ=${
|
|
166
|
+
log(`DIVERGE ${o.addr} occ=${occDir} rg=${rgDir} liv=${livDir}\n`)
|
|
154
167
|
}
|
|
155
168
|
} catch { /* an observer error must NEVER escape — it is a passive sidecar */ }
|
|
156
169
|
}
|
|
157
170
|
i++
|
|
171
|
+
const gs = tracker.summary()
|
|
172
|
+
const totalSamples = VERDICTS.reduce((n, v) => n + gs.verdicts[v].samples, 0)
|
|
158
173
|
writeFileSync(statsFile, JSON.stringify({
|
|
159
174
|
startedISO, lastISO: new Date().toISOString(), elapsedMin: +((Date.now() - t0) / 60000).toFixed(1),
|
|
160
|
-
peers: models.size, samples:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
175
|
+
peers: models.size, samples: totalSamples,
|
|
176
|
+
// Gate (boris's criterion): clean iff zero hard divergences across all verdicts. Hard =
|
|
177
|
+
// every pty-behind + non-benign pty-ahead (steady-state or non-converging). Benign =
|
|
178
|
+
// boot-window ahead that self-heals within budget. Coverage proves the matrix was traversed.
|
|
179
|
+
gateClean: VERDICTS.every(v => gs.verdicts[v].gateClean),
|
|
180
|
+
hardTotal: gs.hardViolations.length,
|
|
181
|
+
verdicts: gs.verdicts,
|
|
182
|
+
coverage: gs.coverage,
|
|
183
|
+
recentHard: gs.hardViolations.slice(-20),
|
|
165
184
|
}, null, 2))
|
|
166
185
|
if (opts.once) break
|
|
167
186
|
await new Promise(r => setTimeout(r, interval))
|