Shared runtime helpers #
clocks #
albedo/clock.gleam owns Gleam clock access. Use system_ms or
system_seconds for durable timestamps and expiry, monotonic_ms for elapsed
time and deadlines, and os_system_ms only when OS-clock semantics are required.
Store slow-query timing uses clock.monotonic_ms. Reuse these functions instead
of adding local clock externals or wrappers. Timers and waits go through
clock.send_after, clock.sleep and clock.delay_ms (albedo_clock from
Erlang), which divide the delay by ALBEDO_TEST_TIME_SCALE. Only a test that
sets it in its fixture's prepare gets faster time; a durable timestamp or
deadline compared against a scaled timer must be scaled the same way (the warm
plan and cache fade do). A provider request's budgets (connect, send, headers,
first event, keepalive) are a peer's network time and are never scaled; a test
shortens them with ALBEDO_PROVIDER_TIMEOUT_MS. Erlang native time units are
VM-dependent; request an explicit unit rather than dividing native ticks.
Unicode scalar limits #
albedo/text_scalars.gleam owns scalar counting and slicing. Its length,
take, drop, and tail scan UTF-8 without creating a whole codepoint list;
retained slices are copied so they release the source binary. Scalars are not
graphemes. Use these helpers for bounded previews and names, including from
Erlang through 'albedo@text_scalars':take(Text, Limit). The kernel job summary
retains at most 4096 scalars; truncating it must not allocate a list for the
entire command.
HTTP response previews and operation validation also use text_scalars.take;
their scalar-prefix adapters do not maintain a separate native implementation.
shared values #
albedo/shared.gleam owns values every process reads without copying.
publish stores one in a persistent term and answers a Shared(a) handle;
read returns the literal, which a spawn or send does not copy (a sublist of
it copies cons cells only). Publishing and release each scan every process:
publish once per lifetime, release in the owner's stop. Reading a released
handle panics. runtime.Runtime holds its installed extensions this way, so
a runtime copied into an HTTP request or a turn's worker costs a few words;
test/harness/runtime_test.gleam fails when the handle or a selection over
the installed list carries a copy. A literal references module code, so the
daemon does not hot-load a module while a shared value from it exists.
A daemon-wide fact found once at boot, with no owner to hand a handle to, is
a persistent term under a module's own key instead: albedo/daemon/webhook_intake
holds the bound intake port (publish at boot, clear when the intake stops,
port for readers). An integer copies nothing, and the one erase happens only
when the intake gives up.
Callbacks stored in ETS must capture compact arguments, not their owner's
state record. session_namespace.watch_kernel binds the destination subject
and message before creating its callback; capturing state there copies the
loaded kernel and session state into the watcher table and into every kernel
process that reads the callback.
mutable provider caches #
albedo_accounts keeps transient account limits in albedo_account_limits ETS,
keyed by scope and account ID. albedo_openai keeps endpoint model-list results
in albedo_openai_models, keyed by endpoint and key hash. Both use the lazy
albedo_registry table owner, so a request exiting does not discard the cache.
Readers access ETS directly; independent account marks do not replace a shared
map. Writes remove expired entries. These changing values must not be published
through persistent_term: replacing a compound term schedules work across all
processes, including unrelated parked agents.
listeners #
albedo/daemon/listener.keep runs a listener under a keeper and reports its
first start within a wait. A start that outlasts the wait returns TimedOut
after the keeper is killed, so it never comes up behind the caller. Later
exits restart it after a doubling pause: Always for the daemon's own port,
Bounded(attempts, stopped) for the webhook intake, which stops and runs
stopped after that many failed starts in a row. The daemon loads mist, glisten,
gleam_http and gleam_otp before its first listener starts, so the wait times the
start rather than cold code loading, which under load can take hundreds of ms.
boot timing #
ALBEDO_BOOT_TIMING=1 adds monotonic elapsed milliseconds to the daemon log
for home setup, code preservation, extension declarations, storage connections,
extension installation and startup workers, core storage, migrations, services,
build identity and readiness publication.
Each line includes its finish time in epoch milliseconds. Nested stages overlap;
do not sum a parent with its children.
test/manual/boot_probe.py <snapshot-launcher> <output.json> [repetitions]
measures fresh and existing empty homes through discovery-file publication, with
spares and code preload disabled. Wrap it in test/manual/stress.py to compare
CPU and fsync load. Its wall time includes VM and application startup before
Gleam main; the stage timers do not.
local CLI startup #
cli/internal/localdaemon serializes launchers with launcher.lock; the daemon
still owns daemon.lock. Launcher acquisition and the replacement's startup
each wait up to five minutes, renewing a 60-second idle budget when daemon.log
grows. Discovery and an approved daemon shutdown retain a 30-second budget.
A live PID alone is not progress. A caller's cancellation or earlier deadline still wins.
The Go launcher also divides these startup budgets and its log-check cadence by
ALBEDO_TEST_TIME_SCALE; controlled subprocess tests scale their progress cadence
to match. Polling and HTTP probe budgets remain unchanged.
daemon.json is stale when its port refuses connections. A draining daemon also requires replacement. Before launching, the CLI waits up to 30 seconds for the recorded pid to release daemon.lock, rechecking discovery in case the listener recovers. It asks the OS who holds SQLite's lock bytes (F_GETLK), since a recorded pid can be reused by any process after a crash or reboot. An approved shutdown likewise waits for the old pid to release that lock.
albedo daemon --stop sends the authenticated shutdown request, then waits up to 35 seconds for the recorded daemon process to exit. This includes the daemon's maximum 30-second kernel-saving drain. A caller's earlier deadline still wins. A timeout reports the pid and log to check; the CLI does not kill the process or remove its manifest.
A stalled startup reports the log path and leaves the detached process running;
check that log before retrying. A child exit reports its startup log immediately.
standby #
A sealed build boots from a standby when one is waiting: a VM the CLI started
with the daemon's own launcher and arguments, holding the dependency apps and
the code a ready daemon has loaded (priv/ready-modules.txt and
priv/boot-modules.txt, both written by test/manual/record_boot_modules.py).
It waits in albedo_standby:wait/0, which albedo.main calls before
server.main. Until a CLI hands it a home it opens no storage, binds no TCP
port, holds no lock and writes no daemon.json. Nothing it runs before the
handover may read HOME, ALBEDO_HOME or other user settings; loading a module
runs none of its code.
Standbys live in ALBEDO_STANDBY_DIR, else $XDG_RUNTIME_DIR/albedo-standby,
else $TMPDIR/albedo-standby-<uid>. The CLI creates it 0700, and the CLI and
the standby both refuse it when it is not this user's alone; that mode is the
access check, so there is no peer-credential check. Each build has
<digest prefix>.json (pid, digest, socket), <digest prefix>-<pid>.sock, and
<digest prefix>-<pid>.log with the standby's output before a handover. A
checkout's build has no .build-digest and never uses one.
After a successful launch or attach the CLI starts one standby unless the
record names a live process: ALBEDO_STANDBY_SERVE names the directory,
ALBEDO_STANDBY_ENV carries the environment it was started with, and its
stdout and stderr are a pipe whose read end it gets as fd 3. It writes its
record before loading; a CLI that arrives meanwhile boots cold. It exits after
ALBEDO_STANDBY_IDLE seconds (600) without a handover, and with
ALBEDO_PARENT_PID when that is set. ALBEDO_STANDBY=0 turns standbys off in
the CLI.
The launching CLI, holding launcher.lock, connects within 50 ms and sends a
4-byte length, a JSON request (digest, open-file limit, working directory,
environment) and daemon.log's descriptor (SCM_RIGHTS). The standby removes
its socket and record on accept, so a second CLI boots cold. It refuses, and
exits, when the digest differs, when the CLI's soft RLIMIT_NOFILE is not the
VM's max_fds (port programs keep the limit the VM started with), when an
ERL_* variable differs from its start, or when ALBEDO_INSPECT is set and
HOME is not the VM's -home (distribution reads its cookie file there). The CLI
then boots cold. Otherwise it rebuilds the environment the launcher would have
made from the CLI's (variables the launcher added stay, a prefix it put on one,
such as a wrapper's PATH, goes on the new value), changes directory, answers
ok, and on go returns into server.main, which runs from defaults() as in
a cold boot. The VM keeps the stdio it started with, which port programs
inherit, so a relay copies that pipe to the handed-over log;
albedo_standby:flush/0 runs before the daemon halts. The CLI holds the
connection until the daemon is ready: its end means the daemon exited, and a
refused home sends exit 75 first.
One standby serves one boot: it becomes that daemon, and the CLI starts the next one after the launch. Boots closer together than a standby takes to load (the e2e suite's parallel lanes) mostly start cold. An idle standby takes about 41 MB on macOS. It moves a boot's VM start and code loading off the launch, not off the machine.
sealed build identity #
An immutable application may carry .build-digest beside ebin and priv,
outside the hashed roots. It contains the existing tree digest as 64 lowercase
hex digits followed by one newline. albedo_daemon:seal_build/1 writes it after
the builder has finished the tree. The daemon and CLI read this identity instead
of scanning every file. Missing or malformed sidecars fall back to the original
sorted-path-and-bytes hash. Reads are bounded to 66 bytes.
Nix packages seal after bytecode compilation and helper installation. Daemon snapshots copy their priv trees, compile Python bytecode, then seal; they no longer refer back to mutable checkout priv files. An editable copy must omit or remove its seal before changing files. Source checkout builds remain unsealed. The daemon still memoizes the identity it booted with.
Sealing also writes .extensions.etf: the installed extension declarations
(extensions.declared()) as an external term tagged with the seal's digest.
extensions.defaults() decodes it instead of calling every extension's
constructor, which on boot loaded about 75 modules. The builder constructs the
declarations twice under different HOME, ALBEDO_HOME, USER, PATH, TMPDIR and
XDG_CONFIG_HOME values and writes nothing if they differ, so a declaration must
read the environment when it runs, not when it is built (instructions and
skills read the user's home when a session composes).
code loading #
Erlang loads modules on their first call. Before readiness, albedo_code:prepare/1
preserves each mutable application code path under $ALBEDO_HOME/code-cache.
On macOS, one isolated Python process calls APFS clonefile for the code trees;
the copies remain independent when Gleam rewrites a beam in place. If cloning
is unavailable, OTP beams are hard-linked when possible, with raw copies across
filesystems; checkout beams are always copied.
Code paths under /nix/store stay where they are when the daemon can hold an
open .app file for their store root. Nix discovers these descriptors as runtime
GC roots; paths in argv alone are not enough. A root that cannot be pinned is
copied instead. No module is loaded just to preserve it.
The cache replaces the previous boot's cache after the home lock is acquired,
and normal shutdown removes it. A crash may leave one boot's cache until the
next start. Clones retain application files; the copy/link fallback leaves
application priv directories symlinked to their originals.
A checkout's build tree links albedo's priv to the source priv, and a
rebuild by another Gleam toolchain deletes that link under a running daemon
until it relinks it; a kernel booted meanwhile found no bundle. prepare/1
reads the link once, first, and albedo_code:priv_dir/0 answers its target:
daemon code reads priv through it, never through code:priv_dir(albedo).
After readiness, a short-lived worker loads only the modules observed during a
first text turn on Chat Completions and Responses. priv/boot-modules.txt is the
union recorded by test/manual/record_boot_modules.py, plus timing-sensitive
maintenance and active-output modules retained from earlier recordings. The daemon
E2E suite allows at most three newly loaded modules outside that list. This warm adds
about 2.5 MiB of code, replacing the first turn's approximately 100 ms cold-load
penalty. Other modules stay lazy. The worker runs at low priority and yields between
two-module batches; readiness and requests never wait for it.
If preservation fails, original code paths are restored and albedo_code:start/0
falls back to the background preload of reachable applications. ALBEDO_PRELOAD=1
forces that older eager mode; ALBEDO_PRELOAD=0 skips both preservation and preload
for test snapshots. The outcome remains in {albedo_code, preloaded} for inspection
({ok, 0} for lazy loading). ERTS port programs and OTP priv files are not preserved,
so GET /server adds a runtime-removed-<instance> warning notice once the
install's lib directory is gone.
native boundaries #
Keep process state, ports, monitors, ETF, and native binary matching in their Erlang owners. Reuse Gleam owners for pure validation and text projection. A new Erlang shim should supply a native capability, not duplicate an existing Gleam helper. Keep typed values through internal projections and encode JSON at the wire boundary; see command state projections.
daemon/active_output.gleam owns unfinished response identities, limits, and
projection transitions. Its native helper owns buffered spill file I/O, range
reads, leases, and filesystem cleanup. Capture flushes by absolute offset so
capturing the same immutable projection cannot append its buffered tail twice.
Do not move projection policy into an opaque Erlang map or reconstruct output
from replay events. The session attachment contract defines
cursor consistency, commit replacement, and reference lifetimes.
session diagnostics #
daemon/session_diagnostics.gleam owns named facts from session_state.State
for native probes: watcher owners, sequence, history residency, tool progress,
and the current run's worker. Erlang probes must call these typed accessors
instead of indexing the state tuple or searching its fields by constructor tag.
The memory inspector reports numbered fields and sizes; it must not mirror
Gleam record names or create atoms for field labels.
dirty I/O schedulers #
The launcher keeps OTP's ten dirty-I/O threads, independently of the two normal
and two dirty-CPU schedulers. SQLite busy waits, fsync and file reads all share
this pool. Two blocked SQLite calls with a two-thread pool delayed /server and
session collection reads by 4.6–4.8 seconds; ten threads kept them below 9 ms.
Three isolated idle boots per setting had the same 51.2 MiB median physical
footprint on macOS (overlapping ranges), so reducing this pool did not establish
an idle-memory saving. The pool remains finite: slow calls still queue at ten.
storage concurrency #
daemon/store.gleam owns one ordered writer connection and four read-only connections for a file-backed database. query and write run on the writer. read uses SQLite's statement snapshot without opening an explicit transaction; snapshot runs a read-only callback in one deferred transaction on a reader. store/readers.gleam admits reads to idle connections on a separate actor, so a queued writer or one slow reader does not delay reads when another connection is free. Waiting reads use a FIFO queue. SQLite WAL permits readers and the writer to run together. Writes still serialize, and a session actor waiting for its own durable write cannot handle another message until that write returns. A write an actor must not wait for goes through query_then, which queues the run and hands its result to a callback in the writer's process after the commit; the actor takes it up as a message. Input admission does this: on a slow disk one commit took over 15 s, and an actor waiting on it refused every caller for that long. A run queued with query_then may call query on the same store, which then runs inline in its batch on the writer's connection; reads inside it that must see the batch go through query, not snapshot or read (conversation.unanswered_calls, conversation.committed_snapshot).
The session actor queues its turn's writes this way (session_state.write_then, whose Landed message carries the continuation, landing in commit order): the turn's opening commit (its worker waits for turn.open before its first request, and a failed commit kills the worker and puts the inputs back), the worker's commits, image fits, steering drains and salvages (the worker's call is acknowledged at once and the answer follows once committed, session_run.confirm_committed), the run's end (finishing holds the session until it is durable), the pin and prepared-head reports, the known prompt, and a child's answer to its requesters. Their continuations publish rows against the history boundary read on the writer right after their own commit (conversation.committed_snapshot), since later commits may land before the session takes one up. A letter or input whose opening commit is still queued counts as held (active_submissions). turn.open carries that commit's boundary (Snapshot.upper), and the worker reads the transcript through it itself (session_history.opening_inputs): the reader sends raw rows, the worker decodes, settles (saving any move, as a session load did) and projects them once, and the session never loads or copies the history for a turn. The session's own questions about the transcript are reader scans, a page at a time, that return only their answer (conversation.unanswered_tool_calls, last_assistant_text, held). The registry decides creations and agent spawns and leaves their commits to the requesting process (Creating, AgentPlan), then holds the session when told.
Writes an actor still waits for, and why: a session's start registers it on the writer, ordered with deletion claims, with orphaned shell commands ended in the same run (no rows change on an ordinary start, so it waits for the writer's queue but syncs nothing); a deletion claim, after which the actor only closes; and requests whose HTTP answer is the commit's own outcome and that run while the session is otherwise quiet: configuration changes, shell command admission, completion and transcript attachment, workspace records, rewinds, compaction, cancellations and interrupts, ACP overlay publication, naming, and the capability notice of a prompt that changed while unloaded. These can take the same decide-then-commit shape when one of them shows up waiting on a slow disk. The writer commits in groups: when runs are already queued behind the one it takes, it runs up to 64 of them in one transaction, each in its own savepoint, and answers each caller only after that commit, so a burst of writes pays for one commit and nothing is acknowledged early. It never waits for more work to arrive. A lone run commits as before. A run that opens a transaction for a later run to close (the schema batch, an in-memory snapshot) is never grouped, and callbacks must not issue a bare BEGIN/COMMIT: transaction already turns into a savepoint inside a group. If the group's commit fails, each run is repeated on its own so each caller hears its own outcome. Pure composition/glance callbacks must use read or snapshot, never query: a sidebar refresh must remain available while the writer is busy. Keep query for mutations and read-modify-write transactions. File-backed stores use WAL with synchronous=FULL and fullfsync (checkpoint_fullfsync on the checkpoint connection): every commit syncs the WAL before the writer answers, so an acknowledged write survives a daemon crash, an OS crash and a power loss. On macOS plain fsync leaves data in the drive's cache; fullfsync makes SQLite use F_FULLFSYNC (about 3 ms a commit on an Apple SSD), and Linux ignores it. Group commit and folding a turn's small writes together keep the number of syncs per turn low.
File-backed writer connections disable SQLite's automatic WAL checkpoint and set journal_size_limit to 8 MiB, so a WAL a pinned reader let grow is cut back when the writer next restarts it. store/checkpoint.gleam owns one additional writable connection and ticks once per second, scheduling the next tick only after the previous one finishes. Each tick reads PRAGMA data_version (it changes when the writer commits) and counts the frames of the WAL's current cycle from the -wal file alone (the header salt names the cycle; a binary search over frame headers finds its end). It runs wal_checkpoint(PASSIVE) once the current cycle holds 4 MiB or the oldest commit not yet backfilled is 30 s old (divided by ALBEDO_TEST_TIME_SCALE, like the tick that checks it), and keeps backfilling a cycle over 4 MiB each tick it gains frames, since a reader can keep the writer from restarting the WAL after a backfill; it retries next tick when a reader kept it from finishing, and backfills on close. Every commit already synced its WAL frames, so waiting costs no durability: a page every turn rewrites (the session row, sqlite_sequence, index pages) is copied into the database once per many turns instead of once per turn. Nothing in the daemon may open the -shm file itself: POSIX locks belong to the process, so closing any descriptor of it drops the locks SQLite's connections hold there, and the next process to open the database rebuilds -shm under their memory maps; the daemon then dies of SIGBUS, which it cannot log. Checkpoint sync and backfill therefore no longer run inside a committing writer callback. Ordinary disk writes and WAL-header synchronization on reuse can still delay the writer. The worker uses a 128 KiB suggested cache, closes before the writer, and monitors its lifetime; losing the checkpoint owner fails the store. In-memory stores need no checkpoint worker. A checkpoint never waits for reader or writer locks; disk I/O itself can still be slow. A pinned read snapshot can retain WAL frames until it ends. Periodic retries let later writes reuse the WAL after readers release it; there is no hard file-size cap while an arbitrary reader remains pinned.
Session captures use one additional read-only connection, separate from the four-reader pool. The caller queues for this capture lane off the session actor, then lends its snapshot connection to a CaptureIn turn. That turn reads bounded metadata (not transcript payloads) alongside its cursor and active-output projection. No actor waits for capture-lane admission: otherwise four captures could reserve all ordinary readers while an interleaved mutation waits for one of them. The loan ends only after the actor replies or dies; a caller timeout cannot recycle a connection that an accepted message may still use. Composition and runtime observations run after the loan is returned. Private in-memory stores have no separate reader, so capture reads their writer directly from the actor instead of lending that writer back to it.
A snapshot sees committed data from its first read. All related reads, including image payload lookups, use the supplied connection. Snapshot callbacks must not call Store-level APIs, retain their connection, or write. Read-modify-write operations and mutations with RETURNING use query and stay on the writer. A read after an acknowledged write sees that commit; a read concurrent with an uncommitted write can see the previous committed state.
Session read messages, including capture, status, summary, stream replay, and idle reports, must not run lifecycle follow-up that writes the store before answering. Idle reports read the kernel PID and live-work count (including running cells) from the port owner's existing published observation table, never a synchronous owner call: an idle sweep must not block the session behind a host-call claim waiting for the writer. Pending workspace application is a mutation and belongs to an explicit mutation path; keeping it out of the read preamble lets these requests observe the last settled state while the ordered writer is busy.
Session lifecycle waits also stay off the session actor. Release saves variables on a worker, parks kernel-dependent work, then enqueues Reset from the actor before replaying that work. Reset and the subsequent Open therefore preserve sender order; an expired acknowledgment wait never retries Reset. Extension reloads (a session reload, an extension's refresh) park behind a release or workspace teardown too: each ends by setting the kernel itself, and a release would put back the pre-reload handle and its tools. Workspace teardown parks boot requests and reports preparing until its worker confirms deletion, then acknowledges the captured workspace revision and replays them. Run completion settles pending workspace intent before publishing idle; a superseding move stays preparing through its own teardown. Deletion cancels the run before killing its worker, so a worker-down message during asynchronous teardown still records interruption, not failure. Child-work checks for unload and rewind run off-actor and recheck the session's own idleness on completion. Awaited observations are closures finished by their callers; stale kernel notifications without a held kernel are ignored.
Remote workspace readiness follows the same rule: SSH reachability probes run off the session actor, and a generation-checked reply resumes the queued turn.
Capture and status reads never call the runtime actor from the session actor. The runtime observes composition on boot slots; kernel teardown runs on separate monitored stop slots. The session answers with a closure (Unobserved, session_namespace.kernel_observer) that the caller finishes: a session without a held kernel learns what the runtime has loaded there, under the same 15 s budget as the composition observation. A read made while the runtime is busy waits for it instead of failing at the session's 5 s call deadline, and the session keeps answering everything else meanwhile. A session's first observation walks its skill and instruction files for the retained-discovery key and once more to confirm they did not change; discovery itself does not walk them a third time (session_catalog.inspect_with takes the inputs already read), and one walk or one discovery looks each shared path component up once (albedo_skills:with_link_memo), since every lookup waits for the CPU on a loaded machine.
Writer callbacks held for at least one second are logged once with their duration, caller label, and generated callback module/function name; a grouped commit that takes that long is logged with its run count, since its sync belongs to no single callback. Only function metadata is logged, never closure environments or SQL parameter values.
Store and reader initialisers allow 60 seconds for disk I/O and dirty-I/O scheduler queues, matching runtime initialisation. Startup errors preserve the underlying actor or SQLite reason.
Reader connections use private SQLite caches with a 128 KiB suggested page-cache limit each (512 KiB across four readers), rather than four default-sized caches. Schema and connection allocations are additional; this is not a total memory cap. Reader connections belong to the store lifetime and close before the writer. A failed connection owner fails the store rather than leaving a live handle with reduced capacity.
Closing a store first runs every stop_before_close callback, latest first, then closes its connections, the way a supervisor stops later children before earlier ones. A process that reads the store on its own schedule (a timer, a manager with its own supervisor) registers one, so it is gone before any read could meet a closed store; the Discord manager does. The callbacks run in the store's process, must not use the store, and share the close's five seconds. Once a store has closed or stopped, read and write answer Error("the store stopped"); query and snapshot, whose callbacks choose their own result type, panic with that message, since a caller that outlives its store is a bug to see, not to absorb. Private in-memory databases retain one connection and serialize snapshots with writes. Their snapshots still enforce read-only access.
stall monitor #
daemon/albedo_stalls.erl holds the VM's one erlang:system_monitor. It logs
long_gc and long_schedule over 250 ms, a heap past 4 Mwords (32 MiB), and a
busy port, each naming the process by its proc_lib label (else registered name
or initial call). At most ten lines go out per ten-second window; as a window
with drops closes, one more line says how many were left out, and a burst past a hundred queued events is counted and
dropped, so a storm cannot grow its mailbox. Nothing else may call
system_monitor: a second caller silently replaces this one.
quiet session actors #
Each session coalesces activity into one 100 ms timer. The first quiet tick
clears the burst's dirty mark; another tick without activity calls OTP 28+'s
stack-preserving erlang:hibernate/0. This compacts the actor's live heap and
keeps its Gleam OTP receive loop and system-message handling intact. Any message
wakes it immediately; the timer never delays handling a message. Existing
Collect messages still perform a major collection when a handler drops state.
turns without a kernel #
A turn starts from the session's composition
(session_state.composition: the held kernel's, else the released one's, else
runtime.prepare_session_async) and holds no Python kernel. The worker's
acquire sends AcquireKernel before each tool call; the session answers with
its live kernel or parks the request behind kernel_or_park, and refuses it once
the run is cancelled. The adoption notice (restored or lost namespace) reaches
the model as a user note after that call's result. A turn whose transcript ends
in unanswered calls still waits for the kernel, which alone can recover them.
Each model request arms a rest timer: once the wait passes rest_ms (3 s) with
no tool call, the kernel is released through the resting release's guards (no
live work, no transition; a watching client does not hold it), rechecked every
second while they fail.
Releasing at once cost 132 ms per step of a fast
tool loop; at 3 s a fast loop never releases. A test shortens the wait with
ALBEDO_TEST_TIME_SCALE.
finite actor calls #
actor_call.try_call uses a monitored process alias for its reply subject.
The native albedo_actor_call.with_reply bracket retires the alias and drains
already-delivered replies when the call returns or raises. A late reply is
then dropped before entering the caller's mailbox. The request itself is not
cancelled; domain operations must still handle an unconfirmed result.
albedo/actor_call.gleam owns monitored calls that return TimedOut or
CalleeDown and remove their monitor on every outcome. Reuse try_call for
finite waits instead of copying subject lookup, reply selection, and monitor
cleanup. Callers own deadlines and translate failures into their domain errors;
a timeout does not cancel accepted work.
Kernel upgrade completion keeps its repeated wait until completion or owner death. Session closure also waits for owner termination after the acknowledgment. These lifetime protocols need more than a finite request/reply call.
A wait nested inside another must end first, so the inner failure reaches the
caller as a typed error instead of the outer call timing out over it: a stop's
signal helper (HELPER_WAIT) under StopJob's call, peek_commands under
session.commands, the daemon's sign-in owner wait under the CLI's request
timeout, a test fixture's waits under eunit's 50 s kill. Widen the outer
deadline with the inner one, or neither. Before widening for a flake, measure
what the wait covers: a wait that normally returns in milliseconds is a
ceiling and only slows a real failure. A test whose result depends on how long
something took needs the race removed where an event can replace the clock;
where the clock is the thing under test (a run() deadline, the warm cache
window), widening is the fix.
registry host lookup #
While the registry is open it publishes its runtime host in albedo_registry_host
(a albedo_registry table), and registry.host reads it without a call; a
session already running is likewise answered from albedo_sessions by
registry.lookup. Shutdown withdraws the host first, so every later read falls
back to the actor and gets its closed-daemon error. Only a session that must
start goes through the actor. With 8,000 concurrent clients the host+lookup pair
took 19.3 ms median through the actor and under 1 µs read directly.
daemon/registry.host owns the five-second runtime-host lookup. Callers retain
Result(Runtime, String) and map failures at the HTTP boundary. Reuse this
function rather than sending Host with a local copy of its timeout. Timer-driven operation and creation-receipt pruning runs on the single scheduling worker, not on the registry, so pruning behind a writer does not block host and loaded-session lookups. Session activation still registers its live owner on the writer, serialized with deletion claims, and can delay registry requests while that writer is busy. Creation does not wait there: the registry decides it and hands the commit back to the creating request (Creating), then holds the new session once told Created, so a burst of spawns shares one commit instead of holding the registry for a commit each. Page
contributors construct page.Row directly; forwarding constructors add no policy.
closure registry tables #
daemon/albedo_registry.erl creates closure-registry ETS tables lazily. A
registering caller monitors the creator and waits for a tagged readiness
acknowledgment or explicit startup failure before inserting. The winning owner
outlives the callers; concurrent losing creators acknowledge the existing
table and exit. Keep this lifetime independent of daemon boot so embedded
runtimes work too. Never replace readiness with a sleep or bounded polling.
HTTP route inventory #
test/harness/http_routes_test.py compares daemon and extension dispatch arms with docs/openapi.yaml operations without booting a daemon. It normalizes parameter names and checks method/path pairs in both directions. It reads outer dispatch arms in the current Gleam layout and the OpenAPI path/operation indentation; new routing syntax needs an extractor update. The Go E2E contract transport checks response schemas for requests it exercises, and test.sh separately checks generated Go bindings with apigen -check.
Contract digest #
The CLI's decoders are generated from docs/openapi.yaml and reject responses that miss a field they require, so a CLI built from one revision cannot use a daemon serving another: a go installed CLI next to a checkout whose daemon gleam run rebuilds after every pull, or a new package beside a daemon whose restart was declined. apigen hashes the contract without its prose (description, summary, example, examples) and writes the same value to cli/internal/daemon/protocol/digest.gen.go and src/albedo/daemon/api_digest.gleam; apigen -check keeps both current. GET /server reports it as api_digest. daemon.Attach still accepts a daemon with another digest, so albedo daemon --stop reaches it, but the CLI's Connect and Connection.Refresh refuse it with APIMismatchError, which names both remedies (rebuild the CLI, or restart the daemon). A daemon that reports no digest predates it and is not refused. Rewording docs leaves the digest alone; any change to a shape, a required set or a route changes it, compatible or not, so after such a change a CLI and daemon must come from the same revision.