okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnkernel: a job_stop frame lets /jobs stop remote background jobs57a82be

main
30.6 KiB821 linesraw
1"""Programs start as background jobs, and jobs are owned process groups.
2
3run(program, *args) starts one program without a shell. A job runs in its own
4session, so its program leads a process group and one signal reaches it plus
5every descendant that stayed in that group. The group
6is ended when the command ends, when its deadline passes, and at kernel
7shutdown. What survives is reported, and unfinished work stays owned.
8When the command ends on its own, members a process that left the group still
9parents stay with it: ssh's ControlPersist master daemonizes out of the job
10but leaves its ProxyCommand in the group, and ending that ends the master.
11
12A job that finishes with its result unread wakes the session: the kernel tells
13the host, the host submits a user turn naming the job, and the model never has
14to poll or await. Reading the result (tail, poll, await, output.read) or
15stopping the job withdraws the wake; an await cut short by an interrupt or a
16deadline read nothing, so it leaves the wake owed. A busy session is retried
17until it goes idle, so a notice can never overtake the read that satisfies it. Jobs
18still owed when the retry lands share one notice, and so one turn.
19"""
20
21from __future__ import annotations
22
23from albedo_output import JobOutput
24
25from collections import OrderedDict
26from collections.abc import Callable, Generator
27from typing import cast
28from albedo_api import (
29    Host,
30    PythonApi,
31    OutputCapture,
32    Send,
33    check_timeout,
34)
35import albedo_proc
36import albedo_shell
37import albedo_shims
38import albedo_trace
39import asyncio
40import os
41import shlex
42import shutil
43import sys
44import uuid
45
46loop: asyncio.AbstractEventLoop
47capture_factory: Callable[[str], OutputCapture]
48preview_limit: int  # internal file searches use the kernel preview budget
49jobs: dict[str, Job] = {}  # every handle the session can address
50active: dict[str, Job] = {}  # unfinished work: the bounded resource
51retained: OrderedDict[str, Job] = OrderedDict()  # finished handles, completion order
52owed: dict[str, Job] = {}  # finished with results unread, in completion order
53announcer: asyncio.Task[None] | None = None
54send: Send
55host: Host | None = None
56watch_output: Callable[[str, Callable[[], None]], None] | None = None
57forget_output: Callable[[str], None] | None = None
58
59ACTIVE_LIMIT = 64  # active jobs in one kernel
60RETAINED_LIMIT = 64  # finished handles still addressable through `jobs`
61COMPLETION_GRACE = 0.1  # seconds to keep reading after the command exits
62SHUTDOWN_TERM = 0.25  # shared by every live group at shutdown
63SHUTDOWN_KILL = 1.0
64NICENESS = 10
65# macOS runs utility-QoS work below the interface, so a busy swarm yields.
66TASKPOLICY = shutil.which("taskpolicy") if sys.platform == "darwin" else None
67NOTICE_RETRY = 2.0  # seconds between wake attempts while the session runs
68NOTICE_COMMAND_CAP = 200  # command characters a wake notice carries
69
70
71class Outlet:
72    """A job's output on its way to other jobs' stdin, besides its capture."""
73
74    def __init__(self) -> None:
75        self.feeds: list[Feed] = []
76        self.closed = False  # the program's output ended; feeds get EOF
77        self.reading: asyncio.ReadTransport | None = None
78        self.held: set[Feed] = set()  # readers whose stdin is full
79
80    def write(self, data: bytes) -> None:
81        for feed in self.feeds:
82            feed.write(data)
83
84    def close(self) -> None:
85        if not self.closed:
86            self.closed = True
87            for feed in self.feeds:
88                feed.end()
89
90    def cut(self) -> None:
91        """Stop reading the program's output once nothing wants it, so its
92        next write fails the way it would into a closed shell pipe."""
93        if self.reading is not None and not self.reading.is_closing():
94            self.reading.close()
95
96    def hold(self, feed: Feed) -> None:
97        self.held.add(feed)
98        if self.reading is not None:
99            self.reading.pause_reading()
100
101    def release(self, feed: Feed) -> None:
102        self.held.discard(feed)
103        if not self.held and self.reading is not None and not self.reading.is_closing():
104            self.reading.resume_reading()
105
106
107class Feed:
108    """One job's output into another's stdin, the way a shell pipe carries it:
109    bytes wait until the reader starts, a full reader pauses the writer, and
110    once every reader has stopped, the writer's next write fails (SIGPIPE)."""
111
112    def __init__(self, writer: Job, retained: bytes) -> None:
113        self.writer = writer
114        self.pending = bytearray(retained)
115        self.pipe: asyncio.WriteTransport | None = None
116        self.ended = False
117        self.broken = False
118
119    def write(self, data: bytes) -> None:
120        if self.broken:
121            return
122        if self.pipe is None:
123            self.pending.extend(data)
124        else:
125            self.pipe.write(data)
126
127    def end(self) -> None:
128        self.ended = True
129        if self.pipe is not None and not self.pipe.is_closing():
130            self.pipe.close()
131
132    def connect(self, pipe: asyncio.WriteTransport) -> None:
133        self.pipe = pipe
134        pipe.write(bytes(self.pending))
135        self.pending.clear()
136        if self.ended:
137            pipe.close()
138
139    def lost(self) -> None:
140        """The reader's stdin closed before the writer's output ended."""
141        self.writer.outlet.release(self)
142        if self.ended or self.broken:
143            return
144        self.broken = True
145        if all(feed.broken for feed in self.writer.outlet.feeds):
146            self.writer.outlet.cut()
147
148
149class Command(asyncio.SubprocessProtocol):
150    """Separate command exit from pipe EOF, without a timer per running job."""
151
152    def __init__(
153        self, capture: OutputCapture, outlet: Outlet, feed: Feed | None
154    ) -> None:
155        self.capture = capture
156        self.outlet = outlet
157        self.feed = feed  # what fills this program's stdin, if a job does
158        self.exited: asyncio.Future[int] = loop.create_future()
159        self.drained: asyncio.Future[None] = loop.create_future()
160        self.transport: asyncio.SubprocessTransport
161        self.pid: int
162
163    def connection_made(self, transport: asyncio.BaseTransport) -> None:
164        self.transport = cast(asyncio.SubprocessTransport, transport)
165        self.pid = self.transport.get_pid()
166        self.outlet.reading = cast(
167            asyncio.ReadTransport, self.transport.get_pipe_transport(1)
168        )
169
170    @property
171    def returncode(self) -> int | None:
172        return self.transport.get_returncode()
173
174    def pipe_data_received(self, fd: int, data: bytes) -> None:
175        # Capture is bounded and synchronous; no unbounded reader queue.
176        self.capture.write_bytes(data)
177        self.outlet.write(data)
178
179    def pipe_connection_lost(self, fd: int, exc: Exception | None) -> None:
180        if fd == 0:
181            if self.feed is not None:
182                self.feed.lost()
183            return  # stdin written and closed; output is still coming
184        self.outlet.close()
185        if not self.drained.done():
186            self.drained.set_result(None)
187        if self.exited.done():
188            self.transport.close()
189
190    def pause_writing(self) -> None:
191        if self.feed is not None:
192            self.feed.writer.outlet.hold(self.feed)
193
194    def resume_writing(self) -> None:
195        if self.feed is not None:
196            self.feed.writer.outlet.release(self.feed)
197
198    def process_exited(self) -> None:
199        code = self.returncode
200        assert code is not None
201        self.exited.set_result(code)
202        if self.drained.done():
203            self.transport.close()
204
205
206Data = str | bytes | os.PathLike[str]
207
208
209async def spawn(
210    argv: list[str],
211    capture: OutputCapture,
212    cwd: str | None,
213    env: dict[str, str] | None,
214    stdin: Data | None,
215    outlet: Outlet,
216    feed: Feed | None,
217) -> Command:
218    """Start argv at a lower priority than the person at the machine, with
219    stdin fed from text, bytes, a file, or another job, and stdout and stderr
220    joined."""
221    if TASKPOLICY is not None:
222        argv = [TASKPOLICY, "-c", "utility", *argv]
223    file = open(stdin, "rb") if isinstance(stdin, os.PathLike) else None
224    piped = stdin is not None or feed is not None
225    source = file or (asyncio.subprocess.PIPE if piped else asyncio.subprocess.DEVNULL)
226    try:
227        _, process = await loop.subprocess_exec(
228            lambda: Command(capture, outlet, feed),
229            *argv,
230            stdin=source,
231            stdout=asyncio.subprocess.PIPE,
232            stderr=asyncio.subprocess.STDOUT,
233            cwd=cwd,
234            env=env,
235            start_new_session=True,
236            preexec_fn=_lower_priority,
237        )
238    finally:
239        if file is not None:
240            file.close()
241    pipe = cast(asyncio.WriteTransport | None, process.transport.get_pipe_transport(0))
242    if pipe is not None and feed is not None:
243        feed.connect(pipe)
244    elif pipe is not None and isinstance(stdin, (str, bytes)):
245        pipe.write(stdin.encode() if isinstance(stdin, str) else stdin)
246        pipe.close()
247    return process
248
249
250def _lower_priority() -> None:
251    try:
252        os.nice(NICENESS)
253    except OSError:
254        pass
255
256
257class Job(JobOutput):
258    """One background program and the process group it owns."""
259
260    def __init__(
261        self,
262        argv: list[str],
263        timeout: float,
264        *,
265        cwd: str | None = None,
266        env: dict[str, str] | None = None,
267        stdin: Data | Job | None = None,
268        traced: bool = True,
269        service: bool = False,
270    ) -> None:
271        self.service = service  # expected to run on; its end is not what we wait for
272        self.source: Job | None = (
273            stdin if isinstance(stdin, Job) else None
274        )  # whose output is our stdin
275        self.feed = self.source._reader() if self.source is not None else None
276        self.outlet = Outlet()
277        self.id: str = uuid.uuid4().hex
278        self.argv: list[str] = argv
279        self.command: str = shlex.join(argv)  # what ran, as one line to read
280        self.process: Command | None = None
281        self.group: albedo_proc.Group | None = None
282        self.exit_code: int | None = None
283        self.timed_out: bool = False
284        self.started: float = loop.time()
285        self.duration: float | None = None  # seconds it ran, once it ended
286        self.termination: albedo_proc.Termination | None = None
287        self.capture: OutputCapture = capture_factory(self.id)
288        self.ending: asyncio.Task[albedo_proc.Termination] | None = None
289        self._awaited = False  # an await returned this job; its result reached them
290        self._waiters = 0  # awaits still waiting; one cut short leaves the wake owed
291        self._read = False  # the finished result was read; no wake is owed
292        self._remote = os.environ.get("ALBEDO_REMOTE_TARGET") or None
293        self._starting = (
294            False  # the OS spawn began; from then on it must finish, not be cancelled
295        )
296        if watch_output is not None:
297            watch_output(self.id, self._mark_read)
298        if traced:
299            albedo_trace.note("run", self.command)
300        data = None if isinstance(stdin, Job) else stdin
301        with albedo_trace.unobserved():  # the cell ran argv, not the wrapper around it
302            self.spawning = loop.create_task(self._launch(cwd, env, data, self.feed))
303        self.task: asyncio.Task[Job] = loop.create_task(self._run(timeout))
304        self.task.add_done_callback(self._cancelled)
305        active[self.id] = self
306        jobs[self.id] = self
307
308    async def _launch(
309        self,
310        cwd: str | None,
311        env: dict[str, str] | None,
312        stdin: Data | None,
313        feed: Feed | None,
314    ) -> Command:
315        self._starting = True
316        return await spawn(self.argv, self.capture, cwd, env, stdin, self.outlet, feed)
317
318    def _reader(self) -> Feed:
319        """A feed of this job's output for another job's stdin: what it wrote
320        so far, then the rest as it comes. Piping it retires its own wake; the
321        reader's notice names the pipeline."""
322        if self.capture.seen > len(self.capture.data):
323            raise ValueError(
324                f"job {self.id} wrote more than it retains ({len(self.capture.data)} bytes); "
325                "pipe from it before it runs: run(...).pipe(...) in one expression"
326            )
327        feed = Feed(self, bytes(self.capture.data))
328        self.outlet.feeds.append(feed)
329        if self.outlet.closed:
330            feed.end()
331        self._read = True
332        return feed
333
334    def pipe(
335        self,
336        program: object,
337        *args: object,
338        cwd: str | os.PathLike[str] | None = None,
339        env: dict[str, object] | None = None,
340        timeout: float = 300,
341    ) -> Job:
342        """Start a program reading this job's output, like a shell `|`:
343        run("git", "log").pipe("rg", "fix"). Returns the reader's handle."""
344        return run(program, *args, cwd=cwd, env=env, stdin=self, timeout=timeout)
345
346    @property
347    def pipeline(self) -> str:
348        """The command line this job ends, with every job piped into it."""
349        return (
350            f"{self.source.pipeline} | {self.command}"
351            if self.source is not None
352            else self.command
353        )
354
355    def claim(self) -> albedo_proc.Group | None:
356        """The group this job owns, derived again when a late spawn beat the assignment."""
357        if self.group is None and self.process is not None:
358            self.group = albedo_proc.Group(
359                self.process.pid, albedo_proc.leader_token(self.process.pid)
360            )
361            send(
362                {
363                    "type": "job_start",
364                    "id": self.id,
365                    "pid": self.process.pid,
366                    "pgid": self.group.pgid,
367                    "leader": self.group.leader,
368                    "command": self.pipeline[:4096],
369                    "service": self.service,
370                }
371            )
372        return self.group
373
374    def _cancelled(self, task: asyncio.Task[Job]) -> None:
375        if task.cancelled():
376            # Cancellation before _run starts skips its finally block.
377            loop.create_task(self._stop())
378
379    async def _spawn(self) -> Command:
380        process = await asyncio.shield(self.spawning)
381        self.process = process
382        self.claim()
383        return process
384
385    async def _run(self, timeout: float) -> Job:
386        try:
387            process = self.process = await self._spawn()
388            await asyncio.wait_for(asyncio.shield(process.exited), timeout)
389            self.exit_code = await self._drain(process)
390        except asyncio.TimeoutError:
391            self.timed_out = True
392        except asyncio.CancelledError:
393            pass
394        except Exception as error:
395            self.capture.write(f"{type(error).__name__}: {error}\n")
396        finally:
397            self.outlet.close()
398            if self.feed is not None and self.feed.pipe is None:
399                self.feed.lost()  # never started, so never read
400            ending = await self._stop(exited=self.exit_code is not None)
401            if self.exit_code is None and self.process is not None:
402                self.exit_code = await self._status(self.process)
403            if self.process is not None and ending.gone:
404                self.process.transport.close()
405            self.duration = loop.time() - self.started
406            if self.timed_out:
407                self.capture.write(
408                    f"\n[deadline exceeded after {timeout:g}s; {ending.report()}]\n"
409                )
410            elif not ending.gone:
411                self.capture.write(f"\n[cleanup failed: {ending.report()}]\n")
412            send(
413                {
414                    "type": "job",
415                    "id": self.id,
416                    "exit_code": self.exit_code,
417                    "timed_out": self.timed_out,
418                    "cleanup": ending.as_json(),
419                }
420            )
421            self.capture.end_spill()
422            release(self)
423            self._owe_unread()
424        return self
425
426    async def _drain(self, process: Command) -> int:
427        """Read output while the command runs; a descendant holding the pipe does not extend it."""
428        code = await asyncio.shield(process.exited)
429        await asyncio.wait({process.drained}, timeout=COMPLETION_GRACE)
430        return code
431
432    async def _status(self, process: Command, window: float = 1.0) -> int | None:
433        """The command's exit status after its group is gone; bounded, never assumed."""
434        try:
435            return await asyncio.wait_for(asyncio.shield(process.exited), window)
436        except asyncio.TimeoutError:
437            return process.returncode
438
439    def __await__(self) -> Generator[object, None, Job]:
440        return self._wait().__await__()
441
442    async def _wait(self) -> Job:
443        """Wait for the job. An await cut short (an interrupted cell, a
444        wait_for deadline) never delivered the result, so the job still wakes
445        the session when it ends."""
446        self._waiters += 1
447        try:
448            await asyncio.shield(self.task)
449            self._awaited = True
450        finally:
451            self._waiters -= 1
452            if self.task.done():
453                self._owe_unread()
454        return self
455
456    def poll(self):
457        if self.exit_code is not None:
458            self._read = True
459        return self.exit_code
460
461    @property
462    def returncode(self) -> int | None:
463        """`exit_code` under subprocess's name; both spellings answer."""
464        return self.poll()
465
466    def _mark_read(self) -> None:
467        """output.read reached this job's channel; a finished result is read."""
468        if self.exit_code is not None:
469            self._read = True
470
471    @property
472    def _unreported(self) -> bool:
473        """Finished, and its result has reached no one yet."""
474        return not self._read and not self._awaited and not self._waiters
475
476    def _owe_unread(self) -> None:
477        """Queue the finished job's wake unless its result reached someone."""
478        if host is not None and self._unreported:
479            owe(self)
480
481    def _facts(self) -> dict[str, object]:
482        """What a wake notice says about this job."""
483        command = self.pipeline[:NOTICE_COMMAND_CAP] + (
484            "..." if len(self.pipeline) > NOTICE_COMMAND_CAP else ""
485        )
486        outcome = (
487            "timed out"
488            if self.timed_out
489            else f"exit_code={self.exit_code}"
490            if self.exit_code is not None
491            else "exit status unknown"
492        )
493        seconds = (
494            f"ran {self.duration:.1f}s"
495            if self.duration is not None
496            else "unknown duration"
497        )
498        return {
499            "id": self.id,
500            "command": command,
501            "where": f" on {self._remote}" if self._remote else "",
502            "summary": f"{outcome}, {seconds}",
503            "exit_code": self.exit_code,
504            "timed_out": self.timed_out,
505            "duration": self.duration,
506            "host": self._remote,
507        }
508
509    async def stop(self) -> albedo_proc.Termination:
510        """End the job's process group. A failed attempt stays retryable."""
511        self._read = True  # an explicit stop is its own report; no wake is owed
512        return await self._stop()
513
514    async def _stop(self, exited: bool = False) -> albedo_proc.Termination:
515        """End the group; once the command `exited` on its own, what a process
516        that left the group still parents stays with that process."""
517        if self.termination is not None and self.termination.gone:
518            return self.termination
519        process = self.process
520        if process is None:
521            if not self._starting:
522                self.spawning.cancel()
523            try:
524                process = await self._spawn()
525            except (Exception, asyncio.CancelledError) as error:
526                self.termination = albedo_proc.Termination(
527                    None, (), True, (f"spawn failed: {error}",)
528                )
529                release(self)
530                if self.task.done():
531                    send(
532                        {
533                            "type": "job",
534                            "id": self.id,
535                            "exit_code": None,
536                            "timed_out": False,
537                            "cleanup": self.termination.as_json(),
538                        }
539                    )
540                return self.termination
541        if self.ending is None or self.ending.done():
542            group = self.claim()
543            self.ending = loop.create_task(
544                albedo_proc.end(group, exited) if group is not None else _unstarted()
545            )
546        ending = await asyncio.shield(self.ending)
547        self.termination = ending
548        release(self)
549        if self.task.done():
550            send(
551                {
552                    "type": "job",
553                    "id": self.id,
554                    "exit_code": process.returncode,
555                    "timed_out": self.timed_out,
556                    "cleanup": ending.as_json(),
557                }
558            )
559        return ending
560
561    def __repr__(self) -> str:
562        return (
563            f"Job(id={self.id!r}, exit_code={self.exit_code!r}, "
564            f"timed_out={self.timed_out!r}, duration={self.duration!r}, "
565            f"bytes={self.capture.seen})"
566        )
567
568
569async def _unstarted() -> albedo_proc.Termination:
570    return albedo_proc.Termination(None, (), True, ("process never started",))
571
572
573def release(job: Job) -> None:
574    """Retire active work only when its group is gone."""
575    if (
576        job.termination is None
577        or not job.termination.gone
578        or active.pop(job.id, None) is None
579    ):
580        return
581    retained.pop(job.id, None)
582    retained[job.id] = job
583    while len(retained) > RETAINED_LIMIT:
584        _, evicted = retained.popitem(last=False)
585        if jobs.get(evicted.id) is evicted:
586            del jobs[evicted.id]
587
588
589def forget(job: Job) -> None:
590    """Drop a finished job a plugin ran for its own purposes: it leaves `jobs`
591    and the retained output channels, so internal work never shows up beside,
592    or crowds out, the model's own jobs and cells."""
593    if jobs.get(job.id) is job:
594        del jobs[job.id]
595    retained.pop(job.id, None)
596    if forget_output is not None:
597        forget_output(job.id)
598
599
600def owe(job: Job) -> None:
601    """Queue a finished job's wake. One announcer carries every owed result,
602    so jobs that finish during a turn wake the session once, not once each."""
603    global announcer
604    owed[job.id] = job
605    if announcer is None or announcer.done():
606        announcer = loop.create_task(_announce())
607
608
609async def _announce() -> None:
610    """Wake the session for every owed result, retrying while it runs.
611
612    A busy session answers with a refusal rather than queueing, so the notice
613    retries until the run ends, naming whatever is still owed then; a read or
614    await in the meantime retires that job, which is also the creating-cell
615    barrier: a cell that will await its own job suppresses the wake before any
616    turn can start.
617    """
618    while True:
619        for job in [job for job in owed.values() if not job._unreported]:
620            del owed[job.id]
621        notify = host
622        if not owed or notify is None:
623            return
624        batch = list(owed.values())
625        try:
626            await notify("jobs.completed", notice([job._facts() for job in batch]))
627        except Exception as error:
628            if getattr(error, "code", "") == "busy":
629                await asyncio.sleep(NOTICE_RETRY)
630                continue
631            for job in batch:
632                job.capture.write(f"\n[completion notice not delivered: {error}\n")
633        for job in batch:
634            owed.pop(job.id, None)
635
636
637# A job on a remote.connect() host lives in that kernel's `jobs`, not the
638# model's, so its notice names the reference rem.run returned instead.
639REMOTE_READING = (
640    " a job on another host is read with .tail() on the reference rem.run"
641    " returned, or await rem.output.read(id) on that connection."
642)
643
644
645def _name(job: dict[str, object]) -> str:
646    """How a notice names a job: its `jobs` entry here, or a remote job's id."""
647    return repr(job["id"]) if job["host"] else f"jobs[{job['id']!r}]"
648
649
650def notice(facts: list[dict[str, object]]) -> dict[str, object]:
651    """The wake turn's display text, model text, and the facts behind both."""
652    advice = (
653        " no user sent this message; use the result if the session's work needs"
654        " it, otherwise acknowledge briefly and stay idle.</system-note>"
655    )
656    if len(facts) == 1:
657        [job] = facts
658        reading = (
659            " its handle is the reference rem.run returned; its .tail() or"
660            f" await rem.output.read({job['id']!r}) on that connection reads its output."
661            if job["host"]
662            else f" its handle is jobs[{job['id']!r}] in python; jobs[{job['id']!r}].tail() or"
663            f" output.read({job['id']!r}) reads its output."
664        )
665        text = (
666            "<system-note>a background job finished with its result unread"
667            f"{job['where']}: {job['summary']}, command: {job['command']}."
668            + reading
669            + advice
670        )
671    else:
672        listed = "; ".join(
673            f"{_name(job)}{job['where']}: {job['summary']}, command: {job['command']}"
674            for job in facts
675        )
676        remote = any(job["host"] for job in facts)
677        text = (
678            f"<system-note>{len(facts)} background jobs finished with their results"
679            f" unread: {listed}. jobs[id].tail() or output.read(id) reads one's"
680            " output." + (REMOTE_READING if remote else "") + advice
681        )
682    display = "\n".join(
683        f"job finished{job['where']} ({job['summary']}): {job['command']}"
684        for job in facts
685    )
686    return {"display": display, "text": text, "jobs": facts}
687
688
689def run(
690    program: object,
691    *args: object,
692    cwd: str | os.PathLike[str] | None = None,
693    env: dict[str, object] | None = None,
694    stdin: Data | Job | None = None,
695    timeout: float = 300,
696    service: bool = False,
697) -> Job:
698    """Start one program, without a shell, and return its handle immediately:
699    run("go", "test", "./...", cwd="cli"). It starts at once, at low priority.
700    The timeout, in seconds and at most a day, counts wall time after the
701    program starts.
702
703    `env` adds to the kernel's environment. `stdin` is text or bytes to feed
704    the program, a path to read, or another job, whose output streams in like
705    a shell pipe (job.pipe(...) says the same). stderr joins stdout.
706
707    Output stays on the handle: job.tail(lines=20) and job.head(lines=20) for
708    its ends, output.read(job.id) for the retained whole. Jobs survive cell
709    completion and each owns its process group. Only unfinished work counts
710    against the concurrent quota; finished handles stay usable in `jobs` until
711    newer ones displace them.
712
713    A job that finishes with its result unread wakes the session by itself, so
714    polling or trailing a handle is optional: leave it in a variable, end the
715    cell, and the wake names the job when it lands. Awaiting the job, reading
716    its result, or stopping it first means no wake.
717
718    `nix` never copies a jj workspace into the store (a flake there is read
719    from its commit), and `nix develop -c program ...` reuses a cached dev
720    shell. `cargo` runs through mbx when it is installed, so every checkout
721    shares compiled work; env={"ALBEDO_NO_MBX": "1"} runs plain cargo.
722
723    Pass service=True for a program that is meant to keep running (a dev
724    server, a file watcher): you are not waiting for it to finish, so it
725    does not keep your prompt cache warm while you are idle.
726    """
727    check_timeout(timeout)
728    if not (stdin is None or isinstance(stdin, (str, bytes, os.PathLike, Job))):
729        raise TypeError(
730            f"stdin is text, bytes, a path, or a job on this kernel, not {type(stdin).__name__}"
731        )
732    argv = albedo_shell.words((program, *args))
733    script = albedo_shell.shell_script(argv)
734    if script is not None:
735        raise albedo_shell.refusal(f"`{os.path.basename(argv[0])} -c`", script=script)
736    environment = albedo_shims.prepend(
737        None
738        if env is None
739        else {**os.environ, **{str(k): str(v) for k, v in env.items()}}
740    )
741    directory = None if cwd is None else os.fsdecode(cwd)
742    if os.sep not in argv[0]:
743        found = shutil.which(argv[0], path=(environment or os.environ).get("PATH"))
744        if found is None:
745            if any(char.isspace() for char in argv[0]):
746                raise albedo_shell.refusal(
747                    "a whole command line as the program", script=argv[0]
748                )
749            raise FileNotFoundError(f"{argv[0]}: no such program on PATH")
750    return start(
751        argv, timeout, cwd=directory, env=environment, stdin=stdin, service=service
752    )
753
754
755def start(
756    argv: list[str],
757    timeout: float,
758    *,
759    cwd: str | None = None,
760    env: dict[str, str] | None = None,
761    stdin: Data | Job | None = None,
762    traced: bool = True,
763    service: bool = False,
764) -> Job:
765    """A job for argv as given, for run() and for plugins' own supervised work."""
766    if len(active) >= ACTIVE_LIMIT:
767        raise RuntimeError(f"{ACTIVE_LIMIT} jobs are active; await or stop one first")
768    return Job(
769        argv, timeout, cwd=cwd, env=env, stdin=stdin, traced=traced, service=service
770    )
771
772
773async def close() -> None:
774    """Kernel shutdown: end every live group inside one shared deadline."""
775    owned = list(active.values())
776    # Never spawn a pending command just to shut it down. In-flight OS spawns,
777    # however, must finish transferring ownership before the shared ladder.
778    for job in owned:
779        if not job._starting:
780            job.spawning.cancel()
781    await asyncio.gather(
782        *(job._spawn() for job in owned if job.process is None), return_exceptions=True
783    )
784    live = [(job, group) for job in owned if (group := job.claim()) is not None]
785    if not live:
786        return
787    endings = await albedo_proc.terminate(
788        [group for _, group in live], SHUTDOWN_TERM, SHUTDOWN_KILL
789    )
790    for (job, _), ending in zip(live, endings):
791        job.termination = ending
792        release(job)
793    failures = [ending.report() for ending in endings if not ending.gone]
794    if failures:
795        send({"type": "cleanup", "module": "run", "failures": failures})
796
797
798def setup(api: PythonApi) -> dict[str, object]:
799    global loop, capture_factory, preview_limit, jobs, active, retained, send
800    global host, watch_output, forget_output
801    loop, capture_factory, preview_limit, send = (
802        api.loop,
803        api.capture,
804        api.preview,
805        api.send,
806    )
807    host, watch_output, forget_output = api.host, api.watch_output, api.forget_output
808
809    async def stop_local_job(job_id: str) -> dict[str, object] | None:
810        job = jobs.get(job_id)
811        if job is None:
812            return None
813        await job.stop()
814        return {"ok": True}
815
816    jobs, active, retained = {}, {}, OrderedDict()
817    api.background_handle(Job)
818    api.on_shutdown(close)
819    if api.on_stop_job is not None:
820        api.on_stop_job(stop_local_job)
821    return {"run": run, "jobs": jobs}