okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
1c00b60
main
1from __future__import annotations 2 3import ast 4import albedo_api 5import albedo_output 6import albedo_bundle 7import albedo_state 8from albedo_values import Unencodable ,decode ,encode 9import albedo_capture 10from albedo_capture import Capture ,PREVIEW ,MAX_IMAGE_BYTES 11import albedo_link 12import albedo_memory 13from albedo_protocol import parse_incoming 14import albedo_proc 15import albedo_shell 16from collections .abc import Awaitable ,Callable ,Iterable ,Sequence 17from typing import cast 18from types import CodeType ,FrameType ,ModuleType 19import albedo_trace 20import asyncio 21import builtins 22import codecs 23import collections 24import contextvars 25import dataclasses 26import inspect 27import importlib 28import io 29import json 30import os 31import pathlib 32import reprlib 33import select 34import shutil 35import signal 36import sys 37import tempfile 38import threading 39import time 40import traceback 41import uuid 42import weakref 43 44CLEANUP_DEADLINE = ( 451.5 # seconds: plugins must finish cleanup inside the supervisor's patience 46) 47LIMITS = {"cell" :16 ,"job" :64 ,"native" :1 }# retained captures per kind 48CELL :contextvars .ContextVar [Capture | None ]= contextvars .ContextVar ( 49"cell" ,default = None 50) 51LINK :albedo_link .StdioLink | albedo_link .SocketLink 52RUN_DIR :str | None = None # a detached kernel's run directory, None over stdio 53WATCH_INTERVAL = 1.0 # seconds between a detached kernel's lifetime checks 54FIRST_ATTACH_GRACE = 60.0 # seconds a detached kernel waits for its first attach 55LOOP = asyncio .new_event_loop () 56asyncio .set_event_loop (LOOP ) 57 58albedo_proc .reap_stopped_safely (LOOP ) 59QUEUE :asyncio .Queue [albedo_api .Execute | albedo_api .State ]= asyncio .Queue () 60PENDING :dict [str ,asyncio .Future [albedo_api .HostReply ]]= {} 61# Host calls a synchronous API waits for; the reader thread hands their reply 62# over directly, since the loop it would go through is the one waiting. 63WAITING :dict [str ,tuple [threading .Event ,list [albedo_api .HostReply ]]]= {} 64HOST_NOW_TIMEOUT = 30.0 65HOST_SLOTS = asyncio .Semaphore (32 ) 66OWNER_CALLS = asyncio .Semaphore (32 )# concurrent tool calls from the connection owner 67OWNER_TASKS :dict [str ,asyncio .Task [object ]]= {}# interruptable by invoke id 68LIVE :collections .OrderedDict [str ,object ]= ( 69collections .OrderedDict () 70)# remote references 71LIVE_LIMIT = 64 # live references retained for the owner; LRU beyond that 72PENDING_OBJECTS :collections .OrderedDict [str ,asyncio .Future [tuple [bool ,object ]]]= ( 73collections .OrderedDict () 74) 75MIRROR_INTERVAL = 0.15 # seconds between output-tail mirror frames while data flows 76MIRROR_TAIL = PREVIEW # the same bounded tail window as a local job 77BACKGROUND_SECONDS = max ( 780.01 ,float (os .environ .get ("ALBEDO_CELL_BACKGROUND_SECONDS" ,"60" )) 79) 80CELL_TASKS :dict [str ,asyncio .Task [dict [str ,object ]]]= {} 81CELL_CAPTURES :dict [str ,Capture ]= {} 82CELL_STARTS :dict [str ,int ]= {}# the kernel's footprint as each running cell began 83BACKGROUND_RESULTS :dict [str ,dict [str ,object ]]= {} 84READ_CELLS :set [str ]= set () 85CELL_ANNOUNCER :asyncio .Task [None ]| None = None 86interrupt_capture :Capture | None = None 87ARCHIVES :collections .OrderedDict [str ,Capture ]= collections .OrderedDict () 88# Finished executions by cell id: a replayed execute answers with its result 89# instead of running the cell twice. Queued or running ones are in EXECUTING. 90FINISHED :collections .OrderedDict [str ,dict [str ,object ]]= collections .OrderedDict () 91FINISHED_LIMIT = 16 92# Base64 image text kept across finished results; older results drop theirs first. 93FINISHED_IMAGE_BYTES = 8 * 1024 * 1024 94EXECUTING :set [str ]= set () 95# Interrupts that arrived while their cell was still queued, by cell id. 96INTERRUPTS :dict [str ,str ]= {} 97READY = threading .Event () 98NAMESPACE :dict [str ,object ]= {"__name__" :"__main__" } 99DEFINITIONS :collections .OrderedDict [str ,str ]= collections .OrderedDict () 100CLEANUP :list [albedo_api .Cleanup ]= [] 101STOP_HANDLERS :list [Callable [[str ],Awaitable [dict [str ,object ]| None ]]]= [] 102HANDLES :list [type [object ]]= [asyncio .Task ] 103REPR = reprlib .Repr () 104REPR .maxstring = REPR .maxother = 4000 105REPR .maxdict = REPR .maxlist = REPR .maxtuple = 50 106 107 108def forget_output (id :str )-> None : 109ARCHIVES .pop (id ,None ) 110READ_WATCHERS .pop (id ,None ) 111 112 113def show (value :object )-> str : 114return ( 115REPR .repr (value ) 116 .encode ("utf-8" ,errors = "replace" )[:PREVIEW ] 117 .decode ("utf-8" ,errors = "ignore" ) 118 ) 119 120 121def send (value :dict [str ,object ])-> None : 122LINK .send (value ) 123 124 125async def cleanup ()-> None : 126"""Run plugin cleanup; a callback may return an awaitable to clean up asynchronously.""" 127for close in reversed (CLEANUP ): 128try : 129result = close () 130if inspect .isawaitable (result ): 131await result 132except BaseException as error : 133try : 134send ( 135 { 136"type" :"cleanup" , 137"module" :getattr (close ,"__module__" ,"plugin" ), 138"failures" : [f" { type ( error ). __name__ } : { error } " ], 139 } 140 ) 141except (OSError ,ValueError ): 142pass # a disconnected owner must not prevent other cleanup callbacks 143 144 145def die ()-> None : 146"""Leave the session: clean up while the loop can still run it, then kill our group. 147 148Called from the reader thread and from the main thread, never from a loop 149callback, so cleanup may await without deadlocking the interpreter. 150""" 151try : 152if LOOP .is_running (): 153asyncio .run_coroutine_threadsafe (cleanup (),LOOP ).result (CLEANUP_DEADLINE ) 154else : 155LOOP .run_until_complete (asyncio .wait_for (cleanup (),CLEANUP_DEADLINE )) 156except BaseException as error : 157try : 158send ( 159 { 160"type" :"cleanup" , 161"module" :"kernel" , 162"failures" : [ 163f"cleanup incomplete: { type ( error ). __name__ } : { error } " 164 ], 165 } 166 ) 167except (OSError ,ValueError ): 168pass # the owner disconnected; it independently tracks our groups 169if RUN_DIR is not None : 170# No socket left behind: an attach from now on learns we are gone. 171shutil .rmtree (RUN_DIR ,ignore_errors = True ) 172# The interpreter and its subprocesses own a separate process group. 173os .killpg (os .getpid (),signal .SIGKILL ) 174 175 176def receive (frame :object )-> None : 177"""Act on one frame from the owner, on the link's reader thread.""" 178global interrupt_capture 179try : 180message = parse_incoming (frame ) 181except ValueError : 182die () 183return 184if message ["type" ]== "shutdown" : 185die () 186elif message ["type" ]== "interrupt" : 187capture = CELL_CAPTURES .get (message ["id" ]) 188if capture is not None : 189interrupt_capture = capture 190capture .interruption = message .get ("reason" ,"cancelled" ) 191os .kill (os .getpid (),signal .SIGINT ) 192_ = LOOP .call_soon_threadsafe (interrupt_queued ,message ) 193else : 194# The loop takes it after the execute it may name, not started yet. 195_ = LOOP .call_soon_threadsafe (deliver ,message ) 196elif message ["type" ]== "reply" and message ["id" ]in WAITING : 197arrived ,answer = WAITING [message ["id" ]] 198answer .append (message ["value" ]) 199arrived .set () 200else : 201_ = LOOP .call_soon_threadsafe (deliver ,message ) 202 203 204def reader ()-> None : 205try : 206LINK .serve (receive ) 207except Exception : 208pass 209die () 210 211 212def watch ()-> None : 213"""A detached kernel ends itself: when its run directory is gone (its home 214was removed), or when nothing attached for the grace period and no job or 215cell is still running.""" 216assert isinstance (LINK ,albedo_link .SocketLink )and RUN_DIR is not None 217while True : 218time .sleep (WATCH_INTERVAL ) 219if not os .path .isdir (RUN_DIR ): 220die () 221busy = bool (CELL_TASKS )or not QUEUE .empty ()or LINK .jobs .live ()> 0 222if not busy and LINK .idle_for ()> LINK .grace : 223die () 224 225 226def guard_memory ()-> None : 227"""Interrupts every running cell once the kernel's footprint passes its 228threshold (albedo_memory), so a runaway cell ends instead of the kernel. 229A thread, like the deadline timer: a synchronous cell holds the loop.""" 230while True : 231time .sleep (albedo_memory .INTERVAL ) 232if not CELL_STARTS : 233continue 234used = albedo_memory .footprint () 235if used is None : 236return 237for id ,start in list (CELL_STARTS .items ()): 238capture = CELL_CAPTURES .get (id ) 239if capture is None or capture .interruption == "memory" : 240continue # already told; it is unwinding 241if used > albedo_memory .threshold (start ): 242receive ({"type" :"interrupt" ,"id" :id ,"reason" :"memory" }) 243 244 245def hello ()-> dict [str ,object ]: 246"""What a detached kernel tells each attach about itself.""" 247pgid = os .getpgid (0 ) 248return { 249"pid" :os .getpid (), 250"pgid" :pgid if pgid == os .getpid ()else None , 251"leader" :albedo_proc .leader_token (os .getpid ()), 252"ready" :READY .is_set (), 253"cells" :len (CELL_TASKS ), 254 } 255 256 257def deliver (message :albedo_api .Incoming )-> None : 258if message ["type" ]== "interrupt" : 259interrupt_queued (message ) 260elif message ["type" ]== "reply" : 261future = PENDING .pop (message ["id" ],None ) 262if future is not None and not future .done (): 263future .set_result (message ["value" ]) 264elif message ["type" ]== "invoke" : 265_ = LOOP .create_task (serve_invoke (message )) 266elif message ["type" ]== "introspect" : 267send ( 268 { 269"type" :"introspected" , 270"id" :message ["id" ], 271"names" :sorted ( 272name 273for name in NAMESPACE 274if name .isidentifier ()and not name .startswith ("_" ) 275 ), 276"handles" : [ 277 {"id" :key ,"repr" :show (value )}for key ,value in LIVE .items () 278 ], 279 } 280 ) 281elif message ["type" ]== "release" : 282LIVE .pop (message ["handle" ],None ) 283elif message ["type" ]== "job_stop" : 284_ = LOOP .create_task (serve_job_stop (message ["id" ])) 285elif message ["type" ]== "snapshot" and (bool (CELL_TASKS )or not QUEUE .empty ()): 286# A namespace mid-cell is no state to carry: the owner tries again later. 287send (state_reply (message , {"error" :"a cell is still running" })) 288elif message ["type" ]== "execute" and message ["id" ]in FINISHED : 289send (FINISHED [message ["id" ]]) 290elif message ["type" ]== "execute" and message ["id" ]in EXECUTING : 291pass # already queued or running; its done frame answers both 292else : 293if message ["type" ]== "execute" : 294EXECUTING .add (message ["id" ]) 295QUEUE .put_nowait (cast (albedo_api .Execute | albedo_api .State ,message )) 296 297 298def interrupt_queued (message :albedo_api .Interrupt )-> None : 299"""An interrupt the reader thread found no running cell for. Here on the 300loop, the cell it names is still queued, or has started since and waits 301at an await, where cancelling reaches it.""" 302reason = message .get ("reason" ,"cancelled" ) 303task = OWNER_TASKS .get (message ["id" ]) 304capture = CELL_CAPTURES .get (message ["id" ]) 305if task is not None : 306_ = task .cancel () 307elif capture is not None : 308capture .interruption = reason 309_ = CELL_TASKS [message ["id" ]].cancel () 310elif message ["id" ]in EXECUTING : 311INTERRUPTS [message ["id" ]]= reason 312 313 314class WorkError (Exception ): 315def __init__ (self ,code :str ,message :str )-> None : 316super ().__init__ (message ) 317self .code :str = code 318 319 320async def host (method :str ,args :dict [str ,object ])-> object : 321async with HOST_SLOTS : 322return await _host (method ,args ) 323 324 325async def _host (method :str ,args :dict [str ,object ])-> object : 326key = uuid .uuid4 ().hex 327future :asyncio .Future [albedo_api .HostReply ]= LOOP .create_future () 328PENDING [key ]= future 329send ({"type" :"call" ,"id" :key ,"method" :method ,"args" :args }) 330try : 331answer = await future 332finally : 333_ = PENDING .pop (key ,None ) 334if answer ["ok" ]: 335return answer ["value" ] 336raise WorkError (answer ["code" ],answer ["message" ]) 337 338 339def host_now (method :str ,args :dict [str ,object ])-> object : 340"""A host call for a synchronous API: blocks the calling thread, the 341loop's included, until the reply. Only for calls the host answers at 342once; an interrupt still ends the wait.""" 343key = uuid .uuid4 ().hex 344arrived = threading .Event () 345answers :list [albedo_api .HostReply ]= [] 346WAITING [key ]= (arrived ,answers ) 347send ({"type" :"call" ,"id" :key ,"method" :method ,"args" :args }) 348try : 349if not arrived .wait (HOST_NOW_TIMEOUT ): 350raise WorkError ( 351"timeout" ,f" { method } had no answer within { HOST_NOW_TIMEOUT :.0f } s" 352 ) 353finally : 354_ = WAITING .pop (key ,None ) 355answer = answers [0 ] 356if answer ["ok" ]: 357return answer ["value" ] 358raise WorkError (answer ["code" ],answer ["message" ]) 359 360 361def attach_image (data :bytes )-> str : 362"""Attach image bytes to the running cell; plugins reach this as api.attach_image.""" 363capture = CELL .get () 364# Spawned tasks inherit context even after their creating cell finishes. 365if capture is None or capture .kind != "cell" or capture .id not in ACTIVE_CAPTURES : 366raise RuntimeError ( 367"images attach to a running cell; background work has no result to carry them" 368 ) 369return capture .attach (bytes (data )) 370 371 372def show_image ( 373source :bytes | bytearray | memoryview | str | os .PathLike [str ], 374)-> albedo_api .Text : 375"""Return an image to yourself with this cell's result. `source` is PNG, 376JPEG, or WebP bytes, or a path to such a file. At most 4 images and 5 MiB 377per cell, each within the model's edge limit, or it raises ValueError; the 378image arrives after the cell finishes, not mid-cell.""" 379if isinstance (source , (bytes ,bytearray ,memoryview )): 380data = bytes (source ) 381elif isinstance (source , (str ,os .PathLike )): 382with open (source ,"rb" )as file : 383data = file .read (MAX_IMAGE_BYTES + 1 ) 384else : 385raise TypeError ( 386f"show_image takes image bytes or a path, not { type ( source ). __name__ } " 387 ) 388return albedo_api .Text ("attached " + attach_image (data )) 389 390 391NATIVE = Capture ("native" ,"native" ) 392NATIVE_LOCK = threading .RLock () 393NATIVE_FD = - 1 394DECODER = codecs .getincrementaldecoder ("utf-8" )("replace" ) 395NATIVE_SINKS :dict [asyncio .Task [object ],Capture ]= {} 396ACTIVE_CAPTURES :set [str ]= set () 397 398 399def retained (id :str )-> Capture : 400capture = ARCHIVES .get (id ) 401if capture is None : 402raise LookupError ( 403f"no retained output for { id !r } : only the 16 most recent cells and 64 most recent jobs " 404f"keep output, so it may have rolled out; output.list() names what is kept, and a " 405f"background job keeps its own output on its handle: jobs[ { id !r } ].tail()" 406 ) 407return capture 408 409 410READ_WATCHERS :dict [str ,Callable [[],None ]| weakref .WeakMethod ]= {} 411 412 413def watch_output (id :str ,notify :Callable [[],None ])-> None : 414"""Notify reads while the output is retained, without owning a bound method's job.""" 415 416def expired (reference :weakref .WeakMethod )-> None : 417if READ_WATCHERS .get (id )is reference : 418READ_WATCHERS .pop (id ,None ) 419 420try : 421READ_WATCHERS [id ]= weakref .WeakMethod (notify ,expired ) 422except TypeError : 423# Functions and other callables need a strong reference to stay usable. 424READ_WATCHERS [id ]= notify 425 426 427class Output : 428def read (self ,id :str ,offset :int = 0 ,limit :int = 4000 )-> albedo_api .Text : 429"""Read retained output by cell or job id, at most 64 KiB per call. 430The 16 most recent cells and 64 most recent jobs keep up to 1 MiB 431each; page through it with offset.""" 432if limit > PREVIEW : 433raise ValueError ( 434f"limit is at most { PREVIEW } per read; page through the rest with offset=" 435 ) 436capture = retained (id ) 437result = albedo_output .read_page (capture ,offset ,limit ) 438notify = READ_WATCHERS .get (id ) 439if isinstance (notify ,weakref .WeakMethod ): 440notify = notify () 441if notify is not None : 442try : 443notify () 444except Exception : 445pass # a plugin's bookkeeping must not fail the read 446return result 447 448def list (self ): 449"""Every retained channel: cells, background jobs, and 'native'. 450 451'native' holds bytes written to fd 1/2 while no cell was running; bytes a 452cell's subprocesses write land in that cell's own output instead. 453""" 454return albedo_api .ReadyList ( 455 {"id" :c .id ,"kind" :c .kind ,"bytes" :c .seen ,"retained" :len (c .data )} 456for c in ARCHIVES .values () 457 ) 458 459 460class Stream (io .TextIOBase ): 461def writable (self )-> bool : 462return True 463 464def write (self ,text :str )-> int : 465with NATIVE_LOCK :# bytes already in the pipe were written first and stay first 466_ = drain () 467 (CELL .get ()or NATIVE ).write (text ) 468return len (text ) 469 470def flush (self )-> None : 471pass 472 473def fileno (self )-> int : 474return 1 475 476 477def drain ()-> bool : 478"""Fold whatever fd 1/2 already holds into the running cell; False once the pipe closes. 479 480Subprocesses and C extensions cannot be attributed through contextvars, so 481they are credited to the cell that is running, the way a terminal does. 482Both the pump thread and Python-level writes drain, so the two surfaces 483interleave in the order the bytes were produced. 484""" 485with NATIVE_LOCK : 486while True : 487try : 488data = os .read (NATIVE_FD ,65536 ) 489except BlockingIOError : 490return True 491except OSError : 492return False 493if not data : 494return False 495text = DECODER .decode (data ) 496if text : 497sinks = list (NATIVE_SINKS .values ()) 498 (sinks [0 ]if len (sinks )== 1 else NATIVE ).write (text ) 499 500 501def native_output (fd :int )-> None : 502"""Wake on output produced while no cell is writing.""" 503try : 504while select .select ([fd ], [], [])[0 ]and drain (): 505pass 506except OSError : 507return 508 509 510def interrupt (_signal :int ,_frame :FrameType | None )-> None : 511if CELL .get ()is not None and CELL .get ()is interrupt_capture : 512raise KeyboardInterrupt () 513 514 515def compile_cell ( 516source :str ,filename :str 517)-> tuple [CodeType ,CodeType | None ,ast .Module ]: 518"""Compile every part before running; return the unmodified tree for state capture.""" 519tree = ast .parse (source ,filename = filename ) 520original = ast .Module (body = list (tree .body ),type_ignores = tree .type_ignores ) 521last = tree .body [- 1 ]if tree .body else None 522trailing = None 523if isinstance (last ,ast .Expr ): 524_ = tree .body .pop () 525trailing = ast .Expression (last .value ) 526prefix = cast ( 527CodeType ,compile (tree ,filename ,"exec" ,ast .PyCF_ALLOW_TOP_LEVEL_AWAIT ) 528 ) 529suffix = ( 530cast ( 531CodeType , 532compile (trailing ,filename ,"eval" ,ast .PyCF_ALLOW_TOP_LEVEL_AWAIT ), 533 ) 534if trailing 535else None 536 ) 537return prefix ,suffix ,original 538 539 540def _bound_names (node :ast .AST )-> set [str ]: 541names = { 542item .id 543for item in ast .walk (node ) 544if isinstance (item ,ast .Name )and isinstance (item .ctx , (ast .Store ,ast .Del )) 545 } 546for item in ast .walk (node ): 547if isinstance (item ,ast .ExceptHandler )and item .name : 548names .add (item .name ) 549elif isinstance (item , (ast .FunctionDef ,ast .AsyncFunctionDef ,ast .ClassDef )): 550names .add (item .name ) 551return names 552 553 554def _drop_definition (name :str )-> None : 555for key in list (DEFINITIONS ): 556if key == name or key .startswith (name + "#" ): 557DEFINITIONS .pop (key ,None ) 558 559 560def _definition_changes ( 561source :str ,tree :ast .Module 562)-> list [tuple [ast .stmt ,set [str ],str | None ,bool ]]: 563changes :list [tuple [ast .stmt ,set [str ],str | None ,bool ]]= [] 564for statement in tree .body : 565names :set [str ]= set () 566definition = isinstance ( 567statement , 568 ( 569ast .FunctionDef , 570ast .AsyncFunctionDef , 571ast .ClassDef , 572ast .Import , 573ast .ImportFrom , 574 ), 575 ) 576if isinstance (statement , (ast .FunctionDef ,ast .AsyncFunctionDef ,ast .ClassDef )): 577names .add (statement .name ) 578elif isinstance (statement ,ast .Import ): 579names .update ( 580alias .asname or alias .name .split ("." )[0 ]for alias in statement .names 581 ) 582elif isinstance (statement ,ast .ImportFrom ): 583names .update ( 584alias .asname or alias .name 585for alias in statement .names 586if alias .name != "*" 587 ) 588else : 589names = _bound_names (statement ) 590if names : 591changes .append ( 592 ( 593statement , 594names , 595ast .get_source_segment (source ,statement )if definition else None , 596isinstance (statement ,ast .Import ), 597 ) 598 ) 599return changes 600 601 602def capture_definitions ( 603source :str ,tree :ast .Module ,upto_line :int | None = None 604)-> None : 605for statement ,names ,segment ,is_import in _definition_changes (source ,tree ): 606if ( 607upto_line is not None 608and (statement .end_lineno or statement .lineno )>= upto_line 609 ): 610continue 611for name in names : 612# Plain dotted imports share their root binding and must accumulate. 613plain_dotted = ( 614is_import 615and any ( 616alias .name .startswith (name + "." )and alias .asname is None 617for alias in statement .names 618 ) 619if isinstance (statement ,ast .Import ) 620else False 621 ) 622if not plain_dotted : 623_drop_definition (name ) 624if segment is not None : 625key = next (iter (names ),"definition" ) 626if ( 627is_import 628and isinstance (statement ,ast .Import ) 629and any ( 630"." in alias .name and alias .asname is None 631for alias in statement .names 632 ) 633 ): 634while key in DEFINITIONS : 635key += "#2" 636elif key in DEFINITIONS : 637_drop_definition (key ) 638# One source segment is replayed once; its imports bind every listed name. 639if segment not in DEFINITIONS .values (): 640DEFINITIONS [key ]= segment 641 642 643async def evaluate (source :str ,cell_id :str ,durable :bool = False )-> object : 644started = False 645ACTIVE_CAPTURES .add (cell_id ) 646try : 647prefix ,suffix ,tree = compile_cell (source ,"<albedo:" + cell_id + ">" ) 648if durable : 649_ = await host ("cells.started" , {"id" :cell_id }) 650started = True 651value = cast (object ,eval (prefix ,NAMESPACE )) 652if inspect .isawaitable (value ): 653_ = await cast (Awaitable [object ],value ) 654if suffix is not None : 655value = cast (object ,eval (suffix ,NAMESPACE )) 656if inspect .isawaitable (value )and not isinstance (value ,tuple (HANDLES )): 657value = await cast (Awaitable [object ],value ) 658NAMESPACE ["_" ]= value 659capture_definitions (source ,tree ) 660return value 661except BaseException as error : 662filename = "<albedo:" + cell_id + ">" 663line = next ( 664 ( 665lineno 666for frame ,lineno in traceback .walk_tb (error .__traceback__ ) 667if frame .f_code .co_filename == filename 668 ), 669None , 670 ) 671if "tree" in locals ()and line is not None : 672capture_definitions (source ,tree ,line ) 673if not hasattr (error ,"_albedo_cell_id" ): 674setattr (error ,"_albedo_cell_id" ,cell_id ) 675setattr (error ,"_albedo_started" ,started ) 676raise 677finally : 678ACTIVE_CAPTURES .discard (cell_id ) 679 680 681class Cells : 682"""Immutable saved source. Repairs never automatically repeat side effects.""" 683 684last_id :str | None = None 685 686async def read ( 687self , 688id :str , 689* , 690start_line :object = 1 , 691end_line :object = None , 692limit :object = 8000 , 693 )-> str : 694"""Return source lines, capped at `limit` bytes. Narrow with start_line/end_line.""" 695if ( 696not isinstance (start_line ,int ) 697or start_line < 1 698or ( 699end_line is not None 700and (not isinstance (end_line ,int )or end_line < start_line ) 701 ) 702 ): 703raise ValueError ("expected positive inclusive line numbers" ) 704if not isinstance (limit ,int )or limit < 1 : 705raise ValueError ("limit must be a positive byte count" ) 706cell_read (id ) 707cell = cast (albedo_api .SavedCell ,await host ("cells.read" , {"id" :id })) 708lines = cell ["source" ].splitlines (keepends = True ) 709data = "" .join (lines [start_line - 1 :end_line ]).encode ( 710"utf-8" ,errors = "replace" 711 ) 712if len (data )<= limit : 713return data .decode ("utf-8" ,errors = "ignore" ) 714return data [:limit ].decode ("utf-8" ,errors = "ignore" )+ ( 715f"\n[ { limit } of { len ( data ) } bytes; the cell has { len ( lines ) } lines; " 716f"narrow with start_line/end_line or raise limit]" 717 ) 718 719async def info (self ,id :str )-> albedo_api .Record : 720"""Status (ok, error, interrupted, started, saved, lost, unavailable), 721parentage, whether the cell started, and how long a finished cell 722ran, without its source.""" 723cell_read (id ) 724cell = cast (albedo_api .SavedCell ,await host ("cells.read" , {"id" :id })) 725return albedo_api .Record ( 726 (key ,value )for key ,value in cell .items ()if key != "source" 727 ) 728 729async def cancel (self ,id :str )-> bool : 730"""Request cancellation of a running cell and suppress its completion wake.""" 731task = CELL_TASKS .get (id ) 732if task is None or task .done (): 733cell_read (id ) 734return False 735READ_CELLS .add (id ) 736CELL_CAPTURES [id ].interruption = "cancelled" 737return task .cancel () 738 739async def list (self ,limit :int = 20 )-> albedo_api .ReadyList : 740"""This session's cells, newest first: id, status, parent, and first line. 741Unlike output.list(), it reaches cells whose output has rolled out.""" 742cells = cast ( 743list [dict [str ,object ]],await host ("cells.list" , {"limit" :limit }) 744 ) 745return albedo_api .ReadyList (albedo_api .Record (cell )for cell in cells ) 746 747async def trace (self ,id :str )-> dict [str ,object ]: 748"""What the cell read, ran and changed: activities plus diffs of files it wrote. 749 750Diffs can be large; inspect the fields rather than printing the whole trace. 751""" 752cell_read (id ) 753return cast (dict [str ,object ],await host ("cells.trace" , {"id" :id })) 754 755async def run ( 756self , 757id :str , 758replacements :Iterable [Sequence [object ]]= (), 759* , 760allow_partial :bool = False , 761check :bool = False , 762 )-> object : 763"""Repair exact unique text and execute a new saved copy. 764 765check=True compiles the rewrite and returns its compile error, or None when 766it compiles; nothing is saved and nothing runs. 767A cell that started needs allow_partial=True after inspecting its effects. 768Syntax/compile failures are safe to repair without that override. 769""" 770pairs = [tuple (pair )for pair in replacements ] 771for pair in pairs : 772if len (pair )!= 2 or not all (isinstance (s ,str )for s in pair ): 773raise ValueError ("replacements must be (old, new) string pairs" ) 774if check : 775source = cast ( 776str ,await host ("cells.draft" , {"id" :id ,"replacements" :pairs }) 777 ) 778try : 779_ = compile_cell (source ,"<albedo:" + id + ":check>" ) 780except (SyntaxError ,ValueError )as error : 781return "" .join ( 782traceback .format_exception_only (type (error ),error ) 783 ).strip () 784return None 785cell = cast ( 786albedo_api .SavedCell , 787await host ( 788"cells.prepare" , 789 {"id" :id ,"replacements" :pairs ,"allow_partial" :allow_partial }, 790 ), 791 ) 792self .last_id = cell ["id" ] 793capture = Capture (cell ["id" ]) 794remember (capture ) 795outer = CELL .get () 796token = CELL .set (capture ) 797outer_sink = swap_sink (capture ) 798began = LOOP .time () 799value :object = None 800error :BaseException | None = None 801status = "ok" 802try : 803value = await evaluate (cell ["source" ],cell ["id" ],True ) 804text = "" if value is None else show (value ) 805except BaseException as failure : 806error = failure 807status = ( 808"interrupted" 809if isinstance (failure , (KeyboardInterrupt ,asyncio .CancelledError )) 810else "error" 811 ) 812capture .write (traceback .format_exc ()) 813text = "" 814finally : 815_ = drain () 816_ = swap_sink (outer_sink ) 817CELL .reset (token ) 818if outer is not None : 819outer .write ("[cell " + cell ["id" ]+ "]\n" + capture .read (0 ,PREVIEW )) 820for image in capture .images : 821try : 822_ = outer .attach (image ) 823except ValueError as dropped : 824outer .write ( 825f"[image from cell { cell [ 'id' ] } dropped: { dropped } ]\n" 826 ) 827deliver_trace (capture ) 828_ = await host ( 829"cells.finish" , 830 { 831"id" :cell ["id" ], 832"outcome" : { 833"id" :cell ["id" ], 834"status" :status , 835"duration" :round (LOOP .time ()- began ,3 ), 836"output" :capture .preview (status ), 837"value" :text , 838"truncated" :capture .seen > PREVIEW , 839"images" :capture .encoded_images (), 840 }, 841 }, 842 ) 843capture .images .clear () 844if error is not None : 845raise error 846return value 847 848 849INJECTED :set [str ]= {"__name__" ,"__builtins__" ,"_" } 850 851 852def swap_sink (capture :Capture | None )-> Capture | None : 853task = cast (asyncio .Task [object ],asyncio .current_task ()) 854with NATIVE_LOCK : 855previous = NATIVE_SINKS .pop (task ,None ) 856if capture is not None : 857NATIVE_SINKS [task ]= capture 858return previous 859 860 861def background_capture (id :str )-> Capture : 862"""A retained channel for a plugin's background work, addressable through `output`.""" 863capture = Capture (id ,"job" ) 864remember (capture ) 865return capture 866 867 868def remember (capture :Capture )-> None : 869"""Keep the newest captures of each kind, so a flood of jobs never evicts cell output.""" 870ARCHIVES [capture .id ]= capture 871ARCHIVES .move_to_end (capture .id ) 872counts = collections .Counter (held .kind for held in ARCHIVES .values ()) 873for key ,held in list (ARCHIVES .items ()): 874if counts [held .kind ]> LIMITS [held .kind ]and key not in EXECUTING : 875forget_output (key ) 876counts [held .kind ]-= 1 877 878 879def state_reply ( 880message :albedo_api .State ,state :dict [str ,object ]| None = None 881)-> dict [str ,object ]: 882if state is None : 883state = ( 884albedo_state .save_state (message ["path" ],NAMESPACE ,DEFINITIONS ,INJECTED ) 885if message ["type" ]== "snapshot" 886else albedo_state .load_state (message ["path" ],NAMESPACE ,DEFINITIONS ) 887 ) 888return { 889"type" :"done" , 890"id" :message ["id" ], 891"status" :"error" if "error" in state else "ok" , 892"output" :"" , 893"value" :"" , 894"truncated" :False , 895"state" :state , 896 } 897 898 899MAX_RESULT_BYTES = ( 9004 * 1024 * 1024 901)# one invoke reply ceiling, below the 8 MiB frame guard 902 903 904def retain (value :object )-> str : 905"""Keep one live object addressable by the owner; identity-stable, oldest rolls out.""" 906for key ,held in LIVE .items (): 907if held is value : 908LIVE .move_to_end (key ) 909return key 910key = uuid .uuid4 ().hex 911LIVE [key ]= value 912LIVE .move_to_end (key ) 913while len (LIVE )> LIVE_LIMIT : 914LIVE .popitem (last = False ) 915capture = getattr (value ,"capture" ,None ) 916if isinstance (capture ,Capture ): 917_ = LOOP .create_task (_mirror (key ,value ,capture )) 918return key 919 920 921def _mirror_state (obj :object )-> dict [str ,object ]: 922"""One snapshot of a captured object: what the owner's tail() and poll() answer from.""" 923capture = cast (Capture ,getattr (obj ,"capture" )) 924tail = capture .tail (MIRROR_TAIL ) 925return { 926"job" :getattr (obj ,"id" ,None ), 927"seen" :capture .seen , 928"tail" :tail .decode ("utf-8" ,errors = "ignore" ), 929"tail_bytes" :len (tail ), 930"exit_code" :getattr (obj ,"exit_code" ,None ), 931"timed_out" :getattr (obj ,"timed_out" ,False ), 932"duration" :getattr (obj ,"duration" ,None ), 933 } 934 935 936async def _mirror (key :str ,obj :object ,capture :Capture )-> None : 937"""Stream one live reference's output tail, so the owner reads it without a round trip. 938 939Frames are coalesced to the mirror interval; the final frame carries the 940completion state, and the pump ends once the object has settled and quieted. 941""" 942seen ,quiet = - 1 ,0 943while LIVE .get (key )is obj : 944await asyncio .sleep (MIRROR_INTERVAL ) 945changed = capture .seen != seen 946if changed : 947seen ,quiet = capture .seen ,0 948else : 949quiet += 1 950finished = getattr (obj ,"exit_code" ,None ) 951if changed or (finished is not None and quiet <= 1 ): 952send ({"type" :"mirror" ,"handle" :key ,** _mirror_state (obj )}) 953if finished is not None and quiet >= 3 : 954return 955 956 957def _invoke_reply ( 958call_id :str ,result :object ,state :dict [str ,object ]| None = None 959)-> dict [str ,object ]: 960"""One reference per result: inline the value when it can cross, retain it live. 961 962Typed composites (dataclasses, list subclasses) get both, so the owner can 963read them as values and still call methods on the live remote object. Plain 964data crosses raw: its methods are pure and its state already crossed, so a 965remote call would be the same computation plus a network fee, and retaining 966it would evict live references worth keeping. 967""" 968try : 969wire = encode (result ) 970encoded = json .dumps (wire ,ensure_ascii = True ) 971except (Unencodable ,TypeError ,ValueError ,RecursionError ): 972reply :dict [str ,object ]= { 973"type" :"invoked" , 974"id" :call_id , 975"ok" :True , 976"handle" :retain (result ), 977 } 978if state is not None : 979reply ["state" ]= state 980return reply 981if len (encoded )> MAX_RESULT_BYTES : 982return { 983"type" :"invoked" , 984"id" :call_id , 985"ok" :False , 986"error" : { 987"ename" :"RemoteValueError" , 988"evalue" :f"result is { len ( encoded ) } bytes, over the " 989f" { MAX_RESULT_BYTES } -byte ceiling" , 990"traceback" : [], 991 }, 992 } 993typed = ( 994isinstance (result ,list ) 995and type (result )is not list 996or dataclasses .is_dataclass (result ) 997and not isinstance (result ,type ) 998 ) 999if typed : 1000return { 1001"type" :"invoked" , 1002"id" :call_id , 1003"ok" :True , 1004"handle" :retain (result ), 1005"value" :wire , 1006 } 1007if state is not None : 1008return { 1009"type" :"invoked" , 1010"id" :call_id , 1011"ok" :True , 1012"handle" :retain (result ), 1013"state" :state , 1014 } 1015return {"type" :"invoked" ,"id" :call_id ,"ok" :True ,"value" :wire } 1016 1017 1018async def _owner_args (value :object )-> object : 1019"""Resolve owner argument markers back to what the call expects. 1020 1021`{"__ref__": id}` names a live reference and `{"pending": id}` a call whose 1022result this argument uses, resolved in order like a pending target, so an 1023argument raced ahead of its own reply still lands. `{"__bytes__": b64}` is 1024byte data, the same marker a reply value uses, decoded by albedo_values. 1025""" 1026if isinstance (value ,dict ): 1027if set (value )== {"__ref__" }and isinstance (value .get ("__ref__" ),str ): 1028obj = LIVE .get (value ["__ref__" ]) 1029if obj is None : 1030raise LookupError (f"live reference { value [ '__ref__' ]!r } is gone" ) 1031return obj 1032if set (value )== {"pending" }and isinstance (value .get ("pending" ),str ): 1033pending = value ["pending" ] 1034future = PENDING_OBJECTS .get (pending ) 1035if future is None : 1036raise LookupError (f"pending call { pending !r } is gone" ) 1037ok ,resolved = await cast (Awaitable [tuple [bool ,object ]],future ) 1038if not ok : 1039raise cast (BaseException ,resolved ) 1040return resolved 1041if set (value )== {"__bytes__" }and isinstance (value .get ("__bytes__" ),str ): 1042return decode (value ) 1043return {key :await _owner_args (item )for key ,item in value .items ()} 1044if isinstance (value ,list ): 1045return [await _owner_args (item )for item in value ] 1046return value 1047 1048 1049def _resolve (name :str )-> object : 1050"""The callable a namespace-path invoke addresses; handles resolve before this.""" 1051parts = name .split ("." )if name else [] 1052if not all (part .isidentifier ()and not part .startswith ("_" )for part in parts ): 1053raise ValueError (f"unaddressable owner invoke: { name !r } " ) 1054obj = NAMESPACE .get (parts [0 ])if parts else None 1055if obj is None : 1056raise LookupError ( 1057"no remote binding " 1058+ repr (parts [0 ]if parts else name ) 1059+ "; rem.tools() lists what exists" 1060 ) 1061for part in parts [1 :]: 1062obj = getattr (obj ,part ) 1063return obj 1064 1065 1066async def _target_object (target :object )-> object | None : 1067"""The object a target names: a live reference, a pending call's result, or nothing. 1068 1069A `{"pending": id}` target waits for the call that produced the object, so 1070method calls raced ahead of their own reply still resolve in order. 1071""" 1072if not isinstance (target ,dict ): 1073return None 1074key = target .get ("handle" ) 1075if isinstance (key ,str ): 1076obj = LIVE .get (key ) 1077if obj is None : 1078raise LookupError (f"live reference { key !r } is gone" ) 1079return obj 1080pending = target .get ("pending" ) 1081if isinstance (pending ,str ): 1082future = PENDING_OBJECTS .get (pending ) 1083if future is None : 1084raise LookupError (f"pending call { pending !r } is gone" ) 1085ok ,value = await cast (Awaitable [tuple [bool ,object ]],future ) 1086if not ok : 1087raise cast (BaseException ,value ) 1088return value 1089return None 1090 1091 1092async def serve_invoke (message :albedo_api .Invoke )-> None : 1093"""One owner tool call: resolve, call, await by the cell rule, answer one reference. 1094 1095The reply inlines the value when it can cross and otherwise retains the 1096object as a live reference; `value` and `handle` are one concept. Awaitables 1097run to completion unless their class is a registered handle, the same rule 1098the local evaluator applies, so a tool's remote shape matches its local one. 1099An explicit await of a live reference awaits it only when the object is 1100awaitable (a job); otherwise the object is the answer, as a reference, so 1101awaiting a call whose result is a plain live object (a spawned Browser) 1102settles into that reference instead of raising. 1103A job's reply carries its state, final when awaited, so the owner's poll() is 1104never racy and the owner can match the job's wake to its reference before 1105any mirror frame arrives. Every call's result object is retained briefly as 1106a pending target for method calls that raced ahead of the reply. 1107""" 1108call_id = message ["id" ] 1109OWNER_TASKS [call_id ]= cast (asyncio .Task [object ],asyncio .current_task ()) 1110future :asyncio .Future [tuple [bool ,object ]]= LOOP .create_future () 1111PENDING_OBJECTS [call_id ]= future 1112PENDING_OBJECTS .move_to_end (call_id ) 1113while len (PENDING_OBJECTS )> LIVE_LIMIT : 1114PENDING_OBJECTS .popitem (last = False ) 1115reply :dict [str ,object ] 1116result :object = None 1117state :dict [str ,object ]| None = None 1118failure :BaseException | None = None 1119try : 1120async with OWNER_CALLS : 1121base = await _target_object (message .get ("target" )) 1122if message .get ("await" )is True : 1123if base is None : 1124raise ValueError ("await needs a live or pending reference target" ) 1125if inspect .isawaitable (base ): 1126result = await cast (Awaitable [object ],base ) 1127else : 1128# A settled object that cannot be awaited (a spawned 1129# Browser) is the result itself; the reply retains it as 1130# the same live reference, so nothing is orphaned. 1131result = base 1132else : 1133name = message .get ("name" ,"" ) 1134if base is None : 1135call = _resolve (name ) 1136else : 1137parts = name .split ("." )if name else [] 1138if not all ( 1139part .isidentifier ()and not part .startswith ("_" ) 1140for part in parts 1141 ): 1142raise ValueError (f"unaddressable owner invoke: { name !r } " ) 1143call = base 1144for part in parts : 1145call = getattr (call ,part ) 1146args = [ 1147await _owner_args (item ) 1148for item in cast (Sequence [object ],message .get ("args" , ())) 1149 ] 1150kwargs = { 1151key :await _owner_args (item ) 1152for key ,item in message .get ("kwargs" , {}).items () 1153 } 1154if not callable (call ): 1155raise TypeError (f"owner invoke { name !r } is not callable" ) 1156result = call (* args ,** kwargs ) 1157if inspect .isawaitable (result )and not isinstance ( 1158result ,tuple (HANDLES ) 1159 ): 1160result = await cast (Awaitable [object ],result ) 1161if isinstance (getattr (result ,"capture" ,None ),Capture ): 1162state = _mirror_state (result ) 1163except asyncio .CancelledError as error : 1164failure = error 1165# The owner cancelled its wait; the remote effect may continue, and the 1166# live reference (a running job, for instance) stays addressable. 1167reply = { 1168"type" :"invoked" , 1169"id" :call_id , 1170"ok" :False , 1171"cancelled" :True , 1172"error" : { 1173"ename" :"CancelledError" , 1174"evalue" :"the owner cancelled this wait; the remote effect " 1175"may continue and its reference stays addressable" , 1176"traceback" : [], 1177 }, 1178 } 1179except BaseException as error : 1180failure = error 1181reply = { 1182"type" :"invoked" , 1183"id" :call_id , 1184"ok" :False , 1185"error" : { 1186"ename" :type (error ).__name__ , 1187"evalue" :str (error )[:8192 ], 1188"traceback" :traceback .format_exc ().splitlines ()[- 8 :], 1189 }, 1190 } 1191else : 1192reply = _invoke_reply (call_id ,result ,state ) 1193finally : 1194OWNER_TASKS .pop (call_id ,None ) 1195if not future .done (): 1196future .set_result ( 1197 (failure is None ,failure if failure is not None else result ) 1198 ) 1199send (reply ) 1200 1201 1202async def serve_job_stop (job_id :str )-> None : 1203"""Stop a background job on behalf of the port owner.""" 1204answer :dict [str ,object ]= { 1205"type" :"job_stop" , 1206"id" :job_id , 1207"ok" :False , 1208"error" :"job not found" , 1209 } 1210for handler in reversed (STOP_HANDLERS ): 1211try : 1212res = await handler (job_id ) 1213if res is not None : 1214answer = {"type" :"job_stop" ,"id" :job_id ,** res } 1215break 1216except Exception as error : 1217answer = { 1218"type" :"job_stop" , 1219"id" :job_id , 1220"ok" :False , 1221"error" :str (error ).lower ()or "could not stop job" , 1222 } 1223break 1224send (answer ) 1225 1226 1227def deliver_trace (capture :Capture )-> None : 1228payload = capture .trace .finish () 1229send ({"type" :"trace" ,"id" :capture .id ,"trace" :payload }) 1230capture .trace .release () 1231 1232 1233def cell_read (id :str )-> None : 1234task = CELL_TASKS .get (id ) 1235if id in BACKGROUND_RESULTS or (task is not None and task .done ()): 1236READ_CELLS .add (id ) 1237BACKGROUND_RESULTS .pop (id ,None ) 1238 1239 1240def first_line (code :str )-> str : 1241"""The cell's first line that is not blank or a comment, for one-line displays.""" 1242for line in code .splitlines (): 1243line = line .strip () 1244if line and not line .startswith ("#" ): 1245return line [:200 ] 1246return "python cell" 1247 1248 1249async def announce_cells ()-> None : 1250while BACKGROUND_RESULTS : 1251batch = dict (BACKGROUND_RESULTS ) 1252display = "\n" .join ( 1253f"cell finished (status= { result [ 'status' ] } , ran { result [ 'duration' ] } s): { result [ 'summary' ] } " 1254for result in batch .values () 1255 ) 1256listed = "; " .join ( 1257f" { id } : status= { result [ 'status' ] } , ran { result [ 'duration' ] } s" 1258for id ,result in batch .items () 1259 ) 1260text = ( 1261"<system-note>background python cells finished with results unread: " 1262+ listed 1263+ ". output.read(id) reads retained output; await cells.info(id) reads " 1264"the final status. no user sent this message; use the results if needed, " 1265"otherwise acknowledge briefly and stay idle.</system-note>" 1266 ) 1267try : 1268await host ("cells.completed" , {"display" :display ,"text" :text }) 1269except Exception as error : 1270if getattr (error ,"code" ,"" )== "busy" : 1271await asyncio .sleep (2 ) 1272continue 1273for id in batch : 1274capture = ARCHIVES .get (id ) 1275if capture is not None : 1276capture .write (f"\n[completion notice not delivered: { error } ]\n" ) 1277for id in batch : 1278BACKGROUND_RESULTS .pop (id ,None ) 1279 1280 1281async def execute_cell ( 1282message :albedo_api .Execute ,capture :Capture 1283)-> dict [str ,object ]: 1284token = CELL .set (capture ) 1285status ,value = "ok" ,"" 1286began = LOOP .time () 1287_ = swap_sink (capture ) 1288try : 1289if capture .interruption : 1290raise asyncio .CancelledError () 1291result = await evaluate ( 1292message ["code" ],capture .id ,message .get ("durable" ,False ) 1293 ) 1294if result is not None : 1295value = show (result ) 1296except (KeyboardInterrupt ,asyncio .CancelledError ): 1297status = "interrupted" 1298if capture .interruption == "deadline" : 1299capture .write ("\n[cell deadline exceeded; execution interrupted]\n" ) 1300elif capture .interruption == "memory" : 1301used = albedo_memory .footprint ()or 0 1302capture .write ( 1303f"\n[kernel memory passed its cap: { albedo_memory . mebibytes ( used ) } held," 1304f" cap { albedo_memory . mebibytes ( albedo_memory . LIMIT ) } ; execution interrupted." 1305" The namespace is kept: del large variables, gc.collect(), or work in smaller pieces.]\n" 1306 ) 1307except BaseException as error : 1308status = "error" 1309failed_id = cast (str ,getattr (error ,"_albedo_cell_id" ,capture .id )) 1310capture .write (traceback .format_exc ()) 1311if message .get ("durable" ,False ): 1312capture .write ( 1313f"\n[cell { failed_id } retained; inspect with await cells.read( { failed_id !r } ); repair with await cells.run( { failed_id !r } , replacements=[(old, new)])]\n" 1314 ) 1315finally : 1316_ = drain () 1317_ = swap_sink (None ) 1318CELL .reset (token ) 1319deliver_trace (capture ) 1320return { 1321"type" :"done" , 1322"id" :capture .id , 1323"status" :status , 1324"duration" :round (LOOP .time ()- began ,3 ), 1325"output" :capture .preview (status )+ capture .spill_note (), 1326"value" :value , 1327"truncated" :capture .seen > PREVIEW , 1328"images" :capture .encoded_images (), 1329 } 1330 1331 1332async def finish_background ( 1333message :albedo_api .Execute ,task :asyncio .Task [dict [str ,object ]] 1334)-> None : 1335global CELL_ANNOUNCER 1336done = await task 1337capture = ARCHIVES [message ["id" ]] 1338if done ["value" ]: 1339capture .write ("\n[result]\n" + str (done ["value" ])+ "\n" ) 1340done ["output" ]= capture .preview (str (done ["status" ]))+ capture .spill_note () 1341done ["truncated" ]= capture .seen > PREVIEW 1342# Commit before the wake, so cells.info/read see the terminal outcome. 1343if message .get ("durable" ,False ): 1344try : 1345await host ("cells.finish" , {"id" :message ["id" ],"outcome" :done }) 1346except Exception as error : 1347ARCHIVES [message ["id" ]].write ( 1348f"\n[cell result could not be journaled: { error } ]\n" 1349 ) 1350else : 1351send (done ) 1352retain_cell_result (message ["id" ],done ) 1353if message ["id" ]in READ_CELLS : 1354READ_CELLS .discard (message ["id" ]) 1355return 1356BACKGROUND_RESULTS [message ["id" ]]= {** done ,"summary" :first_line (message ["code" ])} 1357if CELL_ANNOUNCER is None or CELL_ANNOUNCER .done (): 1358CELL_ANNOUNCER = LOOP .create_task (announce_cells ()) 1359 1360 1361def retain_cell_result (id :str ,done :dict [str ,object ])-> None : 1362FINISHED [id ]= done 1363while len (FINISHED )> FINISHED_LIMIT : 1364old ,_ = FINISHED .popitem (last = False ) 1365BACKGROUND_RESULTS .pop (old ,None ) 1366READ_CELLS .discard (old ) 1367budget = FINISHED_IMAGE_BYTES 1368for result in reversed (FINISHED .values ()): 1369images = cast (list [str ],result .get ("images" , [])) 1370budget -= sum (map (len ,images )) 1371if budget < 0 and images : 1372result ["images" ]= [] 1373result ["output" ]= ( 1374f" { result [ 'output' ] } \n[ { len ( images ) } image(s) not kept for a replayed result]" 1375 ) 1376EXECUTING .discard (id ) 1377CELL_TASKS .pop (id ,None ) 1378CELL_CAPTURES .pop (id ,None ) 1379CELL_STARTS .pop (id ,None ) 1380capture = ARCHIVES .get (id ) 1381if capture is not None : 1382capture .end_spill () 1383capture .images .clear () 1384remember (capture ) 1385send ({"type" :"cells" ,"live" :len (CELL_TASKS )}) 1386 1387 1388def cell_timers ( 1389capture :Capture ,task :asyncio .Task [dict [str ,object ]],timeout_ms :int 1390)-> tuple [ 1391threading .Event , 1392threading .Event , 1393threading .Lock , 1394asyncio .Future [None ], 1395threading .Timer , 1396]: 1397detached = threading .Event () 1398settled = threading .Event () 1399response_lock = threading .Lock () 1400released :asyncio .Future [None ]= LOOP .create_future () 1401 1402def background ()-> None : 1403with response_lock : 1404if settled .is_set ()or task .done (): 1405return 1406detached .set () 1407send (background_reply (capture )) 1408LOOP .call_soon_threadsafe (released .set_result ,None ) 1409 1410# Threads also fire while synchronous cell code occupies the asyncio loop. 1411background_timer = threading .Timer (BACKGROUND_SECONDS ,background ) 1412deadline_timer = threading .Timer ( 1413timeout_ms / 1000 , 1414receive , 1415args = ({"type" :"interrupt" ,"id" :capture .id ,"reason" :"deadline" },), 1416 ) 1417background_timer .daemon = deadline_timer .daemon = True 1418background_timer .start () 1419deadline_timer .start () 1420task .add_done_callback (lambda _ :deadline_timer .cancel ()) 1421return detached ,settled ,response_lock ,released ,background_timer 1422 1423 1424async def serve (): 1425while True : 1426message = await QUEUE .get () 1427if message ["type" ]!= "execute" : 1428send (state_reply (message )) 1429continue 1430capture = Capture (message ["id" ],max_edge = message .get ("max_edge" )) 1431capture .interruption = "" 1432remember (capture ) 1433if len (CELL_TASKS )>= LIMITS ["cell" ]: 1434done :dict [str ,object ]= { 1435"type" :"done" , 1436"id" :capture .id , 1437"status" :"error" , 1438"duration" :0 , 1439"output" :"16 cells are still running; cancel an unused cell with await cells.cancel(id), or end your turn and wait for completion." , 1440"value" :"" , 1441"truncated" :False , 1442"images" : [], 1443 } 1444retain_cell_result (capture .id ,done ) 1445send (done ) 1446continue 1447task = LOOP .create_task (execute_cell (message ,capture )) 1448CELL_TASKS [capture .id ]= task 1449CELL_CAPTURES [capture .id ]= capture 1450CELL_STARTS [capture .id ]= albedo_memory .footprint ()or 0 1451send ({"type" :"cells" ,"live" :len (CELL_TASKS )}) 1452interrupted = INTERRUPTS .pop (capture .id ,None ) 1453if interrupted is not None : 1454capture .interruption = interrupted 1455detached ,settled ,response_lock ,released ,background_timer = cell_timers ( 1456capture ,task ,message .get ("timeout_ms" ,300_000 ) 1457 ) 1458_ = await asyncio .wait ({task ,released },return_when = asyncio .FIRST_COMPLETED ) 1459with response_lock : 1460settled .set () 1461background_timer .cancel () 1462is_background = detached .is_set () 1463if is_background : 1464watch_output (capture .id ,lambda id = capture .id :cell_read (id )) 1465_ = LOOP .create_task (finish_background (message ,task )) 1466else : 1467done = task .result () 1468retain_cell_result (capture .id ,done ) 1469READ_CELLS .discard (capture .id ) 1470send (done ) 1471 1472 1473def background_reply (capture :Capture )-> dict [str ,object ]: 1474return { 1475"type" :"done" , 1476"id" :capture .id , 1477"status" :"backgrounded" , 1478"duration" :BACKGROUND_SECONDS , 1479"output" :capture .preview ("ok" ) 1480+ capture .spill_note () 1481+ ( 1482f"\n[cell { capture . id } is still running in the background. " 1483"the session will wake automatically when it finishes; do NOT poll " 1484"or sleep. meanwhile do other useful work, or give the user a short " 1485"status report and end your turn. the original timeout_ms deadline " 1486"still applies. avoid changing variables this cell is using.]\n" 1487 ), 1488"value" :"" , 1489"truncated" :capture .seen > PREVIEW , 1490"images" :capture .encoded_images (), 1491 } 1492 1493 1494def flush (timeout :float )-> None : 1495"""Give the owner a moment to attach and take what we sent, before a 1496detached kernel that failed to start leaves.""" 1497if isinstance (LINK ,albedo_link .SocketLink ): 1498deadline = time .monotonic ()+ timeout 1499while time .monotonic ()< deadline and ( 1500not LINK .attached ()or LINK .outbox .entries 1501 ): 1502time .sleep (0.02 ) 1503 1504 1505def open_link (argv :list [str ])-> list [str ]: 1506"""Stdio for `kernel.py <modules>`; a socket for `kernel.py --run <dir> <modules>`, 1507whose bridge writes the attach token on our stdin. Returns the module list.""" 1508global LINK ,RUN_DIR 1509if argv [1 :2 ]!= ["--run" ]: 1510LINK = albedo_link .StdioLink () 1511return cast (list [str ],json .loads (argv [1 ])) 1512RUN_DIR = argv [2 ] 1513token = sys .stdin .buffer .readline ().decode ().strip () 1514LINK = albedo_link .SocketLink ( 1515RUN_DIR , 1516token , 1517hello , 1518bundle = albedo_bundle .digest (), 1519grace = FIRST_ATTACH_GRACE , 1520 ) 1521return cast (list [str ],json .loads (argv [3 ])) 1522 1523 1524PRELOADED = ( 1525"asyncio base64 collections datetime functools hashlib itertools json math os re shutil sys textwrap time" 1526).split () 1527 1528 1529def session_builtins (bindings :dict [str ,object ])-> dict [str ,object ]: 1530"""Python's builtins plus the common standard-library modules and the 1531injected bindings. Cells may shadow any of them and `del` reveals it again; 1532a binding wins over a module of the same name.""" 1533modules = {name :importlib .import_module (name )for name in PRELOADED } 1534return vars (builtins )| modules | {"Path" :pathlib .Path }| bindings 1535 1536 1537class Bindings (ModuleType ): 1538"""`import albedo` and `from albedo import files` answer the injected 1539bindings, for code written as if they were a library.""" 1540 1541def __init__ (self ,bindings :dict [str ,object ])-> None : 1542super ().__init__ ("albedo" ,"The tool bindings every cell already has." ) 1543self .bindings = bindings 1544 1545def __getattr__ (self ,name :str )-> object : 1546try : 1547return self .bindings [name ] 1548except KeyError : 1549raise AttributeError (f"albedo has no binding { name !r } " )from None 1550 1551def __dir__ (self )-> list [str ]: 1552return sorted (self .bindings ) 1553 1554 1555def main (): 1556global NATIVE_FD 1557if os .getpgrp ()!= os .getpid (): 1558os .setsid () 1559modules = open_link (sys .argv ) 1560# Beside the run directories: $ALBEDO_HOME/output, ~/.albedo-remote/output. 1561albedo_capture .prune_spills ( 1562os .path .join (os .path .dirname (os .path .dirname (RUN_DIR )),"output" ) 1563if RUN_DIR is not None 1564else os .path .join (tempfile .gettempdir (),"albedo-output" ) 1565 ) 1566_ = os .dup2 (os .open (os .devnull ,os .O_RDONLY ),0 ) 1567read_fd ,write_fd = os .pipe () 1568_ = os .dup2 (write_fd ,1 ) 1569_ = os .dup2 (write_fd ,2 ) 1570os .close (write_fd ) 1571os .set_blocking (read_fd ,False ) 1572NATIVE_FD = read_fd 1573remember (NATIVE ) 1574sys .stdin = open (os .devnull ) 1575sys .stdout = sys .stderr = Stream () 1576threading .Thread (target = native_output ,args = (read_fd ,),daemon = True ).start () 1577threading .Thread (target = reader ,daemon = True ).start () 1578if RUN_DIR is not None : 1579threading .Thread (target = watch ,daemon = True ).start () 1580threading .Thread (target = guard_memory ,daemon = True ).start () 1581_ = signal .signal (signal .SIGINT ,interrupt ) 1582albedo_shell .install ()# refusals precede observational audit hooks 1583albedo_trace .install (CELL .get ) 1584api = albedo_api .PythonApi ( 1585version = 2 , 1586loop = LOOP , 1587host = host , 1588host_now = host_now , 1589HostError = WorkError , 1590forget_output = forget_output , 1591capture = background_capture , 1592preview = PREVIEW , 1593send = send , 1594on_shutdown = CLEANUP .append , 1595background_handle = HANDLES .append , 1596modules = modules , 1597watch_output = watch_output , 1598attach_image = attach_image , 1599on_stop_job = STOP_HANDLERS .append , 1600 ) 1601NAMESPACE .update (cells = Cells (),output = Output (),show_image = show_image ) 1602try : 1603LOOP .run_until_complete (albedo_api .load_plugins (modules ,api ,NAMESPACE )) 1604except Exception as error : 1605send ({"type" :"startup_error" ,"message" :str (error )}) 1606flush (5.0 ) 1607die () 1608return 1609# Match an interactive Python session only after loading trusted plugins. 1610sys .path [0 ]= "" 1611# Plugin bindings and session objects are rebuilt on every start, never saved. 1612INJECTED .update (NAMESPACE ) 1613# Cell globals may shadow tools; deletion reveals the original session binding. 1614bindings = { 1615name :value for name ,value in NAMESPACE .items ()if not name .startswith ("_" ) 1616 } 1617NAMESPACE ["__builtins__" ]= session_builtins (bindings ) 1618sys .modules ["albedo" ]= Bindings (bindings ) 1619# Declare our own process group; a group we do not lead is never the supervisor's target. 1620pgid = os .getpgid (0 ) 1621send ( 1622 { 1623"type" :"ready" , 1624"pid" :os .getpid (), 1625"pgid" :pgid if pgid == os .getpid ()else None , 1626"leader" :albedo_proc .leader_token (os .getpid ()), 1627 } 1628 ) 1629READY .set () 1630task = LOOP .create_task (serve ()) 1631while not task .done (): 1632try : 1633LOOP .run_until_complete (task ) 1634except KeyboardInterrupt : 1635if interrupt_capture is not None : 1636running = CELL_TASKS .get (interrupt_capture .id ) 1637if running is not None : 1638_ = running .cancel () 1639die () 1640 1641 1642if __name__ == "__main__" : 1643main ()