feat(process): persistent process tier on astrid:[email protected]#12
Conversation
Folds the reattachable-background-process design (RFC: host_abi) into the still-draft astrid:[email protected] contract — no version bump, since nothing has launched. Adds, alongside the ephemeral spawn/spawn-background/process-handle: - spawn-persistent -> opaque principal-scoped process-id (host-owned registry, survives instance churn); attach (re-materialise the handle, detach-on-drop); id-keyed read-logs/read-since/write-stdin/close-stdin/signal/wait/stop/ release-process; list-processes/status/status-many; durable watch/unwatch. - New types: process-id, process-phase, log-cursor, log-stream, log-chunk, process-info, overflow-policy, resource-limits. - spawn-request gains limits + persistent-only fields (label, keep-stdin-open, overflow, log-ring-bytes, max-lifetime-ms, idle-timeout-ms, exit-retention-ms). - error-code gains no-such-process / registry-full / invalid-id / persist-unsupported; process-signal gains stop (SIGSTOP) / cont (SIGCONT); process-info carries cpu-ms / mem-bytes-peak. The full surface is the contract; deferred behaviour is tagged NOT YET IMPLEMENTED / ENFORCED / POPULATED so the shapes are fixed before 1.0 freezes. Parses clean under scripts/validate-wit.sh.
There was a problem hiding this comment.
Code Review
This pull request introduces a persistent tier for background processes in the host/[email protected] interface, adding functions like spawn-persistent, attach, and read-since, as well as resource limits and durable lifecycle events. The review identifies several robustness and security issues: returning string-based logs in stop and read-logs can cause WebAssembly guest-host boundary traps on non-UTF-8 output; returning a distinct invalid-id error code undermines the timing-oracle defense; max-cpu-ms should use seconds to match POSIX RLIMIT_CPU granularity; and base32-encoded process IDs used as topic suffixes in watch may violate IPC topic character constraints.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
Pull request overview
This PR extends the canonical astrid:[email protected] host WIT contract to add a persistent process tier, enabling host-owned processes that survive pooled instance resets and can be reattached via a principal-scoped process-id.
Changes:
- Adds persistent-tier APIs (
spawn-persistent,attach, id-keyed log/stdio/control ops,list-processes/status/status-many,watch/unwatch) alongside the existing ephemeral tier. - Introduces new persistent-tier types (
process-id,process-phase, cursoredread-sincetypes,process-info,overflow-policy,resource-limits) and expandsspawn-requestwith limits + persistent-only fields. - Extends
error-codeandprocess-signalto support persistent-tier lifecycle/error semantics and additional signals.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…d, max-cpu-secs, topic-safe id, no reaped phase, doc byte-fidelity Review on #12: - stop returns exit-info (not kill-result): stop reaps the id, so callers read-since for byte-faithful final output before stopping; avoids handing back lossy UTF-8 buffers. - Remove invalid-id error: a distinct code for malformed-vs-nonexistent is a free structural oracle. Malformed ids now collapse to no-such-process (host may fast-reject internally; guest-visible result identical). - max-cpu-ms -> max-cpu-secs: RLIMIT_CPU only enforces second granularity. - process-id is lowercase base32, no padding, so it is a valid IPC topic suffix ([a-z0-9._-]+) usable directly by watch. - Drop process-phase::reaped: once reaped the id resolves to no-such-process, so exited is the terminal observable phase; reap reason rides the watch event. - Document read-logs as the single-shared-ring drain (multi-reader -> read-since) and the read-logs/process-result UTF-8 lossiness vs read-since byte-fidelity; attach-handle wait(none) is rejected with invalid-input.
## Linked Issue Closes #866 ## Summary Implements the **persistent-process tier** of `astrid:[email protected]` (merged contract: astrid-runtime/wit#12; design: astrid-runtime/rfcs#22). A capsule can now spawn a background child that **outlives the pooled, stateless WASM instance** that started it — previously an ephemeral `process-handle` is reaped when its instance resets on return to the dynamic pool, so the split `spawn → read → stop` pattern across tool invocations was impossible. The core is a host-owned `PersistentProcessRegistry`, cloned into every pooled `HostState` exactly like the cancellation `ProcessTracker`, so a `process-id` survives instance churn. It owns the child (spawned on the daemon runtime under the same `bwrap`/Seatbelt sandbox as the ephemeral tier), its per-stream log rings, and its stdin pipe. ## Changes - **`wit` submodule → merged main** (`8946e98`); committed `wit-staging/` ships the contract on the published-crate path (zero drift; `host/` byte-identical). - **`PersistentProcessRegistry`** (`engine/wasm/host/process/persistent/`, split into `mod`/`ring`/`entry`/`ids`/`config` + tests): - `spawn-persistent` — 256-bit host-minted CSPRNG `process-id`, lowercase base32 (doubles as an IPC topic suffix); registry stores only a **keyed BLAKE3 hash**, never the raw token. Refuses the **owner-fallback** principal (`persist-unsupported`). - `status` / `status-many` / `list-processes` (non-draining), `read-logs` (drain) + `read-since` (non-draining, cursor-addressed, byte-faithful `list<u8>`), `signal` (incl. `stop`/`cont`), bounded `wait`, `stop` (SIGTERM→grace→SIGKILL, frees the slot), `release-process`, `write-stdin` / `close-stdin` (via `keep-stdin-open` capture). - **Security:** every id-keyed call re-resolves the live `(principal, capsule)` vs the recorded creator; unknown / wrong-owner / wrong-capsule / reaped all collapse to `no-such-process` (no oracle). - **Lifecycle:** per-principal concurrent + retained-id caps; idle / max-lifetime / exit-retention TTLs (guest values clamped DOWN to host ceilings); per-capsule reaper task; kill-all on capsule unload / daemon graceful shutdown. A per-process monitor task owns the `Child` (`kill_on_drop` backstop); reaping happens outside the registry lock. - **`HostState` gains `persistent_processes`**, wired through the engine (one registry per `WasmEngine`, cloned into the pool + the lifecycle/hooks construction sites). - **macOS works by default** — the only weaker property is orphan-on-daemon-*hard-crash* (Seatbelt has no `die-with-parent`); a graceful shutdown / unload reaps. Documented, not gated behind `persist-unsupported`. ## Deferred (honest) - `attach` (resource-handle materialisation — needs dual-typed resource-table dispatch; the id-keyed ops ARE its documented equivalent). - `watch` / `unwatch` — host-published lifecycle events raise an OPEN publish-authority question in RFC host_abi; `status` + bounded `wait` is the working polling alternative. - WIT `(NOT YET …)` items: resource-limit enforcement, `cpu-ms` / `mem-bytes-peak`, instance-local pollables. ## Test Plan ### Automated - [x] `cargo test --workspace` passes — **1990 passed, 0 failed**, incl. 3 new integration tests that spawn **real** processes through the registry (spawn/status/wait/read + owner-isolation; concurrent-cap reject + stop-reaps + slot-free; non-draining cursor `read-since`) and pure-logic unit tests for the log ring, cursor, and id minting. - [x] No new clippy warnings — the diff is `astrid-capsule` + `astrid-hooks`, both clean; the pre-existing `sys.rs:365` / `astrid-workspace` lints are untouched. ### Manual - `cargo build --workspace` green; `cargo fmt --all --check` clean. ## Checklist - [x] Linked to an issue - [x] CHANGELOG.md updated under `[Unreleased]`
## Summary Adds the ergonomic SDK surface for the **persistent-process tier** of `astrid:[email protected]` (host side: astrid-runtime/astrid#867; contract: astrid-runtime/wit#12), so capsule authors can spawn a background child that **outlives the pooled, stateless instance** that started it — unlike `process::Process`, whose kernel resource is reaped on instance reset. ## Changes - **`contracts` submodule → the merged `astrid:[email protected]` persistent tier** (wit commit `1a06cf4`, the #12 squash). Scoped deliberately to the process package: `1a06cf4` predates #11, so the `interfaces/` bundle (`astrid-sdk/wit/astrid-contracts.wit`) is untouched and the open hook-event-request PR #50 is unaffected (`sync-contracts-wit.sh --check` stays green). `astrid-sys/build.rs` restages `wit-staging/deps/astrid-process/`; the raw `wit_process::*` bindings regenerate. - **`astrid-sdk/src/process.rs` ergonomic layer:** - `Command` gains the persistent-only builder knobs (`label`, `keep_stdin_open`, `overflow`, `log_ring_bytes`, `max_lifetime`, `idle_timeout`, `exit_retention`, `limits`) and a `spawn_persistent()` terminal → `PersistentProcess`. - `PersistentProcess` (keyed by an opaque `ProcessId`): `status`, `read_logs` (drain), `read_since` (non-draining cursor, byte-faithful `LogChunk`), `write_stdin`, `close_stdin`, `signal`, `wait` (bounded), `stop` (consumes), `release` (consumes). - Module fns: `attach(id)` — an id-wrapper so reattach from a later invocation works **without** the host's deferred `attach` resource fn (the first id-keyed call validates ownership) — plus `list` and `status_many`. - New types: `ProcessId`, `ProcessInfo`, `ProcessPhase`, `LogStream`, `LogCursor`, `LogChunk`, `OverflowPolicy`, `ResourceLimits`. `Signal` gains `Stop` / `Cont`. - **`Command::into_wit` updated** for the new `SpawnRequest` fields (a required fix — the WIT bump added 8 fields). ## Not exposed (host-deferred) `watch` / `unwatch` (open publish-authority RFC question — poll via `status` + bounded `wait`) and resource-limit enforcement (`limits` is plumbed through but the host does not enforce it yet). ## Test Plan - [x] `cargo build --workspace` + `cargo test --workspace` green - [x] No new clippy warnings — my diff is `astrid-sdk` (clean); the 2 `astrid-sdk-macros` backtick warnings are pre-existing and untouched - [x] `cargo fmt --all --check` clean - [x] `scripts/sync-contracts-wit.sh --check` passes (interfaces bundle untouched — #50 unaffected)
## Summary Mirrors the Rust SDK and the host `astrid:[email protected]` **persistent-process tier** (host: astrid-runtime/astrid#867; Rust SDK: astrid-runtime/sdk-rust#53; contract: astrid-runtime/wit#12), so JS/TS capsule authors can spawn a background child that **outlives the pooled, stateless instance** that started it — unlike `process.BackgroundProcessHandle`, whose kernel resource is reaped on instance reset. ## Changes - **`contracts` submodule → the merged `astrid:[email protected]` persistent tier** (wit commit `1a06cf4`, the #12 squash). Deliberately the **#12-only** commit (predates #11), so the `astrid:contracts` events bundle (`astrid-contracts.wit` / generated `contracts.ts`) is untouched (`sync-contracts-wit.sh --check` stays green) and unrelated open SDK PRs are unaffected. On `main`, `astrid-build` reads the host WIT **live from `contracts/host/`** (`CANONICAL_WIT_DIR`), so the bump is all ComponentizeJS needs. - **`wit-imports.d.ts`** (hand-written host ABI types): persistent types + free-function declarations on `astrid:process/[email protected]` — `spawnPersistent`, `attach`, `listProcesses`, `status`, `statusMany`, `readLogs`, `readSince`, `writeStdin`, `closeStdin`, `signal`, `wait`, `stop`, `releaseProcess`, `watch`, `unwatch`; `ProcessInfo`, `ProcessPhase`, `LogStream`, `LogCursor`, `LogChunk`, `OverflowPolicy`, `ResourceLimits`; `ErrorCode` gains `no-such-process` / `registry-full` / `persist-unsupported`; `ProcessSignal` gains `stop` / `cont`; `SpawnRequest` gains the 8 persistent fields. - **`process.ts`** ergonomic layer: `spawnPersistent(cmd, args, options)` + the persistent `SpawnPersistentOptions` knobs; `PersistentProcess` class (`status` / `readLogs` (drain) / `readSince` (non-draining cursor → byte-faithful `LogChunkResult`; `logCursorStart()`) / `writeStdin` / `closeStdin` / `signal` / `wait` (bounded) / `stop` (consumes — SIGTERM→grace→SIGKILL) / `release`); module fns `attach(id)` (id-wrapper reattach — works without the host's deferred `attach` resource fn), `listProcesses`, `statusMany`; ergonomic types. `buildSpawnRequest` updated for the new `SpawnRequest` fields (required). - **`index.ts`**: top-level re-exports for the new public types. ## Not exposed (host-deferred) `watch` / `unwatch` (open publish-authority RFC question — poll via `status` + bounded `wait`) and resource-limit enforcement (`limits` is plumbed through but the host doesn't enforce it yet). ## Test Plan - [x] `npm run build` (tsc) green across the workspace - [x] **End-to-end:** the `examples/test-capsule` **componentizes** against the persistent WIT — ComponentizeJS produced a 12.96 MB WASM with **169 host imports**, proving the new host functions wire through the JS build path - [x] `scripts/sync-contracts-wit.sh --check` passes (events bundle untouched) - [x] Git diff scoped to 5 files (contracts pointer + 3 SDK sources + CHANGELOG); no `contracts.ts` drift
Summary
Adds the persistent process tier to
host/[email protected]— the canonical WIT for the design folded into the host-ABI RFC (rfcs#22, "Process: ephemeral and persistent tiers"). No version bump:astrid:processis still draft (pre-launch), so the persistent surface belongs in@1.0.0rather than an evolution file.Why
Under the dynamic instance pool a
spawn-backgroundprocess-handleis owned by the spawning instance and reaped when that instance resets — so the start-then-read-then-stop split-tool pattern (shell'sspawn_background_process/read_process_logs/kill_process) is impossible. The persistent tier returns an opaque, principal-scopedprocess-idbacked by a host-owned registry that survives instance churn, reattachable from any later invocation. Generalizes beyond shell to MCP-stdio hosts, dev-server supervisors, and log tailers.What's added
Alongside the frozen ephemeral
spawn/spawn-background/process-handle:spawn-persistent→process-id;attach(re-materialise the handle, detach-on-drop); id-keyedread-logs/read-since/write-stdin/close-stdin/signal/wait/stop/release-process;list-processes/status/status-many; durablewatch/unwatch.process-id,process-phase,log-cursor,log-stream,log-chunk,process-info,overflow-policy,resource-limits.spawn-requestgainslimits+ persistent-only fields (label, keep-stdin-open, overflow, log-ring-bytes, max-lifetime-ms, idle-timeout-ms, exit-retention-ms).error-codegainsno-such-process/registry-full/invalid-id/persist-unsupported;process-signalgainsstop/cont(SIGSTOP/SIGCONT);process-infocarriescpu-ms/mem-bytes-peak.Contract vs implementation
The file is the full contract; deferred behaviour is tagged
(NOT YET IMPLEMENTED)/(NOT YET ENFORCED)/(NOT YET POPULATED)so the shapes are fixed before 1.0 freezes. The host stubs the persistent functions, stdin capture, real pollables, resource-limit enforcement, and usage population until they land (core → SDKs → shell follow).Validation
scripts/validate-wit.shparses everyhost/*.witclean (incl. this file). One genuine WIT catch:streamis a reserved keyword, soread-since's stream selector is namedwhich-stream.Open question carried from the RFC: whether
watch's host-publishedastrid.process.v1.*topics need a manifest[publish]or are a kernel-authored class (ties to topic-grammar #809);watchmay be cut forstatus+bounded-waitpolling.