okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
132fa12
main
1"""A session's kernel outlives its daemon and its connection. 2 3The kernel runs detached and the daemon connects to its socket directly; it 4reattaches after a restart (graceful or crash) and after the connection drops 5mid-cell, so the namespace, the kernel process, and an in-flight cell's result 6all survive. A kernel found dead after a restart has its jobs ended. 7""" 8 9import ast 10import json 11import os 12import signal 13import subprocess 14import time 15import unittest 16 17from harness import alive 18from harness import Albedo ,Provider ,exclusive ,python ,text 19 20 21def bridges (home ): 22"""Pids of the bridges whose kernel lives under this daemon's home.""" 23listing = subprocess .run ( 24 ["ps" ,"-axo" ,"pid=,command=" ],capture_output = True ,text = True ,check = True 25 ).stdout 26return [ 27int (line .split (None ,1 )[0 ]) 28for line in listing .splitlines () 29if "albedo_bridge.py" in line and str (home )in line 30 ] 31 32 33# exclusive: restarts the daemon 34@exclusive 35class KernelDetachTests (unittest .TestCase ): 36def setUp (self ): 37self .cells = [] 38 39def script (request ): 40if request ["input" ][- 1 ].get ("role" )== "user" and self .cells : 41return python (self .cells .pop (0 )) 42return text ("ok" ) 43 44self .provider = Provider (script ) 45self .addCleanup (self .provider .close ) 46 47self .app = Albedo (self .provider ,protocol = "responses" ) 48self .app .__enter__ () 49self .addCleanup (self .app .__exit__ ,None ,None ,None ) 50self .session = self .app .session () 51 52def cell (self ,code ): 53"""Run one cell through a model turn and return its tool result.""" 54self .cells .append (code ) 55self .app .prompt (self .session ,"run it" ).close () 56self .app .idle (self .session ) 57results = [ 58json .loads (part ["value" ]) 59for entry in self .app .history (self .session )["items" ] 60if entry ["kind" ]== "tool_result" and entry ["tool" ]["name" ]== "python" 61for part in entry ["content" ] 62if part ["kind" ]== "json" and part ["field" ]== "result" 63 ] 64return results [- 1 ] 65 66def test_variables_survive_graceful_and_crash_restarts (self ): 67first = self .cell ("import os\nsurvivor = 41\nos.getpid()" ) 68self .app .restart () 69after_stop = self .cell ("survivor += 1\n(survivor, os.getpid())" ) 70self .assertEqual (after_stop ["value" ],f"(42, { first [ 'value' ] } )" ) 71self .app .restart (crash = True ) 72after_crash = self .cell ("survivor += 1\n(survivor, os.getpid())" ) 73self .assertEqual (after_crash ["value" ],f"(43, { first [ 'value' ] } )" ) 74notes = [ 75part ["text" ] 76for entry in self .app .history (self .session )["items" ] 77if entry ["kind" ]== "note" 78for part in entry ["content" ] 79if part ["kind" ]== "text" 80 ] 81self .assertFalse ([note for note in notes if "variables are gone" in note ]) 82 83def test_a_connection_dropped_mid_cell_still_delivers_the_result (self ): 84first = self .cell ("import os\nos.getpid()" ) 85self .assertEqual (bridges (self .app .home ), [],"a local kernel kept a bridge" ) 86# The kernel drops the daemon's connection from its side a second in. 87slept = self .cell ( 88"import socket, sys, threading\n" 89"link = sys.modules['__main__'].LINK\n" 90"threading.Timer(1, link.connection.shutdown, [socket.SHUT_RDWR]).start()\n" 91"threading.Event().wait(3)\nprint('woke')\nos.getpid()" 92 ) 93self .assertEqual (slept ["status" ],"ok" ,slept ) 94self .assertEqual (slept ["output" ].strip (),"woke" ) 95self .assertEqual (slept ["value" ],first ["value" ]) 96self .assertEqual (bridges (self .app .home ), []) 97 98def test_concurrent_jobs_keep_running_and_deadlines_count_wall_time (self ): 99# Legacy admission settings must not throttle new kernels. 100self .app .daemon .env .update ( 101ALBEDO_MAX_LOCAL_JOBS = "1" ,ALBEDO_JOB_GRACE_SECONDS = "0.2" 102 ) 103self .app .restart () 104program = "import time; time.sleep(5.5); print('progress', flush=True); time.sleep(60)" 105result = self .cell ( 106"import sys\n" 107f"workers = [run(sys.executable, '-c', { program !r } , timeout=7) for _ in range(3)]\n" 108"for worker in workers:\n await worker\n" 109"[(worker.timed_out, worker.duration, worker.tail().startswith('progress')) for worker in workers]" 110 ) 111self .assertEqual (result ["status" ],"ok" ,result ) 112outcomes = ast .literal_eval (result ["value" ]) 113self .assertEqual (len (outcomes ),3 ) 114for timed_out ,duration ,progressed in outcomes : 115self .assertTrue (timed_out ) 116self .assertTrue (progressed ) 117self .assertGreaterEqual (duration ,7 ) 118self .assertLess (duration ,10 ) 119 120def last_prompt (self ): 121"""The newest model request's user text: where a notice would ride.""" 122request = next ( 123record ["request" ] 124for record in reversed (self .provider .requests ) 125if any ( 126item .get ("role" )== "user" 127for item in record ["request" ].get ("input" , []) 128 ) 129 ) 130return [ 131item ["content" ]for item in request ["input" ]if item .get ("role" )== "user" 132 ][- 1 ] 133 134def test_a_restart_tells_the_model_what_the_kernel_is_still_running (self ): 135started = self .cell ( 136"import asyncio, os\nj = run('sleep', '120')\n" 137"await asyncio.sleep(0.3)\n(os.getpid(), j.id)" 138 ) 139kernel ,job = ( 140part .strip (" '\"" )for part in started ["value" ].strip ("()" ).split ("," ) 141 ) 142self .app .restart () 143 144# The next turn runs on the reattached kernel, and its request says 145# the kernel carried on and names the job still running, so a live 146# handle is not mistaken for one the restart killed. 147self .cells .append ("import os\nos.getpid()" ) 148self .app .prompt (self .session ,"run it" ).close () 149self .app .idle (self .session ) 150prompt = self .last_prompt () 151self .assertIn ("<system-note>The python kernel reattached" ,prompt ) 152self .assertIn ("Jobs still running: " + job ,prompt ) 153self .assertIn ("sleep 120" ,prompt ) 154self .assertEqual (self .cell ("import os\nos.getpid()" )["value" ],str (int (kernel ))) 155# The notice rides the first request after the reattach, once. 156self .cells .append ("import os\nos.getpid()" ) 157self .app .prompt (self .session ,"run it" ).close () 158self .app .idle (self .session ) 159self .assertNotIn ("The python kernel reattached" ,self .last_prompt ()) 160 161def test_a_job_ending_after_a_restart_wakes_its_idle_session (self ): 162started = self .cell ("j = run('sleep', '4')\nj.id" ) 163job = started ["value" ].strip ("'\"" ) 164requests = len (self .provider .requests ) 165# The restart leaves the session idle and unloaded; only its kernel 166# comes back, and the job's end must still reach the model. 167self .app .restart () 168deadline = time .monotonic ()+ 30 169while len (self .provider .requests )== requests and time .monotonic ()< deadline : 170time .sleep (0.1 ) 171self .assertGreater (len (self .provider .requests ),requests ,"no wake" ) 172self .app .idle (self .session ) 173self .assertIn (job ,self .last_prompt ()) 174 175def test_a_kernel_killed_while_the_daemon_was_down_has_its_jobs_ended (self ): 176started = self .cell ( 177"import asyncio, os\nj = run('sleep', '300')\nawait asyncio.sleep(0.5)\n(os.getpid(), j.process.pid)" 178 ) 179kernel ,job = (int (part )for part in started ["value" ].strip ("()" ).split ("," )) 180 181# Graceful stop leaves the kernel running; it dies hard before the 182# next daemon reaches it, and its job's group outlives it. 183self .app .restart (prepare = lambda _ :os .kill (kernel ,signal .SIGKILL )) 184deadline = time .monotonic ()+ 15 185while alive (job )and time .monotonic ()< deadline : 186time .sleep (0.1 ) 187self .assertFalse (alive (job ),"the dead kernel's job is still running" ) 188 189 190if __name__ == "__main__" : 191unittest .main ()