okami.mom/albedo

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

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

main
37.4 KiB602 linesraw

the detached kernel

a session's python kernel (priv/python/albedo_kernel.py, which runs albedo_cells.py as __main__ so its bytecode comes from the cache) is its own process, detached from the daemon: it outlives a dropped connection and a daemon restart, keeping its namespace and its background jobs. the daemon connects to it directly, or through a bridge when it runs on another host, and every message between the two travels through a small session layer that neither loses nor repeats a message across a reconnect.

host calls

albedo_python.erl decodes the Python call frame once and passes its method and native arguments to host.Route. Routes return Result(Json, #(code, message)); rpc.handle dispatches by namespace and rpc.reply encodes the answer once. The port owner embeds that answer in the reply frame, encoding the call id separately. Host failure and restart replies use the same rpc.reply shape. A frame without a string method or an args field is refused with invalid / invalid host request; argument validation stays in each route. The Python JSON boundary is unchanged.

tool bindings

Cells also see the common standard-library modules (asyncio base64 collections datetime functools hashlib itertools json math os re shutil sys textwrap time, and Path) without importing them; these live in the same session-private builtins, so they are not saved as user variables. import albedo and from albedo import files answer the injected bindings (albedo_cells.Bindings).

A cell may not block the kernel with time.sleep of a second or more: albedo_shell refuses it through the same audit hook that refuses raw subprocess calls, and says to start the work with run() and do other useful work while it runs, and when nothing else is left to give the user a status report first, then wait with await asyncio.sleep(n). Job timeouts are seconds, at most a day (albedo_api.check_timeout): a job outlives the cell that started it, so it is not held to the cell's hour.

The kernel keeps injected helpers in cell globals and in a session-private copy of Python's builtins. An assignment such as files = [...] shadows the helper; del files reveals the original binding again, including inside functions and later cells. The process-wide builtins module is unchanged. Plugin instances are owned by this kernel's API, not module-level singletons. This fallback is built once after plugins load and is not serialized with user state.

spawned browsers

priv/python/albedo_plugins/browser/launch.py owns browser discovery and spawned processes. browser.spawn() uses a temporary profile and headless mode by default; await b.close() stops the owned process and removes that profile. Connecting to an external browser never takes ownership of its process.

Automatic headless launches prefer chrome-headless-shell on PATH, then $ALBEDO_HOME/browsers/chrome-headless-shell (~/.albedo by default), before regular Chrome/Chromium. The Nix dev shell and packaged daemon put the shell on PATH, using nixpkgs' pinned Chrome for Testing binary without the Playwright runtime. Full Chrome's headless launches on macOS can leave duplicate recent-app Dock tiles even after they exit. Outside Nix, install the official Chrome for Testing shell with its support files and symlink its executable at that home path; discovery resolves the symlink so the shell can find its resources. No browser is downloaded by spawn().

executable=, ALBEDO_BROWSER_EXECUTABLE, PRIME_BROWSER_EXECUTABLE, and CHROME_PATH still override discovery. headless=False skips automatic shell selection. An absent shell falls back to Chrome/Chromium; a selected browser's startup failure is reported, not retried with another executable. Dock settings and the user's normal Chrome profile are never changed.

background job observations

GET /sessions/{id} exposes kernel.live_job_count and kernel.running_jobs without starting a kernel. Unknown observations are null. The list contains up to 100 live jobs ordered by ID, with id, nullable pid, command (up to 4096 scalars), and started_at: wall-clock milliseconds when the port owner recorded the job's group, zero when unknown (a restored link older than the field). Jobs the kernel's remote connections run (remote.connect(...).run) are listed beside the local ones, with no pid (they own no group on this machine) and the wall-clock time the connection first saw them. The remote plugin reports them as {"type": "jobs", "live": n, "awaited": m, "items": [{id, command, service, started}]}, m the ones not started as services; the port owner keeps the latest frame (and a reattach's hello carries it as external_jobs). A kernel from before items reports counts only, and the count then exceeds the list by the awaited ones it could not name. kernel.awaited_jobs is the one definition of which listed jobs will wake the session (not service); awaits_jobs (the cache warmer, resumed kernels) and the session's still-running summary both ask it, so an extension never reads service itself. Stopping a remote job through /jobs sends a job_stop frame down the bridge to the local kernel ({"type": "job_stop", "id": "<job id>"}); if the kernel is unreachable (port := none), stopping fails immediately without queueing. The local kernel forwards the stop to the remote kernel (or, for degraded fallback ssh jobs, stops the job by killing the local ssh client). The port owner waits up to 15 seconds for a confirmation frame ({"type": "job_stop", "id": "<job id>", "ok": true|false}); if unconfirmed after 15 seconds, the stop fails. The TUI uses these observations for its idle-job status, animation, and sidebar, and calls a job background only once it has run three seconds (jobGrace), so work a cell starts and awaits never flashes through the chrome; /jobs lists every live job regardless of age. The chat reads these every 750 ms while anything is live (a turn, a job, a booting or reattaching kernel) and every 5 s otherwise; a live stream event wakes a resting poll. The Erlang port owner truncates commands through text_scalars.take, which scans only the retained prefix and copies it. Do not convert the entire command to a codepoint list to take a bounded preview; see runtime.

/jobs reads GET /extensions/run/sessions/{id}/jobs, whose page declares the stop action. POST /extensions/run/sessions/{id}/jobs/{job_id}/stop supervises that job's recorded process group and reports failure if its stop is unconfirmed. A missing job returns 404. After response loss, read the collection before confirming another attempt; stopping is never automatically retried. Job rows carry no resource; the stop action binds the row id.

A job that finishes with its result unread wakes the session. The model reads the full notice with the job's handle; the transcript keeps the wake's one-line display (job finished (exit_code=0, ran 1.2s): …) as a note from job.

retained output

Every cell and job owns one output channel (albedo_capture.Capture), kept for the 16 newest cells and 64 newest jobs plus native. A channel holds the first 1 MiB as written and, once more than that has arrived, the last 64 KiB in a separate tail buffer that exists only from then on; seen counts everything. Job and remote output enter as bytes (write_bytes), never decoded and re-encoded, so a UTF-8 character split across two pipe reads survives and a late job.pipe() replays the exact bytes while seen still fits in the retained start. Text readers (output.read, tail(), previews) decode a window on demand. output.read returns at most 64 KiB per call and raises for a larger limit rather than shortening it, since a caller that asked for everything and got a silent prefix builds files from it; the rest is paged with offset. A single print longer than what could be retained is not encoded whole when it is ASCII: the buffers see both ends and the middle only counts (unless a spill file takes it, below).

job output

priv/python/albedo_output.py owns JobOutput, shared by local jobs and SSH command-mode jobs. head(n) and tail(n) are previews: up to 64 KiB, with lines= taking precedence. Zero returns empty. An out-of-range request or a character/line request the retained window cannot satisfy raises with job.read() / job.save(path) as the recovery, never silently clamps. A line cut at a window boundary is not counted as a complete line.

After completion, job.read() returns complete UTF-8 text up to 1 MiB; larger whole-text requests raise with the pagination call to bound allocations and remote result frames. job.read(offset=..., limit=...) reads byte-offset pages up to 64 KiB, including while running, and transparently reads spill files. output.read(id, offset=..., limit=...) uses the same disk-backed paging for jobs and cells. Non-UTF-8 bytes are ignored at page boundaries, as in existing previews; whole text reads decode with replacement.

job.save(path) streams the exact bytes into a temporary file in 64 KiB chunks, atomically replaces the destination, and returns its absolute path. It never loads the whole spill file into memory. Running jobs, output lost past the disk cap, or unavailable spill files refuse rather than saving partial output; failed saves leave the destination untouched and remove the temporary file. Pages crossing lost output also refuse instead of returning a short page or false EOF. The error names the retained prefix and its pagination call. For artifacts beyond the disk cap, attach .pipe("tee", path) before awaiting; a late pipe cannot recover dropped bytes.

remote.connect() mirrors the same 64 KiB tail window, coalesced at the existing interval. Larger reads and saves use the ordinary remote method transport. Remote spill files and saves live on the remote host; degraded SSH command-mode jobs spill and save locally, where their capture lives.

spilled output

albedo_capture.Capture owns spilling and byte reads. Cells spill past the 64 KiB preview, jobs past the 1 MiB in-memory prefix. Both retain up to SPILL_LIMIT (16 MiB) on disk. The first overflowing write opens a private spill file with all preceding bytes; subsequent writes append without buffering, so pages can read live output. Job completion closes the writer. Memory still holds only the first 1 MiB and rolling 64 KiB tail, irrespective of disk output size.

Cell results name their spill file, seen, and whether all output is retained (Capture.spill_note). The directory sits beside the run directories: $ALBEDO_HOME/output locally, ~/.albedo-remote/output remotely, and albedo-output in the temp dir for a kernel over stdio. Spill files survive output channel eviction and are pruned after SPILL_KEEP (14 days) on kernel boot. A spill creation or write failure does not interrupt the command: in-memory previews remain available, but pages beyond the retained prefix and whole-output saves refuse. Missing or shortened spill files raise visibly on reads and saves.

memory cap

The kernel's physical footprint (albedo_memory.footprint: the resident set from /proc/self/statm, or Darwin's ri_phys_footprint through libproc, the figure vmmap and Activity Monitor show) is capped at ALBEDO_KERNEL_MEMORY_BYTES, 256 MiB by default, which the daemon passes through to local kernels. A thread samples it every 100 ms while cells run and interrupts, with reason memory, every running cell whose threshold the footprint passed; the cell ends interrupted with a note naming what is held and the cap, and the namespace stays. A cell's threshold is the cap, or 8 MiB over the footprint it started at when the kernel was already past the cap (a large variable left behind), so del big; gc.collect() runs instead of being interrupted at once. It is a sampled guard, not an allocator limit: one allocation larger than the cap succeeds and is caught by the next sample, and a cell that catches KeyboardInterrupt is told once. Jobs are separate processes and are not counted.

Finished results are kept by cell id (the newest 16) for execute replays. Their base64 image text is capped at 8 MiB across all of them, newest first; an older result past the budget replays without its images and says so in its output.

background cells

The kernel returns a python tool result with status=backgrounded after 60 seconds (ALBEDO_CELL_BACKGROUND_SECONDS overrides the threshold, in seconds). The result includes output so far, the cell id, and instructions to do useful work or report status and end the turn, without polling or sleeping. This is not a final outcome: the journal stays started until cells.finish records it. cells.info/read/trace remain available; traces appear when execution finishes. The original timeout_ms is carried to the kernel and still interrupts the cell at its deadline, even after the daemon's original execute wait has ended.

New cells can run while a detached cell awaits. They share the namespace, so avoid modifying variables the detached cell uses. Python output, images, and activity traces use the task's context; native fd output goes to native when multiple cells run, since it cannot be attributed safely. A synchronous cell can return its tool result early, but other cells must wait until it yields or finishes. Background and deadline timers run outside the asyncio loop so a synchronous cell cannot prevent its deadline from firing.

At most 16 cells run concurrently. Running captures are protected from eviction; completed output rolls out under the existing 16-capture limit. await cells.cancel(id) requests cancellation and suppresses that cell's wake. Explicit kernel reset/replacement ends its cells; a daemon restart merely reattaches. Internal cells count frames, replayed in the hello, keep idle reaping and automatic stale-kernel replacement from ending a running cell without pretending the cells are process jobs in the public API.

Completion is committed before cells.completed sends a wake. The transcript gets a one-line cell finished (status=ok, ran 90.0s): <first code line> from cell, while the model gets the full notice with the cell id separately. Unread completions share a wake and retry every two seconds while the session is busy; there is no model polling. A wake that parks while the kernel is being replaced starts its turn on the replacement, together with the upgrade notice. The final expression is appended to retained output under [result], so output.read(id) can read it after a background completion. Reading finished output, source, status, or trace retires a pending wake. Reading partial output while the cell still runs does not retire its eventual completion. A read racing with the completion journal also retires the wake.

processes

  • kernel: started in its own session and process group, stdio not tied to anyone. it listens on kernel.sock in its run directory, $ALBEDO_HOME/run/<kernel id>/ (0700; the socket is bound relative to the directory so a long temp home stays under the ~104-byte sun_path limit). kernel.log there catches what it writes before its own output capture starts.
  • relay (in albedo_python.erl): how the port owner reaches a local kernel. an erlang process owns a gen_tcp connection to kernel.sock ({packet, 4}) and passes frames to the port owner as a bridge port would, so no python process stays beside a local kernel. a fresh boot first runs albedo_bridge.py launch <run_dir> <modules-json> (token as its one stdin line), which starts the kernel and exits once the socket answers. the relay announces nothing, so the port owner hashes the bundle itself, the same way albedo_bundle.digest() does. a refused or missing socket reads as exit 3. a run directory whose socket path does not fit sun_path (104 bytes) keeps the bridge.
  • bridge (priv/python/albedo_bridge.py): what the daemon's erlang port runs for a remote kernel (the same argv over ssh, see remote kernels below) or a run directory too deep for the relay, with the same {packet, 4} stdio framing the kernel used to have. start <run_dir> <modules-json> starts the kernel beside it (the token goes over the kernel's stdin, never argv) and waits for its socket; attach <run_dir> connects to a running one. it announces its own bundle hash ({"bridge": {"bundle"}}), then copies bytes both ways until either side closes. exit status 3 means no kernel is there. killing it never touches the kernel.
  • port owner (albedo_python.erl): one erlang process per kernel, as before. it owns the job groups the kernel reports and the albedo_signal.py termination ladder, aimed at the pid/pgid/leader the kernel declares in its hello.

a kernel run with plain albedo_kernel.py <modules> (no --run) keeps the old raw stdio protocol and dies with its pipe: the remote plugin's targets use that.

handshake

the daemon writes {"attach": {"kernel", "token", "ack", "grace"}} first. the kernel checks the token (kept in the daemon's sqlite), bumps its epoch, cuts off any older connection (newest attach wins), and answers {"hello": {protocol, bundle, epoch, ack, pid, pgid, leader, ready, jobs, external, awaited_external, external_jobs, dropped}}, or {"refused": reason}. protocol (1) and the hello reports the running cells count. bundle is albedo_bundle.digest(), the same content hash the remote plugin stages under; a hello whose bundle or protocol differs from the bridge's (or, for a local kernel, the port owner's own hash) makes the kernel stale (see version skew). jobs (live job_start frames) and external let a fresh port owner take over job ownership and live-job observations.

session layer

after the handshake every frame is {"seq": n, "ack": m, "frame": {...}}, or a bare {"ack": m}. ack is cumulative: everything up to it arrived.

  • kernel side (priv/python/albedo_link.py): an in-memory outbox of unacknowledged frames, resent after each hello. mirror (per handle), trace (per cell) and jobs coalesce latest-wins; everything else must be delivered. bounded at 4096 frames / 64 MiB: coalescing frames go first, then the oldest, counted in the next hello's dropped. a daemon frame at or below the last seq applied is a replay and is dropped; each applied frame is acknowledged at once.
  • daemon side (python/link.gleam, tables from the python extension's kernel_links schema migration): kernel_links (one row per session: kernel id, token, run dir, workspace, modules, pid/pgid/leader, epoch, last seq sent, and the job groups it owns, rewritten as they change), kernel_outbox keyed (session, kernel, seq), bounded at 2048 frames / 64 MiB per kernel, and kernel_calls. the port owner persists each frame before writing it, deletes what the kernel acknowledges, and acknowledges kernel frames within 100 ms. its own last-seen seq lives in memory: after a daemon restart it accepts everything the kernel resends, and the dedupe rules below make that safe.

dedupe:

  • host call id: a call already running in this daemon is ignored (its reply goes out through the outbox); one answered already is ignored the same way; one an earlier daemon started without answering is answered {ok: false, code: "unknown"}, never run again. a call's row goes once the kernel acknowledges its reply.
  • execute, by cell id: a finished cell (the newest 16) resends its done; a queued or running one is ignored. callers must not reuse cell ids.
  • interrupt, reply, release are idempotent already. an interrupt for a cell still queued behind the loop is kept and cancels the cell as it starts; one for a cell the kernel no longer holds is dropped.
  • job_stop: the port owner asks the local kernel to stop a remote background job with {"type": "job_stop", "id": "<job id>"}. The kernel answers with {"type": "job_stop", "id": "<job id>", "ok": true} or {"type": "job_stop", "id": "<job id>", "ok": false, "error": "<lowercase why>"} exactly once per request. The port owner does not queue while the kernel is unreachable (port := none), and gives up with an unconfirmed error after 15 s. The request is delivered like any other frame, so one sent while the kernel was briefly out of reach can still land after a reattach and end the job later: "unconfirmed" means the outcome is unknown, not that the stop failed.
  • what the kernel resends after a restart: job wakes are host calls (jobs.completed), so the call ledger covers them; job_start, job, and jobs only update ownership and observations, which converge; a done nobody waits for rewrites the same outcome; traces save by cell id.

the runtime scheduler

harness/runtime/boot.gleam owns every worker the runtime actor starts. Booting, attaching, swapping a stale kernel at open, an explicit upgrade, recomposing for a reload (proposed or desired), and a composition read all queue as a BootRequest and run through boot_next, which admits them into boot_slots workers, one class never starving the other. A handler that queues a request ends in |> boot.boot_next in serve; nothing spawns a kernel worker outside start. Each worker computes off the actor, then delivers its message or, when the runtime stopped meanwhile, releases what it made (work). runtime/state names the generation lifecycle a request belongs to: admit opens one, current_waiters says whether a result is still for it, generation_over ends it and replays what waited.

drops

  • the connection drops (the relay's socket closes, or the bridge dies): the port owner reattaches with backoff (50 ms doubling to 2 s, 40 tries); exit 3 or a refusal means the kernel is gone and the old abandon path reaps it. a port owner attaching after a restart starts from the record's pid/pgid/leader and groups, so a kernel that died hard while nobody was attached still has its process group and its jobs' groups ended. the leader token (start time, from /proc or Darwin's sysctl) keeps a reused pid from being signalled. the cell keeps running meanwhile.
  • the cell deadline passes, or an interrupt goes unanswered for 2 s, while detached: the interrupt waits in the outbox and the caller gets kernel.Detached, which leaves the journaled cell started (the model is told to check cells.info). a done that arrives for a cell nobody waits for any more is journaled through the cells.finish host route.
  • the daemon crashes: its connection closes (a bridge sees EOF and exits); the kernel waits.
  • graceful shutdown: runtime.detach_kernels first, so closing sessions and stopping the runtime let kernels go instead of ending them.
  • daemon start: runtime.resume_kernels reattaches every recorded kernel in the background, one at a time through the boot slots, so late results and job wakes arrive without waiting for the session. no actor is loaded for an idle session yet, so a wake finds no registered closure: albedo_wakes then calls the loader the registry installs (on_missing), which starts the session as a Lookup would, and delivers to it; only a session that cannot start answers unavailable, which the kernel does not retry. a session whose kernel came back running a job that is not a service is started at once, idle, so its cache warming resumes (robot-docs/cache-warming.md). a session that asks for its kernel meanwhile waits for that attach, and gets a fresh boot if there was nothing to attach to. an attached kernel's runtime.origin is Resumed, and the session keeps its namespace without the disk restore. a recorded kernel whose module set no longer fits is kept and marked stale; one in another workspace is stopped and replaced. when jobs or background cells are still running, the session's first turn on a Resumed kernel carries a model note that the kernel reattached with its namespace, naming those jobs (id and command) and cells (id and first line), so a live handle is not mistaken for one the restart killed (with nothing running, only the UI note appears). an origin is handed out once: later opens of the same kernel get Kept, so no notice repeats and no live cell is journaled as ended. the note is built from the port owner's observation and the cells journal, never from a new kernel frame, so a reattached kernel on an older bundle still gets it.

explicit drops still end the kernel at once: reset, release by the idle sweep, /cd, session close, extension reloads (the replacement boots beside the live kernel, never attached to it), and the old kernel after a swap.

version skew

a kernel is stale when it runs another bundle than the bridge that reached it (for a local kernel, the bundle the port owner hashed), speaks another protocol (both read from the hello), or booted with another module set than its session now has (found at reattach). kernel.stale answers the reason and whether the swap was forced. GET /sessions/{id} returns the native kernel observation with instance_id, build, state, stage, stale, and live_job_count. Unknown identities and counts are null. Reading this resource does not prepare or replace a kernel. stage reports remote bundle staging while the owner is booting.

At the next idle moment, session_namespace.ready lets the runtime replace a stale kernel when no live jobs keep it. The runtime does the work off its actor:

  1. Snapshot the old namespace to namespace.state in its run directory.
  2. Boot a replacement beside it. The replacement's identities, groups and outbox belong to kernel_stages; the old kernel_links row stays authoritative.
  3. Check that the replacement is attached and current, then restore its namespace.
  4. Mark the staged replacement ready, stop the old kernel and its jobs, and confirm that the old durable ownership record was removed.
  5. Publish the replacement in kernel_links and adopt its actual handle.

Boot, validation or restore failure stops the staged candidate and keeps the old kernel. A failed old-kernel stop reports the actual failure and attempts to stop the staged candidate. Shutdown may already have affected jobs; the report cannot promise to restore those external effects. Failed cleanup retains the relevant durable identities for a later verified stop. No failed stop is reported as a successful swap.

On restart, storage publishes a ready candidate if its old link is absent. The daemon then supervises abandoned staged candidates before admitting work. Staged owners use their immutable kernel ID throughout publication, so replies and ownership updates continue to reach the same namespace.

The adopted kernel has origin Upgraded. The next model input names the carried variables and definitions, the jobs the swap stopped, and the cells it ended, and says retained output from before the upgrade is gone while the cells journal keeps every cell's source and recorded result; the transcript records the upgrade. A swap ends the old kernel, so cells it was still running can never deliver their own result: they are journaled interrupted (a forced /kernel upgrade or a protocol-skew swap can end them; the automatic swap waits, because a kernel running a synchronous cell answers Busy to the snapshot and upgradable counts running cells). A background upgrade that cannot proceed keeps a usable old kernel and tries at a later idle moment. A lost old kernel is reported as lost.

The idle moment a stale kernel waits for is a real caller: a turn, a wake, a compaction. Nothing parks at a turn's end just because the queue is empty, and an arriving kernel nobody is waiting for is adopted when the held one is gone — its notice still reaches the model — rather than dropped silently. A pending cell-completion wake is such a caller: it parks while the kernel is unavailable, the swap runs under it, and its turn starts on the replacement carrying the upgrade notice, so the wake is never lost to the swap.

Live jobs keep an older bundle or module set until they end. POST /sessions/{id}/kernel/upgrade is an explicit idle-session action that can stop those jobs. Its response waits for the session to adopt the replacement and reports actual old/new identities and builds, observed stopped job IDs, warnings, and failure. An empty unattached session remains unchanged. There is no automatic retry after a lost HTTP response; read the session kernel first. /kernel upgrade uses this same operation, and /kernel reads the observation.

a kernel booted now that is already stale says this daemon and its own python bundle disagree (a protocol bumped on one side only, a partial install): its boot fails with that reason (albedo_python:out_of_step/1), so a turn waits blocked on it instead of swapping one fresh kernel for another forever.

protocol skew does not wait for jobs: a kernel on another protocol cannot be supervised. its outbox is dropped, not replayed; the swap asks for a snapshot and a shutdown through the frozen frames, carries nothing if the snapshot fails, and the stop's signal ladder ends the recorded process group either way.

lifetime

a kernel with nothing attached for its grace period reaps its job groups (plugin cleanup) and exits, unless a job or cell is still live. the grace is ALBEDO_KERNEL_GRACE_SECONDS read by the daemon (default 3600) and sent in every attach; before its first attach a kernel waits 60 s. a kernel the daemon reattached at start is attached, so the grace never runs out for it: the maintenance sweep saves and stops one whose session has no loaded actor, no live job or cell, and no turn within ALBEDO_IDLE_SECONDS. a kernel also exits within a second of its run directory disappearing, which is how test homes clean up after themselves. the e2e harness sets a 30 s grace, the gleam test home 20 s.

remote kernels

a session whose workspace is [user@]host:/path (workspaces.md) runs its kernel on that host. nothing above the bridge changes: the outbox, reattach with backoff and resume_kernels treat an ssh drop as a bridge that exited.

  • one ssh layer: priv/python/albedo_ssh.py builds the argv (BatchMode, ConnectTimeout=10, StrictHostKeyChecking=accept-new, ControlMaster=auto, a ControlPath and ControlPersist), finds an agent socket, wraps commands in the remote login shell and stages the bundle. the model's remote plugin imports it, so the daemon and remote.connect() share masters and the staged bundle.
  • aliases: albedo_ssh.alias(host, ssh_options, environment) names a host this kernel process reaches without ~/.ssh/config: its -o values go before albedo's defaults (ssh keeps the first value it is given) and its environment reaches ssh and the ProxyCommand it spawns, which is how a token stays out of every command line. it is per process, so the daemon's own remote kernels never see one. colab.md uses it.
  • the user's masters: when the user's ssh config names a ControlPath for the host (ssh -G, which reads no network), albedo uses that path and its ControlPersist (600 when it says no), so a master the user opened in a terminal carries albedo, and the master albedo opened carries the user's own ssh, colmena and git in run jobs: one sign-in (one hardware-key touch) per host. with none configured it is /tmp/albedo-ssh-cm/%C for 600 s.
  • clean stdout: every remote command is /bin/sh -c 'exec 3>&1 1>&2; exec "$SHELL" -l -c "exec 1>&3 3>&-; <command>"', quoted with shlex in albedo_ssh.in_login_shell only. whatever the login profile prints (a motd, a greeting) goes to stderr, and the command gets the real stdout back, so the bridge's frames, the remote plugin's kernel and the probe's lines stay clean whatever the user's shell is.
  • fixed command lines: the probe (and, with no network, albedo_ssh.py commands <target> <home>) answers the complete remote commands the daemon runs: bridge (albedo_bridge.py --frame), signal (albedo_signal.py -) and remove. their inputs go on stdin, never in the command line, so nothing in erlang quotes for a shell: the bridge's first frame is {"bridge": {"argv": [start|attach, run_dir, modules?], "cwd": path}} (exit 4 when cwd is no folder), the ladder reads its request line, remove the run directory.
  • probe (albedo_ssh.py probe <target>, run by albedo_ssh.erl): one command over the master prints os, arch, home, cores, the python3 version and whether ~/.albedo-remote/<digest[:16]> is staged; python older than 3.11 is unsupported, a missing bundle is staged (tar over the same stream, unpacked beside the target and moved into place, so a bundle directory that exists is complete). ssh's own failure (exit 255) is needs_auth when its words say a person could get in (permission denied, host key verification, passphrase, keyboard-interactive), otherwise unreachable. answers are cached in the daemon, ready ones for a minute and failures for five seconds; a second caller joins a probe in flight. albedo/harness/ssh.gleam is the gleam face.
  • boot: kernel.place probes (or reuses the cached probe) before the bridge starts, so a warmed host boots fast. the port owner runs the bridge command over ssh. a fresh boot's exit 4 reads as "not a folder there", 255 as an ssh failure. run directories live under the remote home, ~/.albedo-remote/run/<kernel id>, recorded absolute.
  • a host out of reach is not a kernel gone: only an attach the bridge ends with exit 3 (nothing at the run directory) or a refused attach proves a remote kernel gone. ssh failing (255), any other exit, or a startup timeout while attaching is a dropped connection: the session gets the kernel at once, reattaching, and the port owner keeps attaching in the background, backing off to every 30 s, until the kernel's grace (plus a minute) has surely passed since it lost the connection; then it forgets the record quietly, without reaching for the host. when the probe itself fails at boot, a recorded kernel is attached with commands rebuilt from its run directory (which names the home), so a wifi blip during a daemon restart keeps the namespace. a turn in a session with a kernel on record goes through while its host is unreachable or needs a sign-in, waiting like any reattach. only a fresh boot needs the host now.
  • ownership: the pids a remote kernel declares are that host's. the albedo_signal.py ladder for its kernel and job groups runs there over ssh (the signal command, 15 s), never locally; if ssh can't reach the host the targets are left to the kernel's own grace exit, which reaps its jobs on the host, and the error says so. only the bridge (a local ssh client) is ever signalled here. the run directory goes with rm -rf over ssh. os_pid answers nothing for a remote kernel, so the reaper's ps never reads an unrelated local pid.
  • turns: a session that holds a kernel (attached, or reattaching through its outbox) never probes on a turn. one that must boot probes for up to 3 s: still warming is left to the boot, which waits for the probe; needs_auth, unreachable or unsupported leave the turn admitted and waiting, blocked with the probe's words as its receipt's blockingReason, retried every 15 s like any blocked input (operations.md).
  • the model's remote calls (albedo_plugins/remote.py on the owner side, serve_invoke in the remote kernel): one call per frame, answered by one reference. a result that can cross is inlined; anything else is kept live (LIVE, 64 entries, identity-stable handles) so methods call the real object there. awaiting a reference sends one await frame: the remote side awaits the object only when it is awaitable (a job); a settled non-awaitable object -- a spawned browser handle -- is the answer itself, back as the same reference, so an await can never strand a live process with no handle. arguments cross with the same markers as targets and values: {"__ref__": id} for a live reference, {"pending": id} for a call still in flight (resolved in order), {"__bytes__": b64} for byte data, decoded remotely by albedo_values, so rem.run(..., stdin=b"...") and byte-sized results work. rem.write takes text or bytes; either travels on the stdin of one ssh command, never in the command line.
  • the model gets a context line (python extension, place.gleam): "your python kernel and run jobs execute on chernobog (Linux aarch64)…", with the remote home.

Host probes

GET /hosts?target={host} reads cached SSH probe state. POST /hosts/{host}/probe starts or joins a bounded probe and returns 202. The client polls the read until it reaches a terminal state. Probe state includes safe diagnostics and authentication instructions when operator sign-in is needed. The same probe supports remote folder gathering. See the HTTP contract for the wire format and workspaces for folder browsing.

signing in

BatchMode stays on for the daemon. when a remote session's waiting turn is blocked, the chat asks GET /hosts?target={host}; if that says needs_auth and the daemon shares the tui's machine, the chat offers ctrl+l: it runs ssh -M -fN -o ControlPath=<control_path> -o ControlPersist=600 <host> through tea.ExecProcess, so ssh asks in the terminal, then warms the host, and the waiting turn starts on the daemon's next try; the daemon rides that master from then on. with a daemon elsewhere the notice says to run ssh <host> on its machine, which carries the daemon when the user's ssh config multiplexes that host. the offer goes once the turn starts or goes away, or the session moves.

not yet

  • profile output a remote login shell prints reaches the daemon's own stderr log on every remote command.
1# the detached kernel
2
3a session's python kernel (`priv/python/albedo_kernel.py`, which runs
4`albedo_cells.py` as `__main__` so its bytecode comes from the cache) is its own process,
5detached from the daemon: it outlives a dropped connection and a daemon
6restart, keeping its namespace and its background jobs. the daemon connects
7to it directly, or through a bridge when it runs on another host, and every message between the two travels through a small
8session layer that neither loses nor repeats a message across a reconnect.
9
10## host calls
11
12`albedo_python.erl` decodes the Python call frame once and passes its method
13and native arguments to `host.Route`. Routes return
14`Result(Json, #(code, message))`; `rpc.handle` dispatches by namespace and
15`rpc.reply` encodes the answer once. The port owner embeds that answer in the
16reply frame, encoding the call id separately. Host failure and restart replies
17use the same `rpc.reply` shape. A frame without a string method or an `args`
18field is refused with `invalid` / `invalid host request`; argument validation
19stays in each route. The Python JSON boundary is unchanged.
20
21## tool bindings
22
23Cells also see the common standard-library modules (`asyncio base64 collections datetime functools hashlib itertools json math os re shutil sys textwrap time`, and `Path`) without importing them; these live in the same session-private builtins, so they are not saved as user variables. `import albedo` and `from albedo import files` answer the injected bindings (`albedo_cells.Bindings`).
24
25A cell may not block the kernel with `time.sleep` of a second or more: `albedo_shell` refuses it through the same audit hook that refuses raw subprocess calls, and says to start the work with `run()` and do other useful work while it runs, and when nothing else is left to give the user a status report first, then wait with `await asyncio.sleep(n)`. Job timeouts are seconds, at most a day (`albedo_api.check_timeout`): a job outlives the cell that started it, so it is not held to the cell's hour.
26
27The kernel keeps injected helpers in cell globals and in a session-private copy
28of Python's builtins. An assignment such as `files = [...]` shadows the helper;
29`del files` reveals the original binding again, including inside functions and
30later cells. The process-wide `builtins` module is unchanged. Plugin instances
31are owned by this kernel's API, not module-level singletons. This fallback is
32built once after plugins load and is not serialized with user state.
33
34## spawned browsers
35
36`priv/python/albedo_plugins/browser/launch.py` owns browser discovery and spawned
37processes. `browser.spawn()` uses a temporary profile and headless mode by default;
38`await b.close()` stops the owned process and removes that profile. Connecting to
39an external browser never takes ownership of its process.
40
41Automatic headless launches prefer `chrome-headless-shell` on PATH,
42then `$ALBEDO_HOME/browsers/chrome-headless-shell` (`~/.albedo` by default), before
43regular Chrome/Chromium. The Nix dev shell and packaged daemon put the shell on
44PATH, using nixpkgs' pinned Chrome for Testing binary without the Playwright
45runtime. Full Chrome's headless launches on macOS can leave duplicate recent-app
46Dock tiles even after they exit. Outside Nix, install the official
47[Chrome for Testing shell](https://developer.chrome.com/docs/automation-and-testing/headless-chrome-shell)
48with its support files and symlink its executable at that home path; discovery
49resolves the symlink so the shell can find its resources. No browser is downloaded
50by `spawn()`.
51
52`executable=`, `ALBEDO_BROWSER_EXECUTABLE`, `PRIME_BROWSER_EXECUTABLE`, and
53`CHROME_PATH` still override discovery. `headless=False` skips automatic shell
54selection. An absent shell falls back to Chrome/Chromium; a selected browser's
55startup failure is reported, not retried with another executable. Dock settings
56and the user's normal Chrome profile are never changed.
57
58## background job observations
59
60`GET /sessions/{id}` exposes `kernel.live_job_count` and `kernel.running_jobs`
61without starting a kernel. Unknown observations are null. The list contains up
62to 100 live jobs ordered by ID, with `id`, nullable `pid`, `command` (up to
634096 scalars), and `started_at`: wall-clock milliseconds when the port owner
64recorded the job's group, zero when unknown (a restored link older than the
65field). Jobs the kernel's remote connections run (`remote.connect(...).run`)
66are listed beside the local ones, with no `pid` (they own no group on this
67machine) and the wall-clock time the connection first saw them. The remote
68plugin reports them as `{"type": "jobs", "live": n, "awaited": m, "items":
69[{id, command, service, started}]}`, `m` the ones not started as services; the
70port owner keeps the latest frame (and a reattach's hello carries it as
71`external_jobs`). A kernel from before `items` reports counts only, and the
72count then exceeds the list by the awaited ones it could not name.
73`kernel.awaited_jobs` is the one definition of which listed jobs will wake the
74session (not `service`); `awaits_jobs` (the cache warmer, resumed kernels) and
75the session's still-running summary both ask it, so an extension never reads
76`service` itself. Stopping a remote job through `/jobs` sends a `job_stop`
77frame down the bridge to the local kernel (`{"type": "job_stop", "id": "<job id>"}`);
78if the kernel is unreachable (`port := none`), stopping fails immediately
79without queueing. The local kernel forwards the stop to the remote kernel (or,
80for degraded fallback ssh jobs, stops the job by killing the local ssh client).
81The port owner waits up to 15 seconds for a confirmation frame (`{"type":
82"job_stop", "id": "<job id>", "ok": true|false}`); if unconfirmed after 15
83seconds, the stop fails.
84The TUI uses these observations for its idle-job status, animation, and
85sidebar, and calls a job background only once it has run three seconds
86(`jobGrace`), so work a cell starts and awaits never flashes through the
87chrome; `/jobs` lists every live job regardless of age. The chat reads these
88every 750 ms while anything is live (a turn, a job, a booting or reattaching
89kernel) and every 5 s otherwise; a live stream event wakes a resting poll.
90The Erlang port owner truncates commands through `text_scalars.take`, which
91scans only the retained prefix and copies it. Do not convert the entire command
92to a codepoint list to take a bounded preview; see [runtime](runtime.md#unicode-scalar-limits).
93
94`/jobs` reads `GET /extensions/run/sessions/{id}/jobs`, whose page declares the
95stop action. `POST /extensions/run/sessions/{id}/jobs/{job_id}/stop` supervises
96that job's recorded process group and reports failure if its stop is unconfirmed.
97A missing job returns 404. After response loss, read the collection before
98confirming another attempt; stopping is never automatically retried. Job rows
99carry no resource; the stop action binds the row id.
100
101A job that finishes with its result unread wakes the session. The model reads
102the full notice with the job's handle; the transcript keeps the wake's one-line
103display (`job finished (exit_code=0, ran 1.2s): …`) as a note from `job`.
104
105## retained output
106
107Every cell and job owns one output channel (`albedo_capture.Capture`), kept
108for the 16 newest cells and 64 newest jobs plus `native`. A channel holds the
109first 1 MiB as written and, once more than that has arrived, the last 64 KiB
110in a separate tail buffer that exists only from then on; `seen` counts
111everything. Job and remote output enter as bytes (`write_bytes`), never
112decoded and re-encoded, so a UTF-8 character split across two pipe reads
113survives and a late `job.pipe()` replays the exact bytes while `seen` still
114fits in the retained start. Text readers (`output.read`, `tail()`, previews)
115decode a window on demand. `output.read` returns at most 64 KiB per call and
116raises for a larger `limit` rather than shortening it, since a caller that
117asked for everything and got a silent prefix builds files from it; the rest
118is paged with `offset`. A single print longer than what could be retained
119is not encoded whole when it is ASCII: the buffers see both ends and the
120middle only counts (unless a spill file takes it, below).
121
122### job output
123
124`priv/python/albedo_output.py` owns `JobOutput`, shared by local jobs and
125SSH command-mode jobs. `head(n)` and `tail(n)` are previews: up to 64 KiB,
126with `lines=` taking precedence. Zero returns empty. An out-of-range request
127or a character/line request the retained window cannot satisfy raises with
128`job.read()` / `job.save(path)` as the recovery, never silently clamps.
129A line cut at a window boundary is not counted as a complete line.
130
131After completion, `job.read()` returns complete UTF-8 text up to 1 MiB;
132larger whole-text requests raise with the pagination call to bound allocations
133and remote result frames. `job.read(offset=..., limit=...)` reads byte-offset
134pages up to 64 KiB, including while running, and transparently reads spill files.
135`output.read(id, offset=..., limit=...)` uses the same disk-backed paging for jobs
136and cells. Non-UTF-8 bytes are ignored at page boundaries, as in existing previews;
137whole text reads decode with replacement.
138
139`job.save(path)` streams the exact bytes into a temporary file in 64 KiB chunks,
140atomically replaces the destination, and returns its absolute path. It never
141loads the whole spill file into memory. Running jobs, output lost past the disk
142cap, or unavailable spill files refuse rather than saving partial output; failed
143saves leave the destination untouched and remove the temporary file. Pages crossing
144lost output also refuse instead of returning a short page or false EOF. The error
145names the retained prefix and its pagination call. For artifacts beyond the disk
146cap, attach `.pipe("tee", path)` before awaiting; a late pipe cannot recover dropped
147bytes.
148
149`remote.connect()` mirrors the same 64 KiB tail window, coalesced at the existing
150interval. Larger reads and saves use the ordinary remote method transport. Remote
151spill files and saves live on the remote host; degraded SSH command-mode jobs
152spill and save locally, where their capture lives.
153
154### spilled output
155
156`albedo_capture.Capture` owns spilling and byte reads. Cells spill past the 64 KiB
157preview, jobs past the 1 MiB in-memory prefix. Both retain up to `SPILL_LIMIT`
158(16 MiB) on disk. The first overflowing write opens a private spill file with
159all preceding bytes; subsequent writes append without buffering, so pages can
160read live output. Job completion closes the writer. Memory still holds only the
161first 1 MiB and rolling 64 KiB tail, irrespective of disk output size.
162
163Cell results name their spill file, `seen`, and whether all output is retained
164(`Capture.spill_note`). The directory sits beside the run directories:
165`$ALBEDO_HOME/output` locally, `~/.albedo-remote/output` remotely, and
166`albedo-output` in the temp dir for a kernel over stdio. Spill files survive output
167channel eviction and are pruned after `SPILL_KEEP` (14 days) on kernel boot.
168A spill creation or write failure does not interrupt the command: in-memory
169previews remain available, but pages beyond the retained prefix and whole-output
170saves refuse. Missing or shortened spill files raise visibly on reads and saves.
171
172## memory cap
173
174The kernel's physical footprint (`albedo_memory.footprint`: the resident set
175from `/proc/self/statm`, or Darwin's `ri_phys_footprint` through libproc, the
176figure vmmap and Activity Monitor show) is capped at `ALBEDO_KERNEL_MEMORY_BYTES`,
177256 MiB by default, which the daemon passes through to local kernels. A
178thread samples it every 100 ms while cells run and interrupts, with reason
179`memory`, every running cell whose threshold the footprint passed; the cell
180ends `interrupted` with a note naming what is held and the cap, and the
181namespace stays. A cell's threshold is the cap, or 8 MiB over the footprint it
182started at when the kernel was already past the cap (a large variable left
183behind), so `del big; gc.collect()` runs instead of being interrupted at once.
184It is a sampled guard, not an allocator limit: one allocation larger than the
185cap succeeds and is caught by the next sample, and a cell that catches
186`KeyboardInterrupt` is told once. Jobs are separate processes and are not
187counted.
188
189Finished results are kept by cell id (the newest 16) for execute replays.
190Their base64 image text is capped at 8 MiB across all of them, newest first;
191an older result past the budget replays without its images and says so in
192its output.
193
194## background cells
195
196The kernel returns a python tool result with `status=backgrounded` after 60
197seconds (`ALBEDO_CELL_BACKGROUND_SECONDS` overrides the threshold, in seconds).
198The result includes output so far, the cell id, and instructions to do useful
199work or report status and end the turn, without polling or sleeping. This is not
200a final outcome: the journal stays `started` until `cells.finish` records it.
201`cells.info/read/trace` remain available; traces appear when execution finishes.
202The original `timeout_ms` is carried to the kernel and still interrupts the cell
203at its deadline, even after the daemon's original execute wait has ended.
204
205New cells can run while a detached cell awaits. They share the namespace, so
206avoid modifying variables the detached cell uses. Python output, images, and
207activity traces use the task's context; native fd output goes to `native` when
208multiple cells run, since it cannot be attributed safely. A synchronous cell
209can return its tool result early, but other cells must wait until it yields or
210finishes. Background and deadline timers run outside the asyncio loop so a
211synchronous cell cannot prevent its deadline from firing.
212
213At most 16 cells run concurrently. Running captures are protected from eviction; completed output rolls out
214under the existing 16-capture limit. `await cells.cancel(id)` requests cancellation and
215suppresses that cell's wake. Explicit kernel reset/replacement ends its cells;
216a daemon restart merely reattaches. Internal `cells` count frames, replayed in
217the hello, keep idle reaping and automatic stale-kernel replacement from ending
218a running cell without pretending the cells are process jobs in the public API.
219
220Completion is committed before `cells.completed` sends a wake. The transcript
221gets a one-line `cell finished (status=ok, ran 90.0s): <first code line>` from
222`cell`, while the model gets the full notice with the cell id separately. Unread completions share a wake and retry
223every two seconds while the session is busy; there is no model polling. A wake
224that parks while the kernel is being replaced starts its turn on the
225replacement, together with the upgrade notice. The final expression is appended to retained output under `[result]`, so
226`output.read(id)` can read it after a background completion. Reading finished
227output, source, status, or trace retires a pending wake.
228Reading partial output while the cell still runs does not retire its eventual
229completion. A read racing with the completion journal also retires the wake.
230
231## processes
232
233- **kernel**: started in its own session and process group, stdio not tied to
234  anyone. it listens on `kernel.sock` in its run directory,
235  `$ALBEDO_HOME/run/<kernel id>/` (0700; the socket is bound relative to the
236  directory so a long temp home stays under the ~104-byte `sun_path` limit).
237  `kernel.log` there catches what it writes before its own output capture
238  starts.
239- **relay** (in `albedo_python.erl`): how the port owner reaches a local
240  kernel. an erlang process owns a `gen_tcp` connection to `kernel.sock`
241  (`{packet, 4}`) and passes frames to the port owner as a bridge port would,
242  so no python process stays beside a local kernel. a fresh boot first runs
243  `albedo_bridge.py launch <run_dir> <modules-json>` (token as its one stdin
244  line), which starts the kernel and exits once the socket answers. the relay
245  announces nothing, so the port owner hashes the bundle itself, the same way
246  `albedo_bundle.digest()` does. a refused or missing socket reads as exit 3.
247  a run directory whose socket path does not fit `sun_path` (104 bytes) keeps
248  the bridge.
249- **bridge** (`priv/python/albedo_bridge.py`): what the daemon's erlang port
250  runs for a remote kernel (the same argv over ssh, see remote kernels below)
251  or a run directory too deep for the relay, with the same `{packet, 4}` stdio
252  framing the kernel used to have. `start <run_dir> <modules-json>` starts the
253  kernel beside it (the token goes over the kernel's stdin, never argv) and
254  waits for its socket; `attach <run_dir>` connects to a running one. it
255  announces its own bundle hash (`{"bridge": {"bundle"}}`), then copies bytes
256  both ways until either side closes. exit status 3 means no kernel is there.
257  killing it never touches the kernel.
258- **port owner** (`albedo_python.erl`): one erlang process per kernel, as
259  before. it owns the job groups the kernel reports and the
260  `albedo_signal.py` termination ladder, aimed at the pid/pgid/leader the
261  kernel declares in its hello.
262
263a kernel run with plain `albedo_kernel.py <modules>` (no `--run`) keeps the
264old raw stdio protocol and dies with its pipe: the remote plugin's targets use
265that.
266
267## handshake
268
269the daemon writes `{"attach": {"kernel", "token", "ack", "grace"}}` first. the
270kernel checks the token (kept in the daemon's sqlite), bumps its epoch, cuts
271off any older connection (newest attach wins), and answers
272`{"hello": {protocol, bundle, epoch, ack, pid, pgid, leader, ready, jobs,
273external, awaited_external, external_jobs, dropped}}`, or `{"refused": reason}`. `protocol` (1) and the
274hello reports the running `cells` count. `bundle` is
275`albedo_bundle.digest()`, the same content hash the remote plugin stages
276under; a hello whose bundle or protocol differs from the bridge's (or, for a
277local kernel, the port owner's own hash) makes the
278kernel stale (see version skew). `jobs` (live `job_start` frames) and `external`
279let a fresh port owner take over job ownership and live-job observations.
280
281## session layer
282
283after the handshake every frame is `{"seq": n, "ack": m, "frame": {...}}`, or
284a bare `{"ack": m}`. `ack` is cumulative: everything up to it arrived.
285
286- **kernel side** (`priv/python/albedo_link.py`): an in-memory outbox of
287  unacknowledged frames, resent after each hello. `mirror` (per handle),
288  `trace` (per cell) and `jobs` coalesce latest-wins; everything else must be
289  delivered. bounded at 4096 frames / 64 MiB: coalescing frames go first, then
290  the oldest, counted in the next hello's `dropped`. a daemon frame at or
291  below the last seq applied is a replay and is dropped; each applied frame is
292  acknowledged at once.
293- **daemon side** (`python/link.gleam`, tables from the python extension's
294  `kernel_links` schema migration): `kernel_links` (one row per session: kernel
295  id, token, run dir, workspace, modules, pid/pgid/leader, epoch, last seq
296  sent, and the job groups it owns, rewritten as they change), `kernel_outbox` keyed (session, kernel, seq), bounded at 2048 frames /
297  64 MiB per kernel, and `kernel_calls`. the port owner persists each frame
298  before writing it, deletes what the kernel acknowledges, and acknowledges
299  kernel frames within 100 ms. its own last-seen seq lives in memory: after a
300  daemon restart it accepts everything the kernel resends, and the dedupe
301  rules below make that safe.
302
303dedupe:
304
305- host call id: a call already running in this daemon is ignored (its reply
306  goes out through the outbox); one answered already is ignored the same way;
307  one an earlier daemon started without answering is answered
308  `{ok: false, code: "unknown"}`, never run again. a call's row goes once the
309  kernel acknowledges its reply.
310- execute, by cell id: a finished cell (the newest 16) resends its `done`; a
311  queued or running one is ignored. callers must not reuse cell ids.
312- interrupt, reply, release are idempotent already. an interrupt for a cell
313  still queued behind the loop is kept and cancels the cell as it starts;
314  one for a cell the kernel no longer holds is dropped.
315- job_stop: the port owner asks the local kernel to stop a remote background job
316  with `{"type": "job_stop", "id": "<job id>"}`. The kernel answers with
317  `{"type": "job_stop", "id": "<job id>", "ok": true}` or `{"type": "job_stop",
318  "id": "<job id>", "ok": false, "error": "<lowercase why>"}` exactly once per
319  request. The port owner does not queue while the kernel is unreachable
320  (`port := none`), and gives up with an unconfirmed error after 15 s. The
321  request is delivered like any other frame, so one sent while the kernel was
322  briefly out of reach can still land after a reattach and end the job later:
323  "unconfirmed" means the outcome is unknown, not that the stop failed.
324- what the kernel resends after a restart: job wakes are host calls
325  (`jobs.completed`), so the call ledger covers them; `job_start`, `job`,
326  and `jobs` only update ownership and observations, which converge; a `done`
327  nobody waits for rewrites the same outcome; traces save by cell id.
328
329## the runtime scheduler
330
331`harness/runtime/boot.gleam` owns every worker the runtime actor starts.
332Booting, attaching, swapping a stale kernel at open, an explicit upgrade,
333recomposing for a reload (proposed or desired), and a composition read all
334queue as a `BootRequest` and run through `boot_next`, which admits them into
335`boot_slots` workers, one class never starving the other. A handler that
336queues a request ends in `|> boot.boot_next` in `serve`; nothing spawns a
337kernel worker outside `start`. Each worker computes off the actor, then
338`deliver`s its message or, when the runtime stopped meanwhile, releases what
339it made (`work`). `runtime/state` names the generation lifecycle a request
340belongs to: `admit` opens one, `current_waiters` says whether a result is
341still for it, `generation_over` ends it and replays what waited.
342
343## drops
344
345- the connection drops (the relay's socket closes, or the bridge dies): the port owner reattaches with backoff (50 ms doubling to
346  2 s, 40 tries); exit 3 or a refusal means the kernel is gone and the old
347  abandon path reaps it. a port owner attaching after a restart starts from
348  the record's pid/pgid/leader and groups, so a kernel that died hard while
349  nobody was attached still has its process group and its jobs' groups
350  ended. the leader token (start time, from `/proc` or Darwin's `sysctl`)
351  keeps a reused pid from being signalled. the cell keeps running meanwhile.
352- the cell deadline passes, or an interrupt goes unanswered for 2 s, while
353  detached: the interrupt waits in the outbox and the caller gets
354  `kernel.Detached`, which leaves the journaled cell `started` (the model is
355  told to check `cells.info`). a `done` that arrives for a cell nobody waits
356  for any more is journaled through the `cells.finish` host route.
357- the daemon crashes: its connection closes (a bridge sees EOF and exits); the kernel waits.
358- graceful shutdown: `runtime.detach_kernels` first, so closing sessions and
359  stopping the runtime let kernels go instead of ending them.
360- daemon start: `runtime.resume_kernels` reattaches every recorded kernel in
361  the background, one at a time through the boot slots, so late results and
362  job wakes arrive without waiting for the session. no actor is loaded for an
363  idle session yet, so a wake finds no registered closure: `albedo_wakes`
364  then calls the loader the registry installs (`on_missing`), which starts the
365  session as a `Lookup` would, and delivers to it; only a session that cannot
366  start answers `unavailable`, which the kernel does not retry. a session whose
367  kernel came back running a job that is not a service is started at once,
368  idle, so its cache warming resumes (robot-docs/cache-warming.md). a session
369  that asks for its kernel meanwhile waits for that attach, and gets a fresh boot if there
370  was nothing to attach to. an attached kernel's `runtime.origin` is
371  `Resumed`, and the session keeps its namespace without the disk restore. a
372  recorded kernel whose module set no longer fits is kept and marked stale; one
373  in another workspace is stopped and replaced. when jobs or background cells
374  are still running, the session's first turn on a `Resumed` kernel carries a
375  model note that the kernel reattached with its namespace, naming those jobs
376  (id and command) and cells (id and first line), so a live handle is not
377  mistaken for one the restart killed (with nothing running, only the UI note
378  appears). an origin is handed out once: later opens of the same kernel get
379  `Kept`, so no notice repeats and no live cell is journaled as ended. the
380  note is built from the port owner's observation and the
381  cells journal, never from a new kernel frame, so a reattached kernel on an
382  older bundle still gets it.
383
384explicit drops still end the kernel at once: reset, release by the idle
385sweep, `/cd`, session close, extension reloads (the replacement boots beside
386the live kernel, never attached to it), and the old kernel after a swap.
387
388## version skew
389
390a kernel is stale when it runs another bundle than the bridge that reached it
391(for a local kernel, the bundle the port owner hashed),
392speaks another protocol (both read from the hello), or booted with another
393module set than its session now has (found at reattach). `kernel.stale`
394answers the reason and whether the swap was forced. `GET /sessions/{id}`
395returns the native `kernel` observation with `instance_id`, `build`, `state`,
396`stage`, `stale`, and `live_job_count`. Unknown identities and counts are null.
397Reading this resource does not prepare or replace a kernel. `stage` reports
398remote bundle staging while the owner is booting.
399
400At the next idle moment, `session_namespace.ready` lets the runtime replace a
401stale kernel when no live jobs keep it. The runtime does the work off its actor:
402
4031. Snapshot the old namespace to `namespace.state` in its run directory.
4042. Boot a replacement beside it. The replacement's identities, groups and
405   outbox belong to `kernel_stages`; the old `kernel_links` row stays authoritative.
4063. Check that the replacement is attached and current, then restore its namespace.
4074. Mark the staged replacement ready, stop the old kernel and its jobs, and
408   confirm that the old durable ownership record was removed.
4095. Publish the replacement in `kernel_links` and adopt its actual handle.
410
411Boot, validation or restore failure stops the staged candidate and keeps the
412old kernel. A failed old-kernel stop reports the actual failure and attempts to
413stop the staged candidate. Shutdown may already have affected jobs; the report
414cannot promise to restore those external effects. Failed cleanup retains the
415relevant durable identities for a later verified stop. No failed stop is reported
416as a successful swap.
417
418On restart, storage publishes a ready candidate if its old link is absent.
419The daemon then supervises abandoned staged candidates before admitting work.
420Staged owners use their immutable kernel ID throughout publication, so replies
421and ownership updates continue to reach the same namespace.
422
423The adopted kernel has origin `Upgraded`. The next model input names the carried
424variables and definitions, the jobs the swap stopped, and the cells it ended,
425and says retained output from before the upgrade is gone while the cells journal
426keeps every cell's source and recorded result; the transcript records the
427upgrade. A swap ends the old kernel, so cells it was still running can never
428deliver their own result: they are journaled `interrupted` (a forced
429`/kernel upgrade` or a protocol-skew swap can end them; the automatic swap
430waits, because a kernel running a synchronous cell answers Busy to the snapshot
431and `upgradable` counts running cells). A background upgrade that cannot
432proceed keeps a usable old kernel and tries at a later idle moment. A lost old
433kernel is reported as lost.
434
435The idle moment a stale kernel waits for is a real caller: a turn, a wake, a
436compaction. Nothing parks at a turn's end just because the queue is empty, and
437an arriving kernel nobody is waiting for is adopted when the held one is gone —
438its notice still reaches the model — rather than dropped silently. A pending
439cell-completion wake is such a caller: it parks while the kernel is
440unavailable, the swap runs under it, and its turn starts on the replacement
441carrying the upgrade notice, so the wake is never lost to the swap.
442
443Live jobs keep an older bundle or module set until they end. `POST
444/sessions/{id}/kernel/upgrade` is an explicit idle-session action that can stop
445those jobs. Its response waits for the session to adopt the replacement and
446reports actual old/new identities and builds, observed stopped job IDs, warnings,
447and failure. An empty unattached session remains unchanged. There is no automatic
448retry after a lost HTTP response; read the session kernel first. `/kernel upgrade`
449uses this same operation, and `/kernel` reads the observation.
450
451a kernel booted now that is already stale says this daemon and its own python
452bundle disagree (a protocol bumped on one side only, a partial install): its
453boot fails with that reason (`albedo_python:out_of_step/1`), so a turn waits
454blocked on it instead of swapping one fresh kernel for another forever.
455
456protocol skew does not wait for jobs: a kernel on another protocol cannot be
457supervised. its outbox is dropped, not replayed; the swap asks for a snapshot
458and a shutdown through the frozen frames, carries nothing if the snapshot
459fails, and the stop's signal ladder ends the recorded process group either
460way.
461
462## lifetime
463
464a kernel with nothing attached for its grace period reaps its job groups
465(plugin cleanup) and exits, unless a job or cell is still live. the grace is
466`ALBEDO_KERNEL_GRACE_SECONDS` read by the daemon (default 3600) and sent in
467every attach; before its first attach a kernel waits 60 s. a kernel the
468daemon reattached at start is attached, so the grace never runs out for it:
469the maintenance sweep saves and stops one whose session has no loaded actor,
470no live job or cell, and no turn within `ALBEDO_IDLE_SECONDS`. a kernel also
471exits within a second of its run directory disappearing, which is how test
472homes clean up after themselves. the e2e harness sets a 30 s grace, the gleam
473test home 20 s.
474
475## remote kernels
476
477a session whose workspace is `[user@]host:/path` (workspaces.md) runs its
478kernel on that host. nothing above the bridge changes: the outbox, reattach
479with backoff and `resume_kernels` treat an ssh drop as a bridge that exited.
480
481- **one ssh layer**: `priv/python/albedo_ssh.py` builds the argv (BatchMode,
482  `ConnectTimeout=10`, `StrictHostKeyChecking=accept-new`,
483  `ControlMaster=auto`, a `ControlPath` and `ControlPersist`), finds an
484  agent socket, wraps commands in the remote login shell and stages the
485  bundle. the model's `remote` plugin imports it, so the daemon and
486  `remote.connect()` share masters and the staged bundle.
487- **aliases**: `albedo_ssh.alias(host, ssh_options, environment)` names a host
488  this kernel process reaches without `~/.ssh/config`: its `-o` values go
489  before albedo's defaults (ssh keeps the first value it is given) and its
490  environment reaches ssh and the `ProxyCommand` it spawns, which is how a
491  token stays out of every command line. it is per process, so the daemon's
492  own remote kernels never see one. colab.md uses it.
493- **the user's masters**: when the user's ssh config names a `ControlPath`
494  for the host (`ssh -G`, which reads no network), albedo uses that path and
495  its `ControlPersist` (600 when it says no), so a master the user opened in
496  a terminal carries albedo, and the master albedo opened carries the user's
497  own ssh, colmena and git in `run` jobs: one sign-in (one hardware-key
498  touch) per host. with none configured it is `/tmp/albedo-ssh-cm/%C` for
499  600 s.
500- **clean stdout**: every remote command is `/bin/sh -c 'exec 3>&1 1>&2;
501  exec "$SHELL" -l -c "exec 1>&3 3>&-; <command>"'`, quoted with shlex in
502  `albedo_ssh.in_login_shell` only. whatever the login profile prints (a
503  motd, a greeting) goes to stderr, and the command gets the real stdout
504  back, so the bridge's frames, the remote plugin's kernel and the probe's
505  lines stay clean whatever the user's shell is.
506- **fixed command lines**: the probe (and, with no network,
507  `albedo_ssh.py commands <target> <home>`) answers the complete remote
508  commands the daemon runs: `bridge` (`albedo_bridge.py --frame`), `signal`
509  (`albedo_signal.py -`) and `remove`. their inputs go on stdin, never in
510  the command line, so nothing in erlang quotes for a shell: the bridge's
511  first frame is `{"bridge": {"argv": [start|attach, run_dir, modules?],
512  "cwd": path}}` (exit 4 when `cwd` is no folder), the ladder reads its
513  request line, `remove` the run directory.
514- **probe** (`albedo_ssh.py probe <target>`, run by `albedo_ssh.erl`): one
515  command over the master prints os, arch, home, cores, the python3 version
516  and whether `~/.albedo-remote/<digest[:16]>` is staged; python older than
517  3.11 is `unsupported`, a missing bundle is staged (tar over the same
518  stream, unpacked beside the target and moved into place, so a bundle
519  directory that exists is complete). ssh's own failure (exit 255) is
520  `needs_auth` when its words say a person could get in (permission
521  denied, host key verification, passphrase, keyboard-interactive),
522  otherwise `unreachable`. answers are cached in the daemon, ready ones for
523  a minute and failures for five seconds; a second caller joins a probe in
524  flight. `albedo/harness/ssh.gleam` is the gleam face.
525- **boot**: `kernel.place` probes (or reuses the cached probe) before the
526  bridge starts, so a warmed host boots fast. the port owner runs the
527  `bridge` command over ssh. a fresh boot's exit 4 reads as "not a folder
528  there", 255 as an ssh failure. run directories live under the remote
529  home, `~/.albedo-remote/run/<kernel id>`, recorded absolute.
530- **a host out of reach is not a kernel gone**: only an attach the bridge
531  ends with exit 3 (nothing at the run directory) or a refused attach
532  proves a remote kernel gone. ssh failing (255), any other exit, or a
533  startup timeout while attaching is a dropped connection: the session gets
534  the kernel at once, reattaching, and the port owner keeps attaching in the
535  background, backing off to every 30 s, until the kernel's grace (plus a
536  minute) has surely passed since it lost the connection; then it forgets
537  the record quietly, without reaching for the host. when the probe itself
538  fails at boot, a recorded kernel is attached with commands rebuilt from
539  its run directory (which names the home), so a wifi blip during a daemon
540  restart keeps the namespace. a turn in a session with a kernel on record
541  goes through while its host is unreachable or needs a sign-in, waiting
542  like any reattach. only a fresh boot needs the host now.
543- **ownership**: the pids a remote kernel declares are that host's. the
544  `albedo_signal.py` ladder for its kernel and job groups runs there over
545  ssh (the `signal` command, 15 s), never
546  locally; if ssh can't reach the host the targets are left to the kernel's
547  own grace exit, which reaps its jobs on the host, and the error says so.
548  only the bridge (a local ssh client) is ever signalled here. the run
549  directory goes with `rm -rf` over ssh. `os_pid` answers nothing for a
550  remote kernel, so the reaper's `ps` never reads an unrelated local pid.
551- **turns**: a session that holds a kernel (attached, or reattaching
552  through its outbox) never probes on a turn. one that must boot probes
553  for up to 3 s: still warming is left to the boot, which waits for the
554  probe; `needs_auth`, unreachable or unsupported leave the turn admitted
555  and waiting, blocked with the probe's words as its receipt's
556  `blockingReason`, retried every 15 s like any blocked input
557  (operations.md).
558- **the model's remote calls** (`albedo_plugins/remote.py` on the owner
559  side, `serve_invoke` in the remote kernel): one call per frame, answered by
560  one reference. a result that can cross is inlined; anything else is kept
561  live (`LIVE`, 64 entries, identity-stable handles) so methods call the real
562  object there. awaiting a reference sends one `await` frame: the remote side
563  awaits the object only when it is awaitable (a job); a settled
564  non-awaitable object -- a spawned browser handle -- is the answer itself,
565  back as the same reference, so an await can never strand a live process
566  with no handle. arguments cross with the same markers as targets and
567  values: `{"__ref__": id}` for a live reference, `{"pending": id}` for a
568  call still in flight (resolved in order), `{"__bytes__": b64}` for byte
569  data, decoded remotely by `albedo_values`, so `rem.run(..., stdin=b"...")`
570  and byte-sized results work. `rem.write` takes text or bytes; either travels
571  on the stdin of one ssh command, never in the command line.
572- **the model** gets a context line (python extension, `place.gleam`):
573  "your python kernel and run jobs execute on chernobog (Linux aarch64)…",
574  with the remote home.
575
576### Host probes
577
578`GET /hosts?target={host}` reads cached SSH probe state.
579`POST /hosts/{host}/probe` starts or joins a bounded probe and returns `202`.
580The client polls the read until it reaches a terminal state. Probe state includes
581safe diagnostics and authentication instructions when operator sign-in is needed.
582The same probe supports remote folder gathering. See the
583[HTTP contract](../docs/http-api-design.md#models-workspaces-hosts-and-provider-login)
584for the wire format and [workspaces](workspaces.md#browsing) for folder browsing.
585
586### signing in
587
588BatchMode stays on for the daemon. when a remote session's waiting turn is
589blocked, the chat asks `GET /hosts?target={host}`; if that says `needs_auth` and the
590daemon shares the tui's machine, the chat offers ctrl+l: it runs
591`ssh -M -fN -o ControlPath=<control_path> -o ControlPersist=600 <host>`
592through `tea.ExecProcess`, so ssh asks in the terminal, then warms the host,
593and the waiting turn starts on the daemon's next try; the daemon rides that
594master from then on. with a daemon elsewhere the notice says to run
595`ssh <host>` on its machine, which carries the daemon when the user's ssh
596config multiplexes that host. the offer goes once the turn starts or goes
597away, or the session moves.
598
599## not yet
600
601- profile output a remote login shell prints reaches the daemon's own
602  stderr log on every remote command.