Skip to content

feat(process): persistent process tier on astrid:[email protected]#12

Merged
joshuajbouw merged 2 commits into
mainfrom
feat/process-persistent-tier
Jun 6, 2026
Merged

feat(process): persistent process tier on astrid:[email protected]#12
joshuajbouw merged 2 commits into
mainfrom
feat/process-persistent-tier

Conversation

@joshuajbouw

Copy link
Copy Markdown
Contributor

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:process is still draft (pre-launch), so the persistent surface belongs in @1.0.0 rather than an evolution file.

Why

Under the dynamic instance pool a spawn-background process-handle is owned by the spawning instance and reaped when that instance resets — so the start-then-read-then-stop split-tool pattern (shell's spawn_background_process/read_process_logs/kill_process) is impossible. The persistent tier returns an opaque, principal-scoped process-id backed 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-persistentprocess-id; 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/cont (SIGSTOP/SIGCONT); process-info carries cpu-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.sh parses every host/*.wit clean (incl. this file). One genuine WIT catch: stream is a reserved keyword, so read-since's stream selector is named which-stream.

Open question carried from the RFC: whether watch's host-published astrid.process.v1.* topics need a manifest [publish] or are a kernel-authored class (ties to topic-grammar #809); watch may be cut for status+bounded-wait polling.

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread host/[email protected] Outdated
Comment thread host/[email protected] Outdated
Comment thread host/[email protected] Outdated
Comment thread host/[email protected] Outdated
Comment thread host/[email protected]

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, cursored read-since types, process-info, overflow-policy, resource-limits) and expands spawn-request with limits + persistent-only fields.
  • Extends error-code and process-signal to support persistent-tier lifecycle/error semantics and additional signals.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread host/[email protected] Outdated
Comment thread host/[email protected]
Comment thread host/[email protected] Outdated
Comment thread host/[email protected] Outdated
…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.
@joshuajbouw joshuajbouw merged commit 1a06cf4 into main Jun 6, 2026
1 check passed
@joshuajbouw joshuajbouw deleted the feat/process-persistent-tier branch June 6, 2026 16:14
@joshuajbouw joshuajbouw restored the feat/process-persistent-tier branch June 6, 2026 16:14
@joshuajbouw joshuajbouw deleted the feat/process-persistent-tier branch June 6, 2026 16:14
joshuajbouw added a commit to astrid-runtime/astrid that referenced this pull request Jun 6, 2026
## 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]`
joshuajbouw added a commit to astrid-runtime/sdk-rust that referenced this pull request Jun 7, 2026
## 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)
joshuajbouw added a commit to astrid-runtime/sdk-js that referenced this pull request Jun 7, 2026
## 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants