okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
5bdc0ee
main
1"""Unread background job results wake idle sessions once, even across idle reaping.""" 2 3import json 4import os 5import time 6import unittest 7import urllib .error 8 9from harness import ( 10Albedo , 11Provider , 12Reply , 13exclusive , 14latest_user , 15python , 16release_fifo , 17text , 18) 19 20# A daemon under this limit sweeps every half second, so the detached job 21# below outlives several sweeps. 22IDLE_SECONDS = 2 23 24 25def wait_for (predicate ,timeout = 40 ): 26deadline = time .monotonic ()+ timeout 27while time .monotonic ()< deadline : 28result = predicate () 29if result : 30return result 31time .sleep (0.1 ) 32raise AssertionError ("background job did not wake session" ) 33 34 35class JobWakeCase (unittest .TestCase ): 36idle_seconds = None 37 38def setUp (self ): 39self .job_gate = None 40 41def script (request ): 42last = request ["messages" ][- 1 ] 43user = latest_user (request ) 44if last .get ("role" )== "user" and "start a slow job" in user : 45seconds = IDLE_SECONDS * 2.5 if "detached" in user else 1.2 46program = ( 47f"open( { str ( self . job_gate )!r } , 'rb').read(1); print('wake-done')" 48if self .job_gate is not None 49else f"import time; time.sleep( { seconds } ); print('wake-done')" 50 ) 51code = ( 52f"import sys\njob = run(sys.executable, '-c', { program !r } )\njob.id" 53 ) 54return python (code ) 55if last .get ("role" )== "user" and "await a slow job past its cell" in user : 56program = ( 57f"open( { str ( self . job_gate )!r } , 'rb').read(1); print('wake-done')" 58 ) 59code = f"import sys\njob = run(sys.executable, '-c', { program !r } )\nawait job" 60return Reply ( 61"python" ,tool_arguments = {"code" :code ,"timeout_ms" :1500 } 62 ) 63return text ("finished" ) 64 65def prepare (app ): 66if self .idle_seconds : 67app .env ["ALBEDO_IDLE_SECONDS" ]= str (self .idle_seconds ) 68 69self .provider = Provider (script ) 70self .addCleanup (self .provider .close ) 71self .app = Albedo (self .provider ,prepare = prepare ) 72self .app .__enter__ () 73self .addCleanup (self .app .__exit__ ,None ,None ,None ) 74 75def users (self ): 76return [latest_user (record ["request" ])for record in self .provider .requests ] 77 78 79class JobWakeTests (JobWakeCase ): 80def test_unread_job_wakes_once_with_live_source_and_durable_turn (self ): 81session = self .app .session () 82gate = self .app .workspace / "job-release" 83self .job_gate = gate 84os .mkfifo (gate ) 85self .app .prompt (session ,"start a slow job" ).close () 86self .app .idle (session ) 87self .assertEqual (len (self .users ()),2 ) 88before = self .app .stream_page (session ) 89 90wait_for (lambda :release_fifo (gate )) 91wait_for (lambda :len (self .users ())>= 3 ) 92self .app .idle (session ) 93self .assertEqual (len (self .users ()),3 ) 94wake = self .users ()[2 ] 95self .assertIn ("background job finished" ,wake ) 96self .assertIn ("jobs[" ,wake ) 97self .assertIn ("output.read" ,wake ) 98live = self .app .stream_page (session ,before )["events" ] 99notices = [ 100event ["data" ]["entry" ] 101for event in live 102if event ["type" ]== "message" 103and event ["data" ]["entry" ]["kind" ]== "note" 104and any ( 105part ["kind" ]== "text" and "job finished" in part ["text" ] 106for part in event ["data" ]["entry" ]["content" ] 107 ) 108 ] 109self .assertEqual ( 110len (notices ), 1111 , 112 {"live" :live ,"history" :self .app .history (session )}, 113 ) 114# The transcript shows the one-line display; the model's notice with the 115# handle and advice stays in the request above. 116shown = "" .join ( 117part ["text" ]for part in notices [0 ]["content" ]if part ["kind" ]== "text" 118 ) 119self .assertTrue (shown .startswith ("job finished (exit_code=0" ),shown ) 120self .assertNotIn ("\n" ,shown ) 121self .assertNotIn ("jobs[" ,shown ) 122durable = [ 123entry 124for entry in self .app .history (session )["items" ] 125if entry ["kind" ]== "note" 126and any ( 127part ["kind" ]== "text" and "job finished" in part ["text" ] 128for part in entry ["content" ] 129 ) 130 ] 131self .assertEqual (len (durable ),1 ) 132self .assertEqual (durable [0 ]["id" ],notices [0 ]["id" ]) 133self .assertEqual (durable [0 ]["turn_id" ],notices [0 ]["turn_id" ]) 134self .assertIsNotNone (durable [0 ]["turn_id" ]) 135self .assertEqual (durable [0 ]["input_id" ],notices [0 ]["input_id" ]) 136for entry in [notices [0 ],durable [0 ]]: 137self .assertEqual ( 138 [ 139part ["value" ] 140for part in entry ["content" ] 141if part ["kind" ]== "json" and part ["field" ]== "origin" 142 ], 143 ["job" ], 144 ) 145time .sleep (2.5 ) 146self .assertEqual (len (self .users ()),3 ) 147 148def start_blocked_job (self ,prompt = "start a slow job" ): 149session = self .app .session () 150self .job_gate = self .app .workspace / "job-release" 151os .mkfifo (self .job_gate ) 152self .app .prompt (session ,prompt ).close () 153self .app .idle (session ) 154self .assertEqual (len (self .users ()),2 ) 155return session 156 157def jobs_resource (self ,session ): 158return json .loads ( 159self .app .api (f"/extensions/run/sessions/ { session } /jobs" ).read () 160 ) 161 162def test_running_job_observation_and_page_clear_after_completion (self ): 163session = self .start_blocked_job () 164kernel = json .loads (self .app .api (f"/sessions/ { session } ?tail=0" ).read ())[ 165"kernel" 166 ] 167self .assertEqual (kernel ["live_job_count" ],1 ) 168running = kernel ["running_jobs" ] 169self .assertEqual (len (running ),1 ) 170self .assertIsInstance (running [0 ]["pid" ],int ) 171self .assertIn (str (self .job_gate ),running [0 ]["command" ]) 172# The row says when the owner recorded the job, in wall-clock 173# milliseconds, so a client can tell fresh work from settled work. 174started = running [0 ]["started_at" ] 175self .assertIsInstance (started ,int ) 176self .assertGreater (started ,0 ) 177self .assertLess (abs (started - time .time ()* 1000 ),60_000 ) 178 179resource = self .jobs_resource (session ) 180self .assertEqual (resource ["items" ],running ) 181self .assertEqual (resource ["live_job_count" ],1 ) 182page = resource ["page" ] 183self .assertEqual (page ["title" ],"jobs" ) 184self .assertEqual (page ["summary" ],"1 background job running" ) 185self .assertEqual (len (page ["rows" ]),1 ) 186self .assertIn (str (self .job_gate ),page ["rows" ][0 ]["text" ]) 187# A row resource is a validated representation or nothing; clients 188# reject one without an etag. 189row_resource = page ["rows" ][0 ]["resource" ] 190self .assertTrue ( 191row_resource is None or isinstance (row_resource .get ("etag" ),str ), 192row_resource , 193 ) 194stop = page ["actions" ][0 ]["operation" ] 195self .assertEqual (stop ["method" ],"POST" ) 196self .assertEqual ( 197stop ["path_template" ], 198"/extensions/run/sessions/{session_id}/jobs/{job_id}/stop" , 199 ) 200self .assertEqual ( 201stop ["path" ], 202 { 203"session_id" : {"source" :"literal" ,"value" :session }, 204"job_id" : {"source" :"row" ,"pointer" :"/id" }, 205 }, 206 ) 207 208gate = self .job_gate 209assert gate is not None 210 211wait_for (lambda :release_fifo (gate )) 212wait_for (lambda :len (self .users ())>= 3 ) 213self .app .idle (session ) 214after = json .loads (self .app .api (f"/sessions/ { session } ?tail=0" ).read ())["kernel" ] 215self .assertEqual (after ["live_job_count" ],0 ) 216self .assertEqual (after ["running_jobs" ], []) 217self .assertEqual (self .jobs_resource (session )["items" ], []) 218 219def test_an_await_cut_short_by_the_cell_deadline_still_wakes (self ): 220session = self .start_blocked_job ("await a slow job past its cell deadline" ) 221statuses = [ 222json .loads (part ["value" ])["status" ] 223for entry in self .app .history (session )["items" ] 224if entry ["kind" ]== "tool_result" 225for part in entry ["content" ] 226if part ["kind" ]== "json" and part ["field" ]== "result" 227 ] 228self .assertEqual (statuses , ["interrupted" ]) 229gate = self .job_gate 230assert gate is not None 231wait_for (lambda :release_fifo (gate )) 232wait_for (lambda :len (self .users ())>= 3 ) 233self .assertIn ("background job finished" ,self .users ()[2 ]) 234 235def test_stopping_one_job_and_rejecting_unknown_job (self ): 236session = self .start_blocked_job () 237resource = self .jobs_resource (session ) 238job_id = resource ["items" ][0 ]["id" ] 239stopped = json .loads ( 240self .app .api ( 241f"/extensions/run/sessions/ { session } /jobs/ { job_id } /stop" , 242method = "POST" , 243 ).read () 244 ) 245self .assertEqual (stopped , {"stopped" :job_id }) 246wait_for (lambda :self .jobs_resource (session )["live_job_count" ]== 0 ) 247self .assertEqual (self .jobs_resource (session )["items" ], []) 248with self .assertRaises (urllib .error .HTTPError )as missing : 249self .app .api ( 250f"/extensions/run/sessions/ { session } /jobs/missing/stop" , 251method = "POST" , 252 ) 253self .assertEqual (missing .exception .code ,404 ) 254 255 256# exclusive: changes daemon idle sweep timing 257@exclusive 258class IdleSweepTests (JobWakeCase ): 259idle_seconds = IDLE_SECONDS 260 261def test_live_job_survives_idle_kernel_sweep (self ): 262session = self .app .session () 263self .app .prompt (session ,"start a slow job, detached" ).close () 264self .app .idle (session ) 265self .assertEqual (len (self .users ()),2 ) 266# No daemon contact while detached: a live job must pin its kernel. 267wait_for (lambda :len (self .users ())>= 3 ) 268self .app .idle (session ) 269self .assertEqual (len (self .users ()),3 ) 270self .assertIn ("background job finished" ,self .users ()[2 ])