@agfpd/iapeer 0.2.76 → 0.2.78

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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/package.json +21 -2
  4. package/src/cli/index.ts +47 -48
  5. package/src/connect/index.ts +4 -4
  6. package/src/core/constants.ts +3 -3
  7. package/src/core/normalize.ts +2 -2
  8. package/src/create/index.ts +1 -1
  9. package/src/daemon/index.ts +5 -5
  10. package/src/daemon/main.ts +9 -9
  11. package/src/enable/index.ts +19 -18
  12. package/src/enable/provisionCommand.ts +10 -10
  13. package/src/identity/index.ts +2 -2
  14. package/src/identity/profileStandard.ts +8 -8
  15. package/src/init/index.ts +20 -13
  16. package/src/install/index.ts +2 -2
  17. package/src/install/signing.ts +3 -4
  18. package/src/launch/adapters/claude.ts +7 -7
  19. package/src/launch/adapters/codex.ts +5 -6
  20. package/src/launch/canary.ts +8 -8
  21. package/src/launch/cmdlog.ts +3 -3
  22. package/src/launch/composeSystemPrompt.ts +2 -1
  23. package/src/launch/index.ts +5 -6
  24. package/src/launch/invocation.ts +1 -1
  25. package/src/launch/launchd.ts +14 -15
  26. package/src/launch/launchdRun.ts +6 -6
  27. package/src/launch/nativeMemory.ts +9 -9
  28. package/src/launch/ptyHost.ts +1 -1
  29. package/src/launch/readyGateModel.ts +3 -3
  30. package/src/launch/types.ts +7 -7
  31. package/src/lifecycle/eventlog.ts +2 -2
  32. package/src/lifecycle/index.ts +21 -21
  33. package/src/lifecycle/queue.ts +1 -1
  34. package/src/onboard/index.ts +19 -13
  35. package/src/onboard/memory.ts +5 -5
  36. package/src/onboard/steps.ts +8 -9
  37. package/src/provision/index.ts +2 -3
  38. package/src/runtime/deploy.ts +2 -2
  39. package/src/runtime/index.ts +4 -4
  40. package/src/shadow/gate.ts +6 -6
  41. package/src/shadow/index.ts +2 -2
  42. package/src/status/index.ts +11 -11
  43. package/src/storage/index.ts +1 -1
  44. package/src/supervisor/daemon.ts +14 -8
  45. package/src/transport/index.ts +11 -11
  46. package/src/update/index.ts +4 -4
  47. package/src/cli/cli.test.ts +0 -603
  48. package/src/cli/listTui.test.ts +0 -71
  49. package/src/codec/codec.test.ts +0 -271
  50. package/src/connect/connect.test.ts +0 -163
  51. package/src/core/constants.test.ts +0 -32
  52. package/src/core/normalize.test.ts +0 -98
  53. package/src/create/create.test.ts +0 -143
  54. package/src/daemon/daemon-http.test.ts +0 -114
  55. package/src/daemon/daemon.test.ts +0 -267
  56. package/src/daemon/main.test.ts +0 -413
  57. package/src/enable/enable.test.ts +0 -95
  58. package/src/enable/provisionCommand.test.ts +0 -107
  59. package/src/identity/identity.test.ts +0 -279
  60. package/src/identity/profileStandard.test.ts +0 -204
  61. package/src/init/init.test.ts +0 -213
  62. package/src/init/runtime-resolve.test.ts +0 -49
  63. package/src/install/install.test.ts +0 -66
  64. package/src/install/signing.test.ts +0 -84
  65. package/src/launch/bootdeliver.test.ts +0 -108
  66. package/src/launch/bootstrap.test.ts +0 -56
  67. package/src/launch/canary.test.ts +0 -346
  68. package/src/launch/cmdlog.test.ts +0 -94
  69. package/src/launch/codexHooksTrust.test.ts +0 -248
  70. package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
  71. package/src/launch/composeSystemPrompt.test.ts +0 -145
  72. package/src/launch/hostComposerOccupied.test.ts +0 -63
  73. package/src/launch/invocation.test.ts +0 -174
  74. package/src/launch/launch.test.ts +0 -359
  75. package/src/launch/launchd.test.ts +0 -499
  76. package/src/launch/nativeMemory.test.ts +0 -193
  77. package/src/launch/ptyHost.test.ts +0 -38
  78. package/src/launch/readyGateModel.test.ts +0 -72
  79. package/src/launch/sockdir.test.ts +0 -74
  80. package/src/lifecycle/eventlog.test.ts +0 -114
  81. package/src/lifecycle/lifecycle.test.ts +0 -1056
  82. package/src/lifecycle/queue.test.ts +0 -185
  83. package/src/onboard/memory.test.ts +0 -157
  84. package/src/onboard/onboard.test.ts +0 -39
  85. package/src/onboard/steps.test.ts +0 -201
  86. package/src/provision/provision.test.ts +0 -288
  87. package/src/registry/registry.test.ts +0 -432
  88. package/src/runtime/runtime.test.ts +0 -226
  89. package/src/runtime/update.test.ts +0 -192
  90. package/src/shadow/enumerate-skip.test.ts +0 -67
  91. package/src/shadow/gate.test.ts +0 -286
  92. package/src/shadow/install.test.ts +0 -153
  93. package/src/shadow/shadow.test.ts +0 -42
  94. package/src/status/status.test.ts +0 -208
  95. package/src/storage/peers-home.test.ts +0 -34
  96. package/src/storage/rotatelog.test.ts +0 -44
  97. package/src/storage/storage.test.ts +0 -65
  98. package/src/supervisor/attached-marker.test.ts +0 -84
  99. package/src/supervisor/boot.test.ts +0 -66
  100. package/src/supervisor/client.attach.test.ts +0 -107
  101. package/src/supervisor/daemon.test.ts +0 -213
  102. package/src/supervisor/deliver.test.ts +0 -58
  103. package/src/supervisor/panelog.test.ts +0 -95
  104. package/src/supervisor/protocol.test.ts +0 -222
  105. package/src/supervisor/selfargv.test.ts +0 -22
  106. package/src/supervisor/spinner-strip.test.ts +0 -52
  107. package/src/transport/compactDone.test.ts +0 -144
  108. package/src/transport/composerQueue.test.ts +0 -204
  109. package/src/transport/hostControl.test.ts +0 -87
  110. package/src/transport/hostDeliver.test.ts +0 -137
  111. package/src/transport/hostLive.test.ts +0 -50
  112. package/src/transport/routesend-wake.test.ts +0 -357
  113. package/src/update/update.test.ts +0 -208
@@ -1,192 +0,0 @@
1
- // update-runtime (§(г)) — version-gate / forced re-install / idempotent re-provision /
2
- // peer restart. Sandboxed (IAPEER_ROOT temp dirs, IAPEER_TEST_SANDBOX), injected
3
- // npx + npm-version + restart.
4
-
5
- import { afterEach, describe, expect, test } from 'bun:test'
6
- import { mkdtempSync, rmSync, writeFileSync } from 'fs'
7
- import { tmpdir } from 'os'
8
- import { join } from 'path'
9
- import { updateAllRuntimes, updateRuntime, type RestartedPeer } from './update.ts'
10
- import { readRuntimeManifest, writeRuntimeManifest, type RuntimeManifest } from './index.ts'
11
- import { upsertPeer } from '../registry/index.ts'
12
-
13
- const roots: string[] = []
14
- function mkTmp(): string {
15
- const d = mkdtempSync(join(tmpdir(), 'iapeer-rtup-'))
16
- roots.push(d)
17
- return d
18
- }
19
- afterEach(() => {
20
- while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
21
- })
22
-
23
- function envFor(root: string, path?: string): NodeJS.ProcessEnv {
24
- return {
25
- IAPEER_ROOT: join(root, 'iapeer'),
26
- IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
27
- IAPEER_TEST_SANDBOX: '1',
28
- HOME: root,
29
- ...(path ? { PATH: path } : {}),
30
- } as NodeJS.ProcessEnv
31
- }
32
-
33
- function stubBins(): { dir: string; hook: string } {
34
- const dir = mkTmp()
35
- writeFileSync(join(dir, 'notifier-runtime'), '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
36
- const hook = join(dir, 'sc.sh')
37
- writeFileSync(hook, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
38
- return { dir, hook }
39
- }
40
-
41
- const restartOk =
42
- (log: string[]) =>
43
- (personality: string): RestartedPeer => {
44
- log.push(personality)
45
- return { personality, state: 'restarted' }
46
- }
47
-
48
- describe('updateRuntime (§(г): gate → re-install → re-provision → restart)', () => {
49
- test('full update: re-npx forced, declared set re-provisioned, peers restarted', async () => {
50
- const root = mkTmp()
51
- const { dir, hook } = stubBins()
52
- const env = envFor(root, dir)
53
- writeRuntimeManifest(
54
- { runtime: 'notifier', version: '0.1.0', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] },
55
- { env },
56
- )
57
- // the live-host layout: the declared peer already sits in its DEFAULT location
58
- // (re-provision must be a no-clobber pass over it, not an identity conflict)
59
- await upsertPeer(
60
- { personality: 'timer', runtime: 'notifier', cwd: join(root, 'iapeer', 'peers', 'timer'), intelligence: 'absent' },
61
- { env },
62
- )
63
- let npxRan = false
64
- const restarts: string[] = []
65
- const r = await updateRuntime({
66
- runtime: 'notifier',
67
- env,
68
- runNpx: (_pkg, e) => {
69
- npxRan = true
70
- // the package self-deploys the NEW manifest with the new version stamp
71
- const m: RuntimeManifest = { runtime: 'notifier', version: '0.2.0', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] }
72
- writeRuntimeManifest(m, { env: e })
73
- return { ok: true }
74
- },
75
- npmVersion: () => '0.2.0',
76
- restartPeer: restartOk(restarts),
77
- })
78
- expect(r.state).toBe('updated')
79
- expect(npxRan).toBe(true) // FORCED re-npx despite the present manifest
80
- expect(r.from).toBe('0.1.0')
81
- expect(r.to).toBe('0.2.0')
82
- expect(r.peers.map(p => p.personality)).toEqual(['timer']) // re-provisioned
83
- expect(restarts).toEqual(['timer']) // restarted via the injected stop/start
84
- expect(readRuntimeManifest('notifier', { env })?.version).toBe('0.2.0')
85
- })
86
-
87
- test('version-gate: manifest stamp equals npm latest → already-latest, NOTHING runs', async () => {
88
- const env = envFor(mkTmp())
89
- writeRuntimeManifest({ runtime: 'notifier', version: '0.2.0' }, { env })
90
- let npxRan = false
91
- const r = await updateRuntime({ runtime: 'notifier', env, runNpx: () => ((npxRan = true), { ok: true }), npmVersion: () => '0.2.0' })
92
- expect(r.state).toBe('already-latest')
93
- expect(npxRan).toBe(false)
94
- })
95
-
96
- test('--force overrides the gate', async () => {
97
- const root = mkTmp()
98
- const { dir, hook } = stubBins()
99
- const env = envFor(root, dir)
100
- writeRuntimeManifest({ runtime: 'notifier', version: '0.2.0', selfConfig: hook }, { env })
101
- let npxRan = false
102
- const r = await updateRuntime({
103
- runtime: 'notifier',
104
- env,
105
- force: true,
106
- runNpx: () => ((npxRan = true), { ok: true }),
107
- npmVersion: () => '0.2.0',
108
- restartPeer: restartOk([]),
109
- })
110
- expect(r.state).toBe('updated')
111
- expect(npxRan).toBe(true)
112
- })
113
-
114
- test('no version stamp → gate skipped, idempotent re-install with an honest detail', async () => {
115
- const root = mkTmp()
116
- const { dir, hook } = stubBins()
117
- const env = envFor(root, dir)
118
- writeRuntimeManifest({ runtime: 'notifier', selfConfig: hook }, { env }) // owners have not stamped yet
119
- const r = await updateRuntime({
120
- runtime: 'notifier',
121
- env,
122
- runNpx: () => ({ ok: true }),
123
- npmVersion: () => '0.2.0',
124
- restartPeer: restartOk([]),
125
- })
126
- expect(r.state).toBe('updated')
127
- expect(r.detail).toContain('no version stamp')
128
- })
129
-
130
- test('not installed → not-installed with the install-runtime recipe; npm unreachable → loud', async () => {
131
- const env = envFor(mkTmp())
132
- const r = await updateRuntime({ runtime: 'notifier', env, npmVersion: () => '0.2.0' })
133
- expect(r.state).toBe('not-installed')
134
- expect(r.detail).toContain('install-runtime notifier')
135
- writeRuntimeManifest({ runtime: 'notifier', version: '0.1.0' }, { env })
136
- const r2 = await updateRuntime({ runtime: 'notifier', env, npmVersion: () => null })
137
- expect(r2.state).toBe('npm-unreachable')
138
- })
139
-
140
- test('failed re-npx → install-failed, no deploy, no restarts', async () => {
141
- const env = envFor(mkTmp())
142
- writeRuntimeManifest({ runtime: 'notifier', version: '0.1.0' }, { env })
143
- const restarts: string[] = []
144
- const r = await updateRuntime({
145
- runtime: 'notifier',
146
- env,
147
- runNpx: () => ({ ok: false, detail: 'network down' }),
148
- npmVersion: () => '0.2.0',
149
- restartPeer: restartOk(restarts),
150
- })
151
- expect(r.state).toBe('install-failed')
152
- expect(restarts).toEqual([])
153
- })
154
-
155
- test('mode-b runtime (telegram, no declared peers): empty re-provision, registered router restarted', async () => {
156
- const root = mkTmp()
157
- const env = envFor(root)
158
- writeRuntimeManifest({ runtime: 'telegram', version: '0.10.0' }, { env })
159
- await upsertPeer({ personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' }, { env })
160
- const restarts: string[] = []
161
- const r = await updateRuntime({
162
- runtime: 'telegram',
163
- env,
164
- runNpx: (_pkg, e) => {
165
- writeRuntimeManifest({ runtime: 'telegram', version: '0.10.3' }, { env: e })
166
- return { ok: true }
167
- },
168
- npmVersion: () => '0.10.3',
169
- restartPeer: restartOk(restarts),
170
- })
171
- expect(r.state).toBe('updated')
172
- expect(r.peers).toEqual([]) // operator-add runtime: nothing declared to re-provision
173
- expect(restarts).toEqual(['arthur']) // the router still restarts onto the new code
174
- })
175
- })
176
-
177
- describe('updateAllRuntimes (--all)', () => {
178
- test('updates installed runtimes, reports the rest not-installed (never an error)', async () => {
179
- const root = mkTmp()
180
- const env = envFor(root)
181
- writeRuntimeManifest({ runtime: 'telegram', version: '1.0.0' }, { env })
182
- const results = await updateAllRuntimes({
183
- env,
184
- runNpx: () => ({ ok: true }),
185
- npmVersion: () => '1.0.0',
186
- restartPeer: restartOk([]),
187
- })
188
- const byRt = Object.fromEntries(results.map(r => [r.runtime, r.state]))
189
- expect(byRt.telegram).toBe('already-latest')
190
- expect(byRt.notifier).toBe('not-installed')
191
- })
192
- })
@@ -1,67 +0,0 @@
1
- // Spawn-flip cutover Block 2, Ф0b-3 slice 3b — the fidelity observer SKIPS supervisor-hosted peers.
2
- //
3
- // The observer compares the pty-model vs LIVE TMUX. pty-hosting is now the DEFAULT, so a normal
4
- // (unmarked) peer has no tmux to compare against — and during a flip a lingering tmux socket for it
5
- // would surface FALSE divergences. This proves the skip fires IN enumerate even in that race (a live
6
- // tmux session + a pane-log present): the default peer is NOT enrolled, while a `.no-pty-host` opt-out
7
- // (tmux) sibling IS. Real-tmux, guarded (a clean CI runner has no tmux; the release machine has it —
8
- // same pattern as routesend-wake).
9
- import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
10
- import { spawnSync } from 'node:child_process'
11
- import { closeSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
12
- import { tmpdir } from 'node:os'
13
- import { join } from 'node:path'
14
- import { buildSocketPath } from '../core/socket.ts'
15
- import { enumerate } from './index.ts'
16
-
17
- const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
18
-
19
- let sockDir: string
20
- let logDir: string
21
- const KEEP = 'keepobs' // .no-pty-host opt-out (tmux) → observed
22
- const SKIP = 'skipobs' // default (no marker) → pty-hosted → skipped
23
- const addrOf = (p: string) => `codex-${p}`
24
- const sockOf = (p: string) => buildSocketPath('codex', p, sockDir)
25
-
26
- function startTmux(personality: string): void {
27
- const r = spawnSync(
28
- 'tmux',
29
- ['-S', sockOf(personality), 'new-session', '-d', '-x', '80', '-y', '24', '-s', addrOf(personality), 'cat'],
30
- { encoding: 'utf8' },
31
- )
32
- if (r.status !== 0) throw new Error(`tmux new-session failed: ${r.stderr}`)
33
- }
34
- function killTmux(personality: string): void {
35
- spawnSync('tmux', ['-S', sockOf(personality), 'kill-server'], { stdio: 'ignore' })
36
- }
37
-
38
- beforeAll(() => {
39
- sockDir = mkdtempSync(join(tmpdir(), 'iapeer-obs-sock-'))
40
- logDir = mkdtempSync(join(tmpdir(), 'iapeer-obs-log-'))
41
- if (!tmuxAvailable) return
42
- // Both peers: a live tmux session + a pane-log (so enrolment is otherwise satisfied).
43
- for (const p of [KEEP, SKIP]) {
44
- startTmux(p)
45
- writeFileSync(join(logDir, `${addrOf(p)}.log`), '')
46
- }
47
- // KEEP carries the `.no-pty-host` opt-OUT → it is on tmux, so the observer MUST compare it. SKIP is a
48
- // DEFAULT (unmarked) peer → pty-hosted → skipped even WITH a lingering live tmux session (the
49
- // flip-transition race the skip guards against).
50
- writeFileSync(join(logDir, `${addrOf(KEEP)}.no-pty-host`), '')
51
- })
52
-
53
- afterAll(() => {
54
- if (tmuxAvailable) for (const p of [KEEP, SKIP]) killTmux(p)
55
- rmSync(sockDir, { recursive: true, force: true })
56
- rmSync(logDir, { recursive: true, force: true })
57
- })
58
-
59
- describe.if(tmuxAvailable)('observer enumerate skips pty-default peers, compares .no-pty-host (tmux) peers', () => {
60
- test('a default (unmarked, pty-hosted) peer is NOT enrolled despite a live tmux session; a .no-pty-host opt-out one IS', () => {
61
- const models = new Map<string, any>()
62
- enumerate(models, { sockDir, logDir, eventLogDir: logDir })
63
- expect(models.has(addrOf(KEEP))).toBe(true) // .no-pty-host opt-out (tmux) → observed (live tmux + log)
64
- expect(models.has(addrOf(SKIP))).toBe(false) // default pty-hosted → skipped despite lingering live tmux
65
- for (const o of models.values()) { try { if (o.fd != null) closeSync(o.fd) } catch { /* */ } }
66
- })
67
- })
@@ -1,286 +0,0 @@
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 — robust center cell (gone→back + history + first-ready parity; cadence-proof)', () => {
149
- // Drive a peer through a gone→back cycle: birth (not-ready), then die. The caller's next sample
150
- // is the re-enroll (a resume/restart), where the center is evaluated at the first ready sample.
151
- const goneBack = (g: GateTracker, peer: string, rt: string): void => {
152
- g.sample(0, mkSample(peer, rt, { hasHistory: true, readyGate: { pty: false, tmux: false } }))
153
- g.peerGone(peer)
154
- }
155
-
156
- test('warm-continuous peer (never gone→back) → NOT counted, even ready-with-history (warm-exclusion)', () => {
157
- const g = new GateTracker()
158
- g.sample(0, mkSample('w', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } }))
159
- g.sample(1, mkSample('w', 'codex', { hasHistory: true, readyGate: { pty: true, tmux: true } }))
160
- expect(g.summary().coverage.readyResumeCaught['codex']).toBeUndefined()
161
- })
162
-
163
- test('gone→back + history + first-ready parity → counted (robust, even first-sight-already-ready)', () => {
164
- const g = new GateTracker()
165
- goneBack(g, 'c', 'codex')
166
- g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: true, tmux: true } })) // re-enroll already-ready (fast resume)
167
- const cov = g.summary().coverage
168
- expect(cov.readyResumeCaught['codex']).toBe(1)
169
- expect(cov.readyResumeCaughtViaTransition['codex']).toBeUndefined() // window not observed (fast) — still counted
170
- })
171
-
172
- test('gone→back WITHOUT history → NOT counted (a fresh restart, not a resume-with-history)', () => {
173
- const g = new GateTracker()
174
- g.sample(0, mkSample('c', 'codex', { hasHistory: false, readyGate: { pty: false, tmux: false } }))
175
- g.peerGone('c')
176
- g.sample(1, mkSample('c', 'codex', { hasHistory: false, readyGate: { pty: true, tmux: true } }))
177
- expect(g.summary().coverage.readyResumeCaught['codex']).toBeUndefined()
178
- })
179
-
180
- test('gone→back + history + DIVERGENCE at first ready (model missed) → NOT counted + dirty (behind)', () => {
181
- const g = new GateTracker()
182
- goneBack(g, 'c', 'codex')
183
- g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: true } })) // tmux ready, model NOT
184
- const s = g.summary()
185
- expect(s.coverage.readyResumeCaught['codex']).toBeUndefined()
186
- expect(s.verdicts.readyGate.behind).toBeGreaterThanOrEqual(1) // the splash-blind bug → dirty → ineligible
187
- })
188
-
189
- test('counted exactly ONCE per life (dedup: one resume = one center++)', () => {
190
- const g = new GateTracker()
191
- goneBack(g, 'c', 'codex')
192
- for (let t = 1; t <= 4; t++) g.sample(t, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: true, tmux: true } }))
193
- expect(g.summary().coverage.readyResumeCaught['codex']).toBe(1)
194
- })
195
-
196
- test('observed not-ready→ready window → counted AND flagged caughtViaTransition (diagnostic only)', () => {
197
- const g = new GateTracker()
198
- goneBack(g, 'c', 'codex')
199
- g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } })) // re-enroll not-ready
200
- g.sample(2, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: true, tmux: true } })) // becomes ready
201
- const cov = g.summary().coverage
202
- expect(cov.readyResumeCaught['codex']).toBe(1)
203
- expect(cov.readyResumeCaughtViaTransition['codex']).toBe(1) // window was observed
204
- })
205
- })
206
-
207
- describe('evaluateReadyGateFlipEligibility (boris guardrail: the eligibility logic itself under test)', () => {
208
- const stat = (over: Partial<VerdictGateStat> = {}): VerdictGateStat => ({
209
- samples: 10,
210
- agree: 10,
211
- behind: 0,
212
- aheadBenign: 0,
213
- aheadHard: 0,
214
- selfHeal: { count: 0, max: 0, mean: 0 },
215
- gateClean: true,
216
- ...over,
217
- })
218
- const mkSummary = (over: {
219
- rgBehind?: number
220
- rgAheadHard?: number
221
- center?: Record<string, number>
222
- births?: Record<string, number>
223
- restarts?: Record<string, number>
224
- } = {}): GateSummary => ({
225
- verdicts: {
226
- occupancy: stat(),
227
- readyGate: stat({ behind: over.rgBehind ?? 0, aheadHard: over.rgAheadHard ?? 0 }),
228
- liveness: stat(),
229
- },
230
- hardViolations: [],
231
- coverage: {
232
- fleetWidthMax: 2,
233
- runtimes: ['claude', 'codex'],
234
- byRuntimeState: {},
235
- births: over.births ?? {},
236
- restarts: over.restarts ?? {},
237
- historySamples: {},
238
- readyResumeCaught: over.center ?? {},
239
- readyResumeCaughtViaTransition: {},
240
- },
241
- })
242
- const COMPLETE = {
243
- center: { codex: 1 },
244
- births: { 'claude/fresh': 1, 'codex/wake-resume': 1 },
245
- restarts: { codex: 1 },
246
- }
247
-
248
- test('incomplete (empty) → NOT eligible, reasons name every missing cell (actionable)', () => {
249
- const e = evaluateReadyGateFlipEligibility(mkSummary())
250
- expect(e.eligible).toBe(false)
251
- expect(e.reasons.some(r => /center codex×wake-resume×history/.test(r))).toBe(true)
252
- expect(e.reasons.some(r => /no codex birth/.test(r))).toBe(true)
253
- expect(e.reasons.some(r => /no claude birth/.test(r))).toBe(true)
254
- expect(e.reasons.some(r => /no restart/.test(r))).toBe(true)
255
- })
256
-
257
- test('complete + clean → eligible', () => {
258
- const e = evaluateReadyGateFlipEligibility(mkSummary(COMPLETE))
259
- expect(e.eligible).toBe(true)
260
- expect(e.reasons).toHaveLength(0)
261
- })
262
-
263
- test('complete BUT dirty (readyGate behind) → NOT eligible, reason names the dirt', () => {
264
- const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, rgBehind: 2 }))
265
- expect(e.eligible).toBe(false)
266
- expect(e.reasons.some(r => /readyGate dirty: behind=2/.test(r))).toBe(true)
267
- })
268
-
269
- test('complete except the CENTER cell empty → NOT eligible, center reason only', () => {
270
- const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, center: {} }))
271
- expect(e.eligible).toBe(false)
272
- expect(e.reasons).toHaveLength(1)
273
- expect(e.reasons[0]).toMatch(/center codex×wake-resume×history: 0/)
274
- })
275
-
276
- test('complete except no restart → NOT eligible, restart reason only', () => {
277
- const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, restarts: {} }))
278
- expect(e.eligible).toBe(false)
279
- expect(e.reasons).toEqual(['no restart observed'])
280
- })
281
-
282
- test('composer-busy is ORTHOGONAL — its absence never blocks ready-gate eligibility', () => {
283
- // COMPLETE carries no composer-busy state anywhere; eligibility must still be true.
284
- expect(evaluateReadyGateFlipEligibility(mkSummary(COMPLETE)).eligible).toBe(true)
285
- })
286
- })