@agfpd/iapeer 0.2.59 → 0.2.61
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 +2 -1
- package/src/cli/index.ts +9 -0
- package/src/shadow/gate.test.ts +249 -0
- package/src/shadow/gate.ts +372 -0
- package/src/shadow/index.ts +51 -28
- package/src/supervisor/client.ts +118 -0
- package/src/supervisor/daemon.test.ts +112 -0
- package/src/supervisor/daemon.ts +210 -0
- package/src/supervisor/index.ts +137 -0
- package/src/supervisor/paths.ts +14 -0
- package/src/supervisor/protocol.test.ts +131 -0
- package/src/supervisor/protocol.ts +155 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.61",
|
|
4
4
|
"description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
36
|
+
"@xterm/addon-serialize": "0.14.0",
|
|
36
37
|
"@xterm/headless": "6.0.0",
|
|
37
38
|
"proper-lockfile": "^4.1.2",
|
|
38
39
|
"transliteration": "^2.6.1"
|
package/src/cli/index.ts
CHANGED
|
@@ -939,6 +939,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
939
939
|
shadow [--once | --minutes <m>] [--interval <ms>] READ-ONLY tmux→pty fidelity observer (migration burn-in): compares pty-model verdicts vs live tmux on the warm fleet → ~/.iapeer/logs/iapeer/shadow-fidelity.json. Never delivers/mutates.
|
|
940
940
|
shadow-install install + load the always-on shadow burn-in launchd job (com.iapeer.shadow-fidelity); idempotent, re-cycled by update/rollback
|
|
941
941
|
shadow-uninstall bootout + remove the shadow burn-in launchd job (the correct way to stop it; STOP file is respawned past by KeepAlive)
|
|
942
|
+
supervisor up|start|attach|list|kill <sess> [runtime] DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)
|
|
942
943
|
`
|
|
943
944
|
|
|
944
945
|
export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
@@ -1801,6 +1802,14 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1801
1802
|
out(`shadow burn-in job ${r.action}: ${r.path}\n`)
|
|
1802
1803
|
return 0
|
|
1803
1804
|
}
|
|
1805
|
+
case 'supervisor': {
|
|
1806
|
+
// DARK (cutover Block 2): the detach-persistent pty-supervisor (PoC pts.mjs port). NOT
|
|
1807
|
+
// wired into delivery/launch — it serves NOTHING on the live fleet; it is validated on
|
|
1808
|
+
// throwaway `tick` sessions. The dynamic import keeps @xterm OUT of the daemon hot path
|
|
1809
|
+
// (the daemon never loads this). Sub-commands: up|start|attach|list|kill|daemon.
|
|
1810
|
+
const { runSupervisorCli } = await import('../supervisor/index.ts')
|
|
1811
|
+
return await runSupervisorCli(rest)
|
|
1812
|
+
}
|
|
1804
1813
|
default: {
|
|
1805
1814
|
// `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
|
|
1806
1815
|
if (verb && isRuntime(verb)) {
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
GateTracker,
|
|
4
|
+
classifyDirection,
|
|
5
|
+
classifySampleState,
|
|
6
|
+
evaluateReadyGateFlipEligibility,
|
|
7
|
+
type GateSummary,
|
|
8
|
+
type PeerSample,
|
|
9
|
+
type VerdictGateStat,
|
|
10
|
+
} from './gate.ts'
|
|
11
|
+
|
|
12
|
+
// SYNTHETIC ACCEPTANCE (boris's Block-1 gate): prove the instrument classifies known-behind vs
|
|
13
|
+
// known-ahead and records coverage CORRECTLY — the live proof that it measures right before we
|
|
14
|
+
// trust any pty verdict for a flip. Pure: no tmux, no @xterm, booleans in, gate stats out.
|
|
15
|
+
|
|
16
|
+
const mkSample = (peer: string, runtime: string, over: Partial<PeerSample> = {}): PeerSample => ({
|
|
17
|
+
peer,
|
|
18
|
+
runtime,
|
|
19
|
+
occupancy: { pty: false, tmux: false },
|
|
20
|
+
readyGate: { pty: false, tmux: false },
|
|
21
|
+
liveness: { pty: true, tmux: true },
|
|
22
|
+
bytesFed: 0,
|
|
23
|
+
hasHistory: false,
|
|
24
|
+
...over,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('classifyDirection (tmux authoritative; true = active/ready/alive pole)', () => {
|
|
28
|
+
test('equal → agree', () => {
|
|
29
|
+
expect(classifyDirection(true, true)).toBe('agree')
|
|
30
|
+
expect(classifyDirection(false, false)).toBe('agree')
|
|
31
|
+
})
|
|
32
|
+
test('pty under-reports (false vs true) → pty-behind (the stale class)', () => {
|
|
33
|
+
expect(classifyDirection(false, true)).toBe('pty-behind')
|
|
34
|
+
})
|
|
35
|
+
test('pty over-reports (true vs false) → pty-ahead', () => {
|
|
36
|
+
expect(classifyDirection(true, false)).toBe('pty-ahead')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('classifySampleState (coverage regimes — idle must be distinguishable, false-green lesson)', () => {
|
|
41
|
+
test('tmux occupancy → composer-busy (regardless of bytes)', () => {
|
|
42
|
+
expect(classifySampleState(true, 0)).toBe('composer-busy')
|
|
43
|
+
expect(classifySampleState(true, 999)).toBe('composer-busy')
|
|
44
|
+
})
|
|
45
|
+
test('no occupancy + output churn → model-advancing (non-trivial regime)', () => {
|
|
46
|
+
expect(classifySampleState(false, 4096)).toBe('model-advancing')
|
|
47
|
+
})
|
|
48
|
+
test('no occupancy + no churn → idle', () => {
|
|
49
|
+
expect(classifySampleState(false, 0)).toBe('idle')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('GateTracker — direction is scored hard vs benign per the adjudicated criterion', () => {
|
|
54
|
+
test('pty-BEHIND is ALWAYS hard (zero tolerance) and closes the verdict gate', () => {
|
|
55
|
+
const g = new GateTracker()
|
|
56
|
+
g.sample(0, mkSample('p', 'claude', { occupancy: { pty: false, tmux: true } }))
|
|
57
|
+
const s = g.summary()
|
|
58
|
+
expect(s.verdicts.occupancy.behind).toBe(1)
|
|
59
|
+
expect(s.verdicts.occupancy.gateClean).toBe(false)
|
|
60
|
+
expect(s.hardViolations.some(v => v.kind === 'behind' && v.verdict === 'occupancy')).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('boot-window pty-AHEAD that self-heals within budget → BENIGN (latency recorded)', () => {
|
|
64
|
+
const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
|
|
65
|
+
// tick0: readyGate ahead (pty ready, tmux not) — peer just born → boot window
|
|
66
|
+
g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: false } }))
|
|
67
|
+
// tick1: readyGate agrees (both ready) → transient closes, latency 1, boot+within budget → benign
|
|
68
|
+
g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
|
|
69
|
+
const rg = g.summary().verdicts.readyGate
|
|
70
|
+
expect(rg.aheadBenign).toBe(1)
|
|
71
|
+
expect(rg.aheadHard).toBe(0)
|
|
72
|
+
expect(rg.gateClean).toBe(true)
|
|
73
|
+
expect(rg.selfHeal.count).toBe(1)
|
|
74
|
+
expect(rg.selfHeal.max).toBe(1)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('READY-GATE non-converging pty-AHEAD is HARD, never benign (refinement #3: false-ready = msg-loss)', () => {
|
|
78
|
+
const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
|
|
79
|
+
// hold readyGate ahead (pty ready, tmux NEVER ready → stays in boot window, never converges)
|
|
80
|
+
for (let t = 0; t <= 5; t++) g.sample(t, mkSample('p', 'codex', { readyGate: { pty: true, tmux: false } }))
|
|
81
|
+
const rg = g.summary().verdicts.readyGate
|
|
82
|
+
expect(rg.aheadHard).toBeGreaterThanOrEqual(1)
|
|
83
|
+
expect(rg.aheadBenign).toBe(0)
|
|
84
|
+
expect(rg.gateClean).toBe(false)
|
|
85
|
+
expect(g.summary().hardViolations.some(v => v.kind === 'ahead-nonconverged' && v.verdict === 'readyGate')).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('STEADY-STATE pty-AHEAD is HARD immediately (cannot be a boot self-heal)', () => {
|
|
89
|
+
const g = new GateTracker({ settleTicks: 2, convergeBudgetTicks: 3 })
|
|
90
|
+
// boot the peer: readyGate true for settleTicks
|
|
91
|
+
g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
|
|
92
|
+
g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } })) // bootDone now
|
|
93
|
+
// steady-state occupancy ahead → hard 'ahead-steady'
|
|
94
|
+
g.sample(2, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true }, occupancy: { pty: true, tmux: false } }))
|
|
95
|
+
const occ = g.summary().verdicts.occupancy
|
|
96
|
+
expect(occ.aheadHard).toBeGreaterThanOrEqual(1)
|
|
97
|
+
expect(g.summary().hardViolations.some(v => v.kind === 'ahead-steady' && v.verdict === 'occupancy')).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('full agreement → clean gate, zero hard violations', () => {
|
|
101
|
+
const g = new GateTracker()
|
|
102
|
+
for (let t = 0; t < 5; t++) g.sample(t, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
|
|
103
|
+
const s = g.summary()
|
|
104
|
+
expect(s.hardViolations).toHaveLength(0)
|
|
105
|
+
expect(s.verdicts.readyGate.gateClean).toBe(true)
|
|
106
|
+
expect(s.verdicts.occupancy.gateClean).toBe(true)
|
|
107
|
+
expect(s.verdicts.liveness.gateClean).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('GateTracker — coverage matrix (clean counts only over traversed cells)', () => {
|
|
112
|
+
test('births split fresh vs wake-resume by history-at-first-sight', () => {
|
|
113
|
+
const g = new GateTracker()
|
|
114
|
+
g.tickStart(1)
|
|
115
|
+
g.sample(0, mkSample('fresh-peer', 'claude', { hasHistory: false }))
|
|
116
|
+
g.tickStart(2)
|
|
117
|
+
g.sample(1, mkSample('woke-peer', 'codex', { hasHistory: true })) // history at first sight = wake-resume
|
|
118
|
+
const cov = g.summary().coverage
|
|
119
|
+
expect(cov.births['claude/fresh']).toBe(1)
|
|
120
|
+
expect(cov.births['codex/wake-resume']).toBe(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('a peer that died and reappeared is a restart', () => {
|
|
124
|
+
const g = new GateTracker()
|
|
125
|
+
g.sample(0, mkSample('p', 'claude'))
|
|
126
|
+
g.peerGone('p')
|
|
127
|
+
g.sample(5, mkSample('p', 'claude'))
|
|
128
|
+
expect(g.summary().coverage.restarts['claude']).toBe(1)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('state cells, fleet width, and history samples are recorded', () => {
|
|
132
|
+
const g = new GateTracker()
|
|
133
|
+
g.tickStart(3) // fleet width 3 this tick
|
|
134
|
+
g.sample(0, mkSample('p', 'claude', { occupancy: { pty: true, tmux: true } })) // composer-busy
|
|
135
|
+
g.sample(1, mkSample('p', 'claude', { bytesFed: 4096 })) // model-advancing
|
|
136
|
+
g.sample(2, mkSample('p', 'claude', { bytesFed: 0 })) // idle
|
|
137
|
+
g.sample(3, mkSample('h', 'codex', { hasHistory: true }))
|
|
138
|
+
const cov = g.summary().coverage
|
|
139
|
+
expect(cov.fleetWidthMax).toBe(3)
|
|
140
|
+
expect(cov.byRuntimeState['claude/composer-busy']).toBe(1)
|
|
141
|
+
expect(cov.byRuntimeState['claude/model-advancing']).toBe(1)
|
|
142
|
+
expect(cov.byRuntimeState['claude/idle']).toBe(1)
|
|
143
|
+
expect(cov.historySamples['codex']).toBe(1)
|
|
144
|
+
expect(cov.runtimes).toEqual(['claude', 'codex'])
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('GateTracker — center cell (codex×wake-resume×history readiness caught by the model)', () => {
|
|
149
|
+
test('a history-bearing peer whose readiness the model catches at the tmux transition → counted', () => {
|
|
150
|
+
const g = new GateTracker()
|
|
151
|
+
g.sample(0, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } }))
|
|
152
|
+
g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: true, tmux: true } })) // ready, model agreed
|
|
153
|
+
expect(g.summary().coverage.readyResumeCaught['codex']).toBe(1)
|
|
154
|
+
})
|
|
155
|
+
test('model MISSES readiness at the transition → NOT counted, and it is a pty-behind (dirty)', () => {
|
|
156
|
+
const g = new GateTracker()
|
|
157
|
+
g.sample(0, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } }))
|
|
158
|
+
g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: true } })) // tmux ready, model NOT
|
|
159
|
+
const s = g.summary()
|
|
160
|
+
expect(s.coverage.readyResumeCaught['codex']).toBeUndefined()
|
|
161
|
+
expect(s.verdicts.readyGate.behind).toBeGreaterThanOrEqual(1) // the exact splash-blind bug
|
|
162
|
+
})
|
|
163
|
+
test('a FRESH (no-history) peer ready transition is NOT a center-cell hit', () => {
|
|
164
|
+
const g = new GateTracker()
|
|
165
|
+
g.sample(0, mkSample('f', 'codex', { hasHistory: false, readyGate: { pty: false, tmux: false } }))
|
|
166
|
+
g.sample(1, mkSample('f', 'codex', { hasHistory: false, readyGate: { pty: true, tmux: true } }))
|
|
167
|
+
expect(g.summary().coverage.readyResumeCaught['codex']).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('evaluateReadyGateFlipEligibility (boris guardrail: the eligibility logic itself under test)', () => {
|
|
172
|
+
const stat = (over: Partial<VerdictGateStat> = {}): VerdictGateStat => ({
|
|
173
|
+
samples: 10,
|
|
174
|
+
agree: 10,
|
|
175
|
+
behind: 0,
|
|
176
|
+
aheadBenign: 0,
|
|
177
|
+
aheadHard: 0,
|
|
178
|
+
selfHeal: { count: 0, max: 0, mean: 0 },
|
|
179
|
+
gateClean: true,
|
|
180
|
+
...over,
|
|
181
|
+
})
|
|
182
|
+
const mkSummary = (over: {
|
|
183
|
+
rgBehind?: number
|
|
184
|
+
rgAheadHard?: number
|
|
185
|
+
center?: Record<string, number>
|
|
186
|
+
births?: Record<string, number>
|
|
187
|
+
restarts?: Record<string, number>
|
|
188
|
+
} = {}): GateSummary => ({
|
|
189
|
+
verdicts: {
|
|
190
|
+
occupancy: stat(),
|
|
191
|
+
readyGate: stat({ behind: over.rgBehind ?? 0, aheadHard: over.rgAheadHard ?? 0 }),
|
|
192
|
+
liveness: stat(),
|
|
193
|
+
},
|
|
194
|
+
hardViolations: [],
|
|
195
|
+
coverage: {
|
|
196
|
+
fleetWidthMax: 2,
|
|
197
|
+
runtimes: ['claude', 'codex'],
|
|
198
|
+
byRuntimeState: {},
|
|
199
|
+
births: over.births ?? {},
|
|
200
|
+
restarts: over.restarts ?? {},
|
|
201
|
+
historySamples: {},
|
|
202
|
+
readyResumeCaught: over.center ?? {},
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
const COMPLETE = {
|
|
206
|
+
center: { codex: 1 },
|
|
207
|
+
births: { 'claude/fresh': 1, 'codex/wake-resume': 1 },
|
|
208
|
+
restarts: { codex: 1 },
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
test('incomplete (empty) → NOT eligible, reasons name every missing cell (actionable)', () => {
|
|
212
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary())
|
|
213
|
+
expect(e.eligible).toBe(false)
|
|
214
|
+
expect(e.reasons.some(r => /center codex×wake-resume×history/.test(r))).toBe(true)
|
|
215
|
+
expect(e.reasons.some(r => /no codex birth/.test(r))).toBe(true)
|
|
216
|
+
expect(e.reasons.some(r => /no claude birth/.test(r))).toBe(true)
|
|
217
|
+
expect(e.reasons.some(r => /no restart/.test(r))).toBe(true)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('complete + clean → eligible', () => {
|
|
221
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary(COMPLETE))
|
|
222
|
+
expect(e.eligible).toBe(true)
|
|
223
|
+
expect(e.reasons).toHaveLength(0)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('complete BUT dirty (readyGate behind) → NOT eligible, reason names the dirt', () => {
|
|
227
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, rgBehind: 2 }))
|
|
228
|
+
expect(e.eligible).toBe(false)
|
|
229
|
+
expect(e.reasons.some(r => /readyGate dirty: behind=2/.test(r))).toBe(true)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('complete except the CENTER cell empty → NOT eligible, center reason only', () => {
|
|
233
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, center: {} }))
|
|
234
|
+
expect(e.eligible).toBe(false)
|
|
235
|
+
expect(e.reasons).toHaveLength(1)
|
|
236
|
+
expect(e.reasons[0]).toMatch(/center codex×wake-resume×history: 0/)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('complete except no restart → NOT eligible, restart reason only', () => {
|
|
240
|
+
const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, restarts: {} }))
|
|
241
|
+
expect(e.eligible).toBe(false)
|
|
242
|
+
expect(e.reasons).toEqual(['no restart observed'])
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('composer-busy is ORTHOGONAL — its absence never blocks ready-gate eligibility', () => {
|
|
246
|
+
// COMPLETE carries no composer-busy state anywhere; eligibility must still be true.
|
|
247
|
+
expect(evaluateReadyGateFlipEligibility(mkSummary(COMPLETE)).eligible).toBe(true)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
@@ -0,0 +1,372 @@
|
|
|
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
|
+
/** History present at first sight ⇒ this peer woke WITH resume-history (vs a fresh empty birth)
|
|
97
|
+
* — the population the center cell (codex×wake-resume×history) is measured on. */
|
|
98
|
+
bornWithHistory: boolean
|
|
99
|
+
/** Previous tmux ready value, to detect the false/none→true readiness transition. */
|
|
100
|
+
prevReadyTmux: boolean | null
|
|
101
|
+
open: Record<Verdict, OpenAhead | null>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface VerdictTally {
|
|
105
|
+
samples: number
|
|
106
|
+
agree: number
|
|
107
|
+
behind: number
|
|
108
|
+
aheadBenign: number
|
|
109
|
+
aheadHard: number
|
|
110
|
+
aheadLatencies: number[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class GateTracker {
|
|
114
|
+
private readonly settleTicks: number
|
|
115
|
+
private readonly budget: number
|
|
116
|
+
private peers = new Map<string, PeerState>()
|
|
117
|
+
private goneOnce = new Set<string>()
|
|
118
|
+
private tally: Record<Verdict, VerdictTally>
|
|
119
|
+
private hard: HardViolation[] = []
|
|
120
|
+
// coverage
|
|
121
|
+
private fleetWidthMax = 0
|
|
122
|
+
private stateCells = new Map<string, number>() // `${runtime}/${state}` → count
|
|
123
|
+
private births = new Map<string, number>() // `${runtime}/${fresh|wake-resume}` → count
|
|
124
|
+
private restarts = new Map<string, number>() // runtime → count
|
|
125
|
+
private historySamples = new Map<string, number>() // runtime → count
|
|
126
|
+
// CENTER cell: a wake-resume-with-history peer whose readiness the MODEL caught at the tmux
|
|
127
|
+
// ready transition (rgTmux false/none→true with rgPty agreeing) — the codex splash-off-screen
|
|
128
|
+
// case the ready-gate flip exists for. A behind at that transition instead lands in readyGate.behind.
|
|
129
|
+
private readyResumeCaught = new Map<string, number>() // runtime → count
|
|
130
|
+
private runtimesSeen = new Set<string>()
|
|
131
|
+
|
|
132
|
+
constructor(cfg: GateConfig = {}) {
|
|
133
|
+
this.settleTicks = cfg.settleTicks ?? 3
|
|
134
|
+
this.budget = cfg.convergeBudgetTicks ?? 3
|
|
135
|
+
const mk = (): VerdictTally => ({ samples: 0, agree: 0, behind: 0, aheadBenign: 0, aheadHard: 0, aheadLatencies: [] })
|
|
136
|
+
this.tally = { occupancy: mk(), readyGate: mk(), liveness: mk() }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Once per tick, before the per-peer samples — records the concurrent fleet width. */
|
|
140
|
+
tickStart(nPeers: number): void {
|
|
141
|
+
if (nPeers > this.fleetWidthMax) this.fleetWidthMax = nPeers
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The observer dropped a peer (session gone) — a later reappearance is a restart. */
|
|
145
|
+
peerGone(addr: string): void {
|
|
146
|
+
if (this.peers.delete(addr)) this.goneOnce.add(addr)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private inc(map: Map<string, number>, key: string): void {
|
|
150
|
+
map.set(key, (map.get(key) ?? 0) + 1)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Record one peer's sample at `tick`. Updates boot state, classifies every verdict's
|
|
154
|
+
* direction, manages ahead-transients, and accumulates the coverage matrix. */
|
|
155
|
+
sample(tick: number, s: PeerSample): void {
|
|
156
|
+
this.runtimesSeen.add(s.runtime)
|
|
157
|
+
let ps = this.peers.get(s.peer)
|
|
158
|
+
if (!ps) {
|
|
159
|
+
// First sight this lifetime → a birth (or a restart if we saw it die before).
|
|
160
|
+
const birthType = s.hasHistory ? 'wake-resume' : 'fresh'
|
|
161
|
+
this.inc(this.births, `${s.runtime}/${birthType}`)
|
|
162
|
+
if (this.goneOnce.has(s.peer)) {
|
|
163
|
+
this.inc(this.restarts, s.runtime)
|
|
164
|
+
this.goneOnce.delete(s.peer)
|
|
165
|
+
}
|
|
166
|
+
ps = {
|
|
167
|
+
runtime: s.runtime,
|
|
168
|
+
firstTick: tick,
|
|
169
|
+
readyStreak: 0,
|
|
170
|
+
bootDone: false,
|
|
171
|
+
bornWithHistory: s.hasHistory,
|
|
172
|
+
prevReadyTmux: null,
|
|
173
|
+
open: { occupancy: null, readyGate: null, liveness: null },
|
|
174
|
+
}
|
|
175
|
+
this.peers.set(s.peer, ps)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Boot-window: a peer is booted once tmux-ready has held true for settleTicks in a row.
|
|
179
|
+
if (s.readyGate.tmux === true) {
|
|
180
|
+
ps.readyStreak++
|
|
181
|
+
if (ps.readyStreak >= this.settleTicks) ps.bootDone = true
|
|
182
|
+
} else if (s.readyGate.tmux === false) {
|
|
183
|
+
ps.readyStreak = 0
|
|
184
|
+
}
|
|
185
|
+
const bootWindow = !ps.bootDone
|
|
186
|
+
|
|
187
|
+
// Center-cell signal: at the readiness transition (rgTmux false/none→true) of a
|
|
188
|
+
// wake-resume-with-history peer, did the MODEL catch readiness too (rgPty=true)? That is the
|
|
189
|
+
// codex splash-off-screen case the ready-gate flip exists for. (A model MISS at this transition
|
|
190
|
+
// is a pty-behind → readyGate.behind → dirty → ineligible, which is exactly right.)
|
|
191
|
+
if (s.readyGate.tmux !== null) {
|
|
192
|
+
// becameReady requires an OBSERVED not-ready→ready transition (prev === false), NOT
|
|
193
|
+
// first-sight-already-ready (prev === null): the center cell must capture a REAL resume
|
|
194
|
+
// splash→ready the observer witnessed (boris's representativeness guardrail), not a warm
|
|
195
|
+
// peer that was already up when the observer attached.
|
|
196
|
+
const becameReady = s.readyGate.tmux === true && ps.prevReadyTmux === false
|
|
197
|
+
if (becameReady && ps.bornWithHistory && s.readyGate.pty === true) this.inc(this.readyResumeCaught, s.runtime)
|
|
198
|
+
ps.prevReadyTmux = s.readyGate.tmux
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Coverage.
|
|
202
|
+
const state = classifySampleState(s.occupancy.tmux === true, s.bytesFed)
|
|
203
|
+
this.inc(this.stateCells, `${s.runtime}/${state}`)
|
|
204
|
+
if (s.hasHistory) this.inc(this.historySamples, s.runtime)
|
|
205
|
+
|
|
206
|
+
// Per-verdict direction + transient bookkeeping.
|
|
207
|
+
this.scoreVerdict('occupancy', tick, ps, bootWindow, s.runtime, s.peer, s.occupancy)
|
|
208
|
+
this.scoreVerdict('readyGate', tick, ps, bootWindow, s.runtime, s.peer, s.readyGate)
|
|
209
|
+
this.scoreVerdict('liveness', tick, ps, bootWindow, s.runtime, s.peer, s.liveness)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private scoreVerdict(
|
|
213
|
+
verdict: Verdict,
|
|
214
|
+
tick: number,
|
|
215
|
+
ps: PeerState,
|
|
216
|
+
bootWindow: boolean,
|
|
217
|
+
runtime: string,
|
|
218
|
+
peer: string,
|
|
219
|
+
vs: VerdictSample,
|
|
220
|
+
): void {
|
|
221
|
+
if (vs.tmux === null) return // tmux side unavailable → not scored
|
|
222
|
+
const t = this.tally[verdict]
|
|
223
|
+
t.samples++
|
|
224
|
+
const dir = classifyDirection(vs.pty, vs.tmux)
|
|
225
|
+
|
|
226
|
+
// An open ahead-transient that exceeds the converge budget is a hard violation the moment
|
|
227
|
+
// it overruns (logged once), regardless of how it later resolves.
|
|
228
|
+
const open = ps.open[verdict]
|
|
229
|
+
if (open && !open.flaggedNonConverged && tick - open.startTick > this.budget) {
|
|
230
|
+
open.flaggedNonConverged = true
|
|
231
|
+
t.aheadHard++
|
|
232
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-nonconverged', detail: `ahead open ${tick - open.startTick} ticks > budget ${this.budget}` })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (dir === 'agree') {
|
|
236
|
+
t.agree++
|
|
237
|
+
if (open) {
|
|
238
|
+
// transient closed → measure self-heal latency; benign iff it opened in the boot
|
|
239
|
+
// window AND healed within budget (and was not already flagged non-converged).
|
|
240
|
+
const latency = tick - open.startTick
|
|
241
|
+
t.aheadLatencies.push(latency)
|
|
242
|
+
if (!open.flaggedNonConverged) {
|
|
243
|
+
if (open.bootWindow && latency <= this.budget) t.aheadBenign++
|
|
244
|
+
else {
|
|
245
|
+
t.aheadHard++
|
|
246
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: open.bootWindow ? 'ahead-nonconverged' : 'ahead-steady', detail: `latency ${latency}, boot=${open.bootWindow}` })
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
ps.open[verdict] = null
|
|
250
|
+
}
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (dir === 'pty-behind') {
|
|
255
|
+
// The stale-frame class — always hard, zero tolerance. A behind also closes any open
|
|
256
|
+
// ahead-transient as an abnormal resolution (not a benign heal).
|
|
257
|
+
t.behind++
|
|
258
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: 'behind' })
|
|
259
|
+
ps.open[verdict] = null
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// pty-ahead: open a transient if none is open. Steady-state ahead is hard immediately
|
|
264
|
+
// (it cannot be a boot self-heal); boot-window ahead waits to see if it converges.
|
|
265
|
+
if (!open) {
|
|
266
|
+
if (!bootWindow) {
|
|
267
|
+
t.aheadHard++
|
|
268
|
+
this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-steady' })
|
|
269
|
+
// still open it so we can measure when it clears, but it is already counted hard
|
|
270
|
+
ps.open[verdict] = { startTick: tick, bootWindow: false, flaggedNonConverged: true }
|
|
271
|
+
} else {
|
|
272
|
+
ps.open[verdict] = { startTick: tick, bootWindow: true, flaggedNonConverged: false }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** True iff no hard divergence has been recorded for `verdict` (the per-verdict gate bar). */
|
|
278
|
+
verdictGateClean(verdict: Verdict): boolean {
|
|
279
|
+
const t = this.tally[verdict]
|
|
280
|
+
return t.behind === 0 && t.aheadHard === 0
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
summary(): GateSummary {
|
|
284
|
+
const latStats = (xs: number[]): { count: number; max: number; mean: number } =>
|
|
285
|
+
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 }
|
|
286
|
+
const verdicts = {} as Record<Verdict, VerdictGateStat>
|
|
287
|
+
for (const v of VERDICTS) {
|
|
288
|
+
const t = this.tally[v]
|
|
289
|
+
verdicts[v] = {
|
|
290
|
+
samples: t.samples,
|
|
291
|
+
agree: t.agree,
|
|
292
|
+
behind: t.behind,
|
|
293
|
+
aheadBenign: t.aheadBenign,
|
|
294
|
+
aheadHard: t.aheadHard,
|
|
295
|
+
selfHeal: latStats(t.aheadLatencies),
|
|
296
|
+
gateClean: this.verdictGateClean(v),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
verdicts,
|
|
301
|
+
hardViolations: this.hard.slice(),
|
|
302
|
+
coverage: {
|
|
303
|
+
fleetWidthMax: this.fleetWidthMax,
|
|
304
|
+
runtimes: [...this.runtimesSeen].sort(),
|
|
305
|
+
byRuntimeState: Object.fromEntries([...this.stateCells].sort()),
|
|
306
|
+
births: Object.fromEntries([...this.births].sort()),
|
|
307
|
+
restarts: Object.fromEntries([...this.restarts].sort()),
|
|
308
|
+
historySamples: Object.fromEntries([...this.historySamples].sort()),
|
|
309
|
+
readyResumeCaught: Object.fromEntries([...this.readyResumeCaught].sort()),
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Ready-gate FLIP eligibility — boris's verdict-scoped gate, operationalized so the burn-in
|
|
316
|
+
* SELF-REPORTS instead of being eyeballed. NECESSARY input, NOT an auto-trigger: the flip stays
|
|
317
|
+
* an explicit human decision (the representativeness of the filled cells — e.g. the splash truly
|
|
318
|
+
* scrolled off-screen — is verified separately on top of this). Eligible iff:
|
|
319
|
+
* • ready-gate is HARD-clean (behind=0 ∧ aheadHard=0), AND
|
|
320
|
+
* • the CENTER cell is filled: codex woke WITH history and the model caught its readiness, AND
|
|
321
|
+
* • a codex AND a claude birth were observed, AND
|
|
322
|
+
* • a restart was observed.
|
|
323
|
+
* composer-busy is ORTHOGONAL to readiness (a typing human already passed readiness) → excluded;
|
|
324
|
+
* it gates the LATER occupancy flip. On !eligible `reasons` say WHICH cell is empty/dirty —
|
|
325
|
+
* actionable, never a black-box "not yet". PURE → its own logic is unit-testable (boris's guardrail). */
|
|
326
|
+
export function evaluateReadyGateFlipEligibility(s: GateSummary): FlipEligibility {
|
|
327
|
+
const reasons: string[] = []
|
|
328
|
+
const rg = s.verdicts.readyGate
|
|
329
|
+
if (rg.behind > 0 || rg.aheadHard > 0) reasons.push(`readyGate dirty: behind=${rg.behind} aheadHard=${rg.aheadHard}`)
|
|
330
|
+
const center = s.coverage.readyResumeCaught['codex'] ?? 0
|
|
331
|
+
if (center < 1) reasons.push(`center codex×wake-resume×history: ${center} readiness caught (need ≥1, representatively splash-off-screen)`)
|
|
332
|
+
const hasBirth = (rt: string): boolean => Object.keys(s.coverage.births).some(k => k.startsWith(`${rt}/`))
|
|
333
|
+
if (!hasBirth('codex')) reasons.push('no codex birth observed')
|
|
334
|
+
if (!hasBirth('claude')) reasons.push('no claude birth observed')
|
|
335
|
+
const restarts = Object.values(s.coverage.restarts).reduce((a, b) => a + b, 0)
|
|
336
|
+
if (restarts < 1) reasons.push('no restart observed')
|
|
337
|
+
return { verdict: 'readyGate', eligible: reasons.length === 0, reasons }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface VerdictGateStat {
|
|
341
|
+
samples: number
|
|
342
|
+
agree: number
|
|
343
|
+
behind: number
|
|
344
|
+
aheadBenign: number
|
|
345
|
+
aheadHard: number
|
|
346
|
+
selfHeal: { count: number; max: number; mean: number }
|
|
347
|
+
gateClean: boolean
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export interface GateCoverage {
|
|
351
|
+
fleetWidthMax: number
|
|
352
|
+
runtimes: string[]
|
|
353
|
+
byRuntimeState: Record<string, number>
|
|
354
|
+
births: Record<string, number>
|
|
355
|
+
restarts: Record<string, number>
|
|
356
|
+
historySamples: Record<string, number>
|
|
357
|
+
/** runtime → count of wake-resume-with-history readiness transitions the MODEL caught (center cell). */
|
|
358
|
+
readyResumeCaught: Record<string, number>
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface GateSummary {
|
|
362
|
+
verdicts: Record<Verdict, VerdictGateStat>
|
|
363
|
+
hardViolations: HardViolation[]
|
|
364
|
+
coverage: GateCoverage
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export interface FlipEligibility {
|
|
368
|
+
verdict: 'readyGate'
|
|
369
|
+
eligible: boolean
|
|
370
|
+
/** On !eligible: one actionable line per unmet requirement (which cell is empty/dirty). */
|
|
371
|
+
reasons: string[]
|
|
372
|
+
}
|