Servidor HTTP
Apps agénticas
Una app que orquesta agentes frente a una UI en vivo necesita cinco cosas que un servidor REST común no tiene: un canal bidireccional al navegador, un proceso hijo que podés mirar mientras corre, una forma de que un evento llegue a todos los clientes abiertos, una sola espera sobre todo eso, y un servidor que vence, cancela y se apaga sin dejar nada atrás. Desde el engine v0.6.7 las cinco están en el lenguaje, con las mismas reglas que todo lo demás: deny-by-default, memoria acotada, errores que se dicen.
No es sólo para agentes. Las mismas cinco primitivas son lo que necesitan un chat, un minijuego multijugador, un editor colaborativo, un dashboard en vivo, un bot de build o una terminal en el navegador: un socket por cliente, un bus por sala o tema (bus_subscribe("room." + id)), un proceso que mirás, un select por conexión y un servidor que limpia lo suyo. "Agéntica" es donde se encontró el hueco; la página aplica a cualquier programa con estado vivo compartido entre clientes.
-- Doc example: the event bus (`bus_*`) and the unified `select` — the plumbing of an
-- agentic app. N subscribers receive the SAME event (fan-out — unlike signal/wait_for,
-- which one receiver consumes), queues are bounded, topics accept globs, and ONE wait
-- covers any mix of handles. Pure: no capability (same trust level as share/signal).
-- Incoming sockets (`route … socket`) and live processes (`proc_*`) need a running
-- server / an `exec` grant, so the page prose covers those with the shapes that run.
intent: "doc example: event bus + select"
let wide be bus_subscribe("agent.*")
let exact be bus_subscribe(["agent.done", "ui.*"])
let other be bus_subscribe("other")
test "publish fans out to every matching subscriber and says how many got it"
assert_eq(bus_publish("agent.done", {"step": 1}), 2)
let ev be bus_recv(wide, 1)
assert_eq(ev["type"], "event")
assert_eq(ev["topic"], "agent.done")
assert_eq(ev["data"]["step"], 1)
assert_eq(bus_recv(exact, 1)["topic"], "agent.done")
assert_eq(bus_recv(other, 0.1), nothing) -- no match → nothing at the timeout
test "select waits on any handles at once and tags the winner"
bus_publish("ui.click", "go")
let ev be select({"wide": wide, "exact": exact}, 1)
assert_eq(ev["name"], "exact")
assert_eq(ev["source"], "bus")
assert_eq(ev["topic"], "ui.click")
assert_eq(ev["data"], "go")
assert_eq(select([wide, other], 0.1), nothing) -- nothing ready → nothing
task publish_glob()
give bus_publish("agent.*", 1) -- globs belong to bus_subscribe
task publish_task()
give bus_publish("agent.done", publish_glob) -- a task is not data
test "a published topic is literal and the payload must be data (loud, not degraded)"
assert_error(publish_glob)
assert_error(publish_task)
test "queues are bounded: drop_oldest keeps the newest — the publisher never blocks"
let small be bus_subscribe("q", {"max_queue": 2})
bus_publish("q", 1)
bus_publish("q", 2)
bus_publish("q", 3)
assert_eq(bus_recv(small, 1)["data"], 2)
assert_eq(bus_recv(small, 1)["data"], 3)
bus_unsubscribe(small)
test "bus_topics lists live subscriptions by pattern"
assert(some(bus_topics(), (t) => t["topic"] == "agent.*"))Rutas WebSocket — socket§
Una ruta con un bloque socket acepta un WebSocket entrante. Adentro, socket es el handle de esta conexión — el mismo tipo de handle que devuelve ws_connect, así que toda la familia ws_* funciona sin cambios:
require serve(8080)
serve on 8080
route "GET /ws" -- el handshake es un GET (RFC 6455)
socket
ws_send(socket, {"hello": "agent"})
while true
let ev be ws_recv(socket, 30) -- {type: "text"|"binary"|"close", data}
when ev == nothing -- 30 s sin nada
stop
when ev["type"] == "close"
stop
otherwise
ws_send(socket, {"echo": ev["data"]})
route "GET /private" requires auth -- la auth corre ANTES del upgrade: 401, sin upgrade
socket
ws_send(socket, "hello " + request.user.name)
request,params,query,headersyuserestán ligados como en cualquier ruta.ws_stats(socket)["role"]es"server".- El cierre es honesto. Termina el bloque o
stop→Close 1000. Error no atrapado →Close 1011+ el mensaje (≤ 123 bytes) y una línea[socket] … handler faileden el log. El server cancelando el handler (timeoutde la ruta, shutdown) →Close 1001(going away) + el motivo. Un cliente que desaparece sin handshake de cierre llega como{type: "close", data: "connection reset without closing handshake"}en el próximows_recv/select. - Un request HTTP común a una ruta socket →
426 Upgrade Required(body JSON, headerUpgrade: websocket). Los clientes HTTP/2 reciben el mismo 426; los navegadores siempre abren WebSockets por HTTP/1.1. - El keepalive es del server: responde pings y manda los suyos cada
SYNSEMA_WS_SERVER_PINGsegundos (default 30) mientras el handler espera; sin pong en dos intervalos → unclosecon motivokeepalive timeout. Un mensaje mayor aSYNSEMA_WS_MAX_MESSAGE(16MB, techo 64MB) es un error atrapable. El backpressure es real en las dos direcciones (canales acotados; la ventana TCP frena al lado lento). - Presupuesto: un socket ocupa un slot de
max_streams(503+Retry-Afteral agotar) y cuenta contraSYNSEMA_WS_MAX_CONNS. Sin capability nueva — la ruta ya está dentro deserve. - Reglas: sólo
GET,socketystreamen la misma ruta es error de parseo, todavía no se permite dentro de un grupoexport routes(error claro). Fuera de una rutasocketes un nombre común. - Un handle jamás cruza requests. Para empujar a N sockets abiertos desde un cron, un agente u otro request, cada handler de socket se suscribe al bus y reenvía — abajo. A propósito no hay una tabla global de sockets.
Procesos vivos — proc_*§
run es one-shot: captura todo y devuelve al final. Un agente que corre cargo test, npm run build o un worker largo necesita ver la salida a medida que sale, mandar stdin y matarlo — un proceso como handle con eventos. Mismo gate que run (require exec(cmd), scope = el comando tal como se escribe), args como lista, nunca un shell. Pipes por defecto (una herramienta que detecta "no es una tty" se comporta como en CI); "pty": true le da una terminal real — ver Pseudo-terminal abajo.
require exec("cargo")
let p be proc_spawn("cargo", ["test"], {"cwd": "./repo"})
while true
let ev be proc_recv(p, 60) -- {type: "stdout"|"stderr"|"exit", data} o nothing
when ev == nothing
proc_kill(p) -- 60 s en silencio: TERM ("KILL" para insistir)
otherwise when ev["type"] == "exit"
print("exit " + text(ev["data"]["exit_code"]))
stop
otherwise
print(ev["type"] + ": " + ev["data"]) -- un evento por línea, sin el salto final
proc_close(p)
| Builtin | Devuelve |
|---|---|
proc_spawn(cmd, args?, opts?) | handle |
proc_recv(h, timeout?) | próximo evento, o nothing al vencer |
proc_select(lista | mapa, timeout?) | primer evento listo entre procesos (select acepta cualquier handle) |
proc_send(h, texto | bytes) | true; error si stdin está cerrado. Es una escritura bloqueante de pipe: un hijo que nunca lee stdin te bloquea |
proc_close_stdin(h) | true — EOF al hijo. Sólo pipes: un pty no tiene un stdin aparte — mandá la tecla de EOF (bytes([4]) Ctrl-D, bytes([26, 13]) Ctrl-Z + Enter) |
proc_resize(h, cols, rows) | true — sólo pty (SIGWINCH / ResizePseudoConsole); error en un proceso por pipes |
proc_status(h) | "running" · "exited" · "killed" · "closed" |
proc_kill(h, señal?) | "TERM" (default; SIGTERM en Unix) o "KILL"; en Windows ambas terminan. Alcanza al árbol de procesos entero (v0.6.9+, ver Ciclo de vida) |
proc_wait(h, timeout?) | {exit_code, signal} o nothing |
proc_stats(h) | {pid, cmd, status, exit_code, pty, tree, queued, queued_bytes, dropped, uptime} — tree: el kill alcanza a los nietos |
proc_close(h) | libera el handle; mata si sigue vivo (TERM, KILL a los 2 s, luego wait) — el árbol entero. Sin huérfanos. Idempotente |
exit llega una vez, después de drenar los dos pipes, como {exit_code, signal} (exit_code -1 si murió por señal; signal es nothing en una salida normal y siempre en Windows). opts: cwd, env (hereda + sobreescribe; un valor secret es error — reveal() explícito), line_mode (true; false = chunks crudos de texto ≤ 64 KiB — data siempre es texto, UTF-8 lossy, nunca partido dentro de un carácter), stderr ("separate" | "merge"), pty (+ cols, rows, term — abajo), process_group (true: grupo de procesos / Job Object propio para que el kill alcance al árbol; false desprende a propósito un daemon que debe sobrevivir a proc_close — entonces sólo muere el hijo directo), y la cola acotada max_queue (4096) / max_queue_bytes (64 MiB) con on_full = "block" (default: el lector deja de drenar, el hijo se frena en su write — backpressure real, sin pérdida) | "drop_oldest" | "error" (el próximo recv falla, el proceso se mata).
Ciclo de vida. Cuando termina el intérprete que lo lanzó — el request bajo serve, el programa, el agente — todo proceso vivo se mata. El árbol entero, no sólo el hijo (v0.6.9+): el hijo nace en su propio grupo de procesos en Unix y en un Job Object con kill-on-close en Windows, así proc_kill/proc_close sobre sh -c "npm run dev" se llevan también al node de abajo — y en Windows hasta un crash del intérprete cierra el job y el árbol con él. proc_stats(h)["tree"] te dice que está en efecto. Un handler nunca deja un fantasma; el trabajo que debe sobrevivir a un request va en cron_after o en un agente. SYNSEMA_PROC_MAX (64, techo 1024) acota los procesos vivos por intérprete. Un nieto que retiene el pipe tras el exit del hijo no te cuelga: 1 s de gracia y se entrega el exit.
Pseudo-terminal — pty: true§
Engine v0.6.8+. Muchos programas preguntan "¿estoy hablando con una terminal?" y, sobre un pipe, saltan la pregunta, asumen "no", se cuelgan o se niegan: prompts y/N que leen /dev/tty, contraseñas de ssh/sudo/gpg, menús con flechas, docker run -it, REPLs, barras de progreso y toda TUI (vim, htop, un CLI agéntico). "pty": true corre al hijo dentro de un pseudo-terminal real — openpty en Linux/macOS, ConPTY en Windows ≥ 10 1809. Misma API, mismos eventos, mismo gate exec(cmd): un pty no otorga ningún poder del SO que un pipe no dé; cambia cómo se comporta el hijo, no qué puede hacer. sandbox lo deniega como a cualquier exec.
require exec("npm")
let p be proc_spawn("npm", ["install"], {"pty": true, "cols": 120, "rows": 40})
let screen be ""
while true
let ev be proc_recv(p, 60)
when ev == nothing
proc_kill(p)
otherwise when ev["type"] == "exit"
stop
otherwise
set screen to screen + ev["data"] -- texto crudo en chunks, ANSI incluido
when contains(strip_ansi(screen), "[y/N]") -- leerlo como lo leería un humano
proc_send(p, "y\r") -- teclas: Enter es \r en una tty
set screen to ""
proc_close(p)
Qué cambia en modo pty — consecuencias de "es una terminal", no API nueva:
- Un solo stream. Una tty no separa stderr: todo llega como
stdout. - Texto crudo con secuencias de escape.
line_modepasa afalsepor defecto —datasigue siendo texto, nobytes: chunks UTF-8, nunca partidos dentro de un carácter, bytes inválidos → U+FFFD. Mandáselos a xterm.js como frames de texto tal cual, ostrip_ansi(texto)para ver lo que ve un humano (colores, movimientos de cursor y títulos OSC fuera; los redibujos con\rconservan el último cuadro). El runtime nunca interpreta VT. - El eco está activo. Lo que mandás con
proc_sendvuelve por la salida. Un secret revelado que tipeás vuelve en claro — siguen valiendo las reglas de siempre (secreten args/env/send → error). - Teclas, no líneas. Enter es
"\r", Ctrl-C esbytes([3]), Ctrl-Dbytes([4]), una flecha es ESC +[A. No hayproc_close_stdin(el error dice qué tecla mandar). - Tamaño.
cols/rows(default 80×24),proc_resize(h, cols, rows)después.termfijaTERM(defaultxterm-256color;env.TERMgana). - El kill alcanza al árbol (como con pipes desde v0.6.9). En Unix el hijo es líder de sesión y se señala al grupo de procesos entero; en Windows se termina el Job Object y se cierra la pseudo-consola — ningún nieto sobrevive colgado de la tty.
- El handshake de Windows está resuelto. ConPTY emite
ESC[6nal arrancar y no entrega nada hasta que alguien contesta; el runtime contesta ese primero y lo quita del stream (un xterm.js del otro lado no lo ve, así no contesta dos veces). LosESC[6nposteriores son de la app y pasan intactos. exit_codees-1si murió por señal; en un ptysignales el número cuando se conoce (15, 9, 1, 2), si nonothing.
Una terminal web es una ruta socket y un pty en un mismo select: bytes del navegador → proc_send, eventos del proceso → socket_send, un mensaje {resize} → proc_resize; xterm.js dibuja. Quién recibe ese socket es una decisión de auth de la app — el runtime no agrega ninguna capability porque no hay nada nuevo que gatear.
Probá primero el flag no interactivo (--yes, CI=1, DEBIAN_FRONTEND=noninteractive): más barato y determinista. El pty es para cuando no existe.
Bus de eventos — bus_*§
signal/wait_for es punto a punto (un receptor consume). Una UI en vivo necesita fan-out: N handlers — uno por cliente SSE o socket — reciben el mismo evento que publicó un agente, un cron u otro request, sin polling. Eso es el bus. Un bus por programa, visible desde todos lados: top-level, workers de parallel_map, ticks de cron, agentes spawneados y cada handler de cada worker del server. Sólo in-process (Redis con la misma API es el roadmap). Sin capability — mismo nivel de confianza que share/signal.
bus_publish("agent.progress", {"step": 3, "of": 10}) -- → cuántos suscriptores lo recibieron
let sub be bus_subscribe("agent.*") -- un topic o una lista; globs * y ?
let ev be bus_recv(sub, 25) -- {type: "event", topic, data, timestamp} o nothing
bus_unsubscribe(sub)
bus_topics() -- [{topic, subscribers}] por patrón
- Los payloads son datos (texto/número/bool/lista/mapa/bytes); una task o un
secretes error, nunca una degradación silenciosa. Los topics publicados son literales — un glob enbus_publishes error (los globs son debus_subscribe). - Acotado por suscriptor en cantidad y bytes:
bus_subscribe("t", {"max_queue": 1024, "max_queue_bytes": 16777216, "on_full": "drop_oldest"})."drop_oldest"es el default (un suscriptor lento nunca frena al publicador);"error"hace fallar el próximo recv y retira la suscripción. - Una suscripción vive lo que vive su intérprete: al terminar el handler/agente se retira — nadie llena una cola que nadie lee.
La ruta SSE que todo dashboard quiere — alimentada por el bus, con el heartbeat a cargo del server:
route "GET /events"
stream
let sub be bus_subscribe("agent.*")
while true
let ev be bus_recv(sub, 25)
when ev != nothing
send ev["data"] as ev["topic"]
File-watch — watch§
Cambios en disco como handle con eventos (engine v0.6.9+), en el mismo hub que procesos, sockets y bus — así un solo select cubre "cambió un archivo" y "terminó el build". Gate: require file(path) — mirar un árbol es leerlo, el mismo scope file_read que list_dir.
require file("src")
require file("src/*")
require exec("cargo")
let files be watch("src", {"interval": 0.2, "ignore": ["*.tmp"]})
let build be nothing
while true
let ev be select({"files": files, "build": build}, 60)
when ev == nothing
continue
when ev["source"] == "watch" -- {type, path, is_dir}
when build != nothing
proc_close(build) -- mata el build anterior, árbol entero
set build to proc_spawn("cargo", ["build"])
otherwise when ev["type"] == "exit"
print("build exit " + text(ev["data"]["exit_code"]))
| Builtin | Devuelve |
|---|---|
watch(path, opts?) | handle — también lo acepta select, etiquetado source: "watch" |
watch_recv(h, timeout?) | el próximo evento, o nothing al timeout |
watch_stats(h) | {path, recursive, interval, entries, scans, queued, dropped} |
watch_close(h) | libera el handle y apaga el scanner. Idempotente |
Los eventos son {type: "create" | "modify" | "delete", path, is_dir}. path usa / y queda relativa si la raíz era relativa. Un rename es un delete más un create. Los directorios sólo emiten create/delete (su mtime cambia con cada hijo — ruido). No se emite nada por lo que ya existía al arrancar el watch. opts: recursive (true), interval en segundos (0.5, piso 0.02), ignore (nombres de entrada, glob * permitido; default [".git", "node_modules", "target"] — pasá [] para incluirlos), max_entries (100 000: por encima watch() falla, y un árbol que lo supera después hace que el próximo recv falle y retira el handle), max_queue (4096; el overflow descarta lo más viejo y lo cuenta en dropped).
Cómo funciona, honestamente. Es polling con snapshot — mtime + tamaño por entrada, comparados cada interval — no inotify, FSEvents ni ReadDirectoryChangesW. Eso compra semántica idéntica en Linux, macOS y Windows, sin límites de watches del kernel y sin dependencia nueva; cuesta un recorrido del directorio por tick (de ahí ignore y max_entries) y una latencia igual a interval. Un cambio hecho y deshecho dentro de un mismo intervalo no se ve: recibís el estado, no la historia. SYNSEMA_WATCH_MAX (64, techo 1024) acota los watches vivos por intérprete; como los procesos, cada watch muere con el intérprete que lo abrió.
La terminal — term§
La terminal propia del programa como handle con eventos (engine v0.6.11+), en el mismo hub que procesos, sockets, bus y watches — así un solo select cubre "el humano tocó una tecla" y "el subagente publicó un resultado". Gate: require stdin. Es lo que necesita un chat CLI, una paleta de comandos o un editor de línea con historial: read_line te da una línea cocinada por vez; term_open te da cada tecla en el momento en que se pulsa.
require stdin
require stdout
let commands be ["/help", "/history", "/approve", "/model", "/quit"]
let term be term_open()
when term == nothing -- pipe / CI / serve / test: no hay terminal
let line be read_line("> ") -- el mismo programa, entrada plana
otherwise
let buf be ""
let sub be bus_subscribe("agent.*")
while true
let matches be where(commands, (c) => starts_with(c, buf))
term_write(term, "\r\x1b[2K> " + buf + " " + join(matches, " "))
let ev be select({"keys": term, "agent": sub}, 60)
when ev == nothing
continue
when ev["source"] == "bus"
term_write(term, "\r\n[agent] " + json_encode(ev["data"]) + "\r\n")
otherwise when ev["type"] == "eof"
stop
otherwise when ev["type"] == "paste"
set buf to buf + ev["text"]
otherwise when ev["key"] == "char"
set buf to buf + ev["text"]
otherwise when ev["key"] == "backspace"
set buf to slice(buf, 0, len(buf) - 1)
otherwise when ev["key"] == "tab" and len(matches) == 1
set buf to matches[0]
otherwise when ev["key"] == "enter" and ev["alt"]
set buf to buf + "\n" -- Alt+Enter = nueva línea
otherwise when ev["key"] == "enter"
stop
term_close(term)
Cada tecla es un evento, así que el menú de / filtra mientras se tipea; el loop sigue vivo mientras un subagente habla por el bus.
| Builtin | Devuelve |
|---|---|
term_open(opts?) | handle — también lo acepta select, etiquetado source: "term"; nothing cuando stdin o stdout no son una TTY, bajo serve, synsema test/conform y en WebAssembly |
term_recv(h, timeout?) | el próximo evento, o nothing al vencer (default 30 s — pasá uno explícito cuando esperás a un humano) |
term_size(h) | {cols, rows} |
term_write(h, text) | escribe a stdout ya, sin pasar por el buffer de print; se permiten escapes ANSI (cursor, borrar línea, colores) |
term_stats(h) | {kitty, paste, ansi, keys, queued, dropped} |
term_close(h) | restaura la terminal y apaga el lector. Idempotente — y el runtime lo hace igual al soltar el handle (fin del programa, error de runtime, stop, pánico) |
Eventos (todos etiquetados source: "term", handle, name):
{type: "key", key, text, ctrl, alt, shift}—keyes un nombre:"char"(ytextes el carácter, con Shift ya aplicado:"A"),"enter","tab","backtab","backspace","delete","insert","escape","up"/"down"/"left"/"right","home"/"end","pageup"/"pagedown","f1"…"f12". Tab y Enter nunca llegan como"\t"/"\r". Conctrl: true,textes la letra minúscula (Ctrl+O→{key: "char", text: "o", ctrl: true}). Sólo pulsaciones (sin eventos de release/repeat).{type: "paste", text}— un pegado (bracketed paste) como un solo evento, saltos de línea incluidos (terminales Unix; en Windows un pegado llega como ráfaga de eventoskey—term_stats(h)["paste"]dice cuál aplica).{type: "resize", cols, rows},{type: "focus", gained}(cuando la terminal los emite).{type: "eof"}— stdin se cerró; se entrega una vez y el handle desaparece (comoread_line→nothing, no es un error).
opts: paste (true, bracketed paste), kitty (true: pide el kitty keyboard protocol cuando la terminal lo soporta — Windows Terminal, kitty, WezTerm, foot, Ghostty — que es lo que hace distinguible Shift+Enter; sin él Shift+Enter es un Enter común, así que hacé de Alt+Enter tu atajo multilínea y de Shift+Enter un bonus), ctrl_c ("exit": Ctrl+C restaura la terminal y termina el proceso con código 130, como haría SIGINT — el raw mode si no se lo tragaría; "key": llega como {key: "char", text: "c", ctrl: true} y el programa decide), max_queue (16384, descarta los más viejos).
Lo que se sostiene con una terminal abierta: print/log siguen funcionando (el runtime escribe \r\n por vos, sin escalera); ask de texto libre, approve y confirm funcionan — el handler de consola suspende el raw mode mientras el humano contesta y lo reanuda; una terminal por proceso — un agente spawneado no puede abrirla mientras la tiene el programa principal. Restaurar la terminal es tarea del runtime, nunca del script: lo que activaste vos (cursor oculto, colores) lo deshacés vos.
Una sola espera — select§
let ev be select({"sock": socket, "child": child, "feed": feed, "cancel": sub}, 60)
targets es una lista de handles o un mapa nombre → handle — cualquier mezcla de ws_connect, socket, proc_spawn, bus_subscribe, watch, term_open. Devuelve el primer evento listo, etiquetado con source ("ws" | "proc" | "bus" | "watch" | "term"), handle y name (forma mapa), más los campos propios del evento (type/data en sockets y procesos; topic/data/timestamp en el bus). nothing al vencer o cuando todos los targets desaparecieron. Equitativo (round-robin), duerme en el poller del kernel mientras no hay nada, falla rápido con un error atrapable cuando un target muere por error de protocolo/cola, y despierta al instante ante una cancelación. ws_select también acepta cualquier handle (conserva su etiqueta conn); proc_select es la misma espera restringida a procesos.
Todo junto — una conexión, un hijo, una suscripción, un loop:
require serve(8080)
require exec("sh")
serve on 8080
route "GET /console"
socket
let sub be bus_subscribe("agent.*")
let child be proc_spawn("sh", ["-c", "for i in 1 2 3; do echo step $i; sleep 1; done"])
while true
let ev be select({"ui": socket, "child": child, "bus": sub}, 60)
when ev == nothing
ws_send(socket, "idle")
otherwise when ev["name"] == "ui"
when ev["type"] == "close"
stop
otherwise
ws_send(socket, "you said " + ev["data"])
otherwise when ev["name"] == "child"
when ev["type"] == "exit"
ws_send(socket, "exit " + text(ev["data"]["exit_code"]))
otherwise
ws_send(socket, ev["type"] + ": " + ev["data"])
otherwise
ws_send(socket, "[" + ev["topic"] + "] " + json_encode(ev["data"]))
La versión completa, con un feed SSE /events y un POST /announce que publica, es examples/agent_console.syn en el repo del engine.
Timeouts y cancelación§
Por default un handler no tiene límite de tiempo. Declaralo en el bloque serve (default de todas las rutas) y/o por ruta:
serve on 8080
timeout 30 -- segundos, default de todas las rutas
route "POST /think"
timeout 300 -- override de ruta: al principio del cuerpo, una sola vez
give reason "…" given request.json
route "GET /events"
timeout none -- esta ruta se exime del default del bloque
stream
…
- Rutas sized: al vencer, el cliente recibe
504 {"error": "gateway timeout: the handler exceeded 30s", "status": 504}conConnection: close, y el handler queda cancelado — se corta en su próximo statement o espera (log:handler cancelled: request timed out after 30s). El tiempo en cola esperando un worker cuenta. stream/socket: el timeout es la vida máxima de la conexión — el SSE termina conevent: error{"error": "cancelled: request timed out after 30s"}; un socket recibeClose 1001con ese motivo.- La cancelación es cooperativa y no se cura. El intérprete la chequea antes de cada statement y dentro de cada espera (
sleep,ws_recv,select,proc_*,bus_recv,wait_for,run), levantandocancelled: <motivo>. Untry/recoverpuede observarla, pero el próximo statement vuelve a fallar — limpiá y salí. Los hijos derun/proc_spawnse matan. timeoutes una cláusula. En cualquier otro lugar (dentro de unwhen, una task, el top-level) es un error de runtime, no un no-op silencioso. Todavía no se soporta dentro de un grupoexport routes— ponelo en el bloque serve.- La desconexión del cliente en una ruta sized no se detecta (hyper no la expone sin body pendiente); streams y sockets sí la detectan.
Shutdown ordenado§
Ante SIGINT (Ctrl-C) — y SIGTERM en Unix, lo que mandan docker stop y systemd:
1. El listener se cierra: las conexiones nuevas se rechazan; un request sobre una conexión keep-alive abierta recibe 503 {"error": "server shutting down"} + Retry-After: 2. 2. Log: [serve] shutting down: draining N in-flight request(s), grace 10s (SYNSEMA_SHUTDOWN_GRACE). El cron deja de programar, los agentes vivos se cancelan (agent_stop, motivo server shutting down), streams y sockets se cancelan de inmediato (SSE event: error cancelled: server shutting down; sockets Close 1001). 3. Los requests sized en vuelo tienen hasta SYNSEMA_SHUTDOWN_GRACE segundos (default 10; 0 = inmediato); lo que queda se cancela; [serve] stopped; código de salida 0 (fue pedido). Un segundo Ctrl-C durante el drain sale ya con 130. 4. Con varios bloques serve on el proceso sale cuando drenó el último.
synsema run no cambia (no tiene listener). Bajo systemd poné TimeoutStopSec por encima de la gracia.
Desde el programa (engine v0.6.18+): shutdown(reason?) pide exactamente este drain — desde una ruta, un bloque socket, un job de cron o un agente. El log dice [serve] shutdown requested by the program: <motivo>, después corren los pasos de arriba y el proceso sale con 0. Idempotente (una segunda llamada no hace nada); error bajo synsema run (un programa run termina con su top level — stop sale de un loop o de una task) y antes de que algo escuche; sin capability (apagarse no es un recurso del host); un secret como motivo se rechaza (el motivo se loguea). Es como una app de escritorio se apaga cuando se cierra su última ventana — Tu app en el escritorio.
Agentes bajo serve§
Un agente spawneado desde un handler o un tick de cron recibe el mismo cableado que un tick de cron — state_*, la base compartida, la cola de approvals, cron, el bus, la memoria declarada — no una isla con builtins frescos. Corre bajo el techo del host de synsema serve --sandbox o --cap-set "<lista>" (un require adentro nunca excede lo que fijó el operador, exactamente como bajo run; --sandbox acá es stdout,time + serve), y un shutdown ordenado lo detiene.
agents() -- [{id, name, state, error, started_at, finished_at}]
agent_stop(id, motivo?) -- true si estaba vivo; el agente termina en estado "stopped"
id es la instancia (Researcher_0), name el agente declarado; estados idle · starting · working · waiting · done · error · stopped. agent_stop es cancelación cooperativa: el agente levanta cancelled: <motivo> antes de su próximo statement y cualquier espera despierta al instante — un agente en while true ya no es inmortal. Los dos funcionan bajo run y serve, sin capability (introspección del propio proceso). Ojo: give agents() pagina como todo give <lista> — leé items, o give {"agents": agents()}.
Knobs del server (entorno del proceso, no .env)§
synsema init los lista, comentados, en .env.example — con la advertencia de que los lee el entorno del proceso (export, Environment= de systemd, -e de Docker):
| Variable | Default | Qué |
|---|---|---|
SYNSEMA_SERVE_WORKERS | cores (mín. 2) | pool de intérpretes para handlers sized (streams/sockets tienen hilo propio) |
SYNSEMA_SHUTDOWN_GRACE | 10 | segundos de drain ante SIGINT/SIGTERM; 0 = inmediato |
SYNSEMA_SSE_KEEPALIVE | 15 | segundos sin frames antes de un comentario SSE : keepalive; 0 apaga |
SYNSEMA_WS_SERVER_PING | 30 | intervalo del ping del server en rutas socket; sin pong en 2 intervalos → close; 0 apaga |
SYNSEMA_WS_SUBPROTOCOLS | — | subprotocolos que el server puede acordar (coma-separados); sin la variable no acuerda ninguno |
SYNSEMA_WS_MAX_MESSAGE | 16MB | tamaño máximo de un mensaje entrante en rutas socket (techo 64MB) |
SYNSEMA_WS_MAX_CONNS | 4096 | handles WebSocket vivos por intérprete (ws_connect + entrantes) |
SYNSEMA_PROC_MAX | 64 | hijos proc_spawn vivos por intérprete (techo 1024) |
SYNSEMA_WATCH_MAX | 64 | handles watch vivos por intérprete (techo 1024; cada uno es un hilo scanner) |
SYNSEMA_RUN_PROGRAM_MAX_DEPTH | 4 | profundidad máxima de anidamiento de run_program dentro de run_program (v0.6.14+) |
Correr Synsema que generaste — run_program (v0.6.14+)§
Una app agéntica que escribe Synsema (una tool que emite el LLM, el plugin de un usuario, un loop que se auto-repara) lo corre con run_program(source, opts) — un proceso hijo del mismo binario bajo un techo ∩ el del padre, con su propio env/cwd/timeout y su audit devuelto como un valor (require sandbox_run). Sin exec, sin synsema en el PATH, sin parsear stderr; el hijo nunca puede exceder al padre. Es la forma segura de "correr el código que generó el modelo" — modelo completo en Sandbox, firma en Builtins.
Lo que a propósito no está§
- Un emulador de terminal o un framework de TUI.
pty: truele da al hijo una terminal real; dibujarla (parsear VT, un modelo de pantalla) es tarea del consumidor — xterm.js en un navegador,strip_ansipara un agente.term_opente da a vos teclas y tamaño, nada más: un editor de línea, una paleta de/o un menú son unas decenas de líneas de Synsema sobreselect(el ejemplo de arriba), no una biblioteca de widgets en el runtime. - Un notificador nativo del filesystem.
watchhace polling sobre un snapshot a propósito: una sola semántica en todos los SO, sin límites del kernel, sin crate*-sys. Si algún día importa latencia sub-segundo sobre un árbol enorme, sería unopts.backend, no una API nueva. - Un bus entre procesos. Redis pub/sub va a reusar
bus_*con unopts.backend. - Una tabla global de sockets. Empujar a "todos los sockets" desde cualquier lado sería una puerta de escape del aislamiento por request; el camino es el bus.
Mirá Servidor HTTP, Cliente WebSocket, Multi-agente y Deploy.