Skip to content

KernelClient exposes no job methods (and no fork) — embedders must round-trip through shell text #242

Description

@tobert

Found by a job-system design audit for embedder/MCP use (2026-07-30); independently flagged by both kaibo casts consulted.

crates/kaish-client/src/traits.rs:48-128 is the whole KernelClient trait: execute, execute_with_vars, get_var/set_var/list_vars, tool_schemas, has_function, cancel, cwd/set_cwd, last_result, reset, ping, shutdown, blob r/w.

No jobs(), no job_status(), no job_wait(), no job_cancel(), no job_output().

An embedder holding only a KernelClient has two options:

  1. execute("jobs --json") / execute("wait %1") / execute("kill %1") — build a shell command string and parse the result back.
  2. Reach around the trait: EmbeddedClient::kernel() (embedded.rs:84-86) → Kernel::jobs() (kernel.rs:5272-5274) → the real Arc<JobManager>.

Route 2 is correct and works — but it is undocumented. docs/EMBEDDING.md's "Exported Types → Jobs" list (:872-873) names BoundedStream, StreamStats, drain_to_stream, DEFAULT_STREAM_MAX_SIZE, JobFs — and not JobManager, JobId, JobInfo, JobStatus. The doc's entire job story (:788-824) is written as shell snippets and cat /v/jobs/... reads.

The execute-lock hazard, currently unstated

execute_with_options_inner takes the kernel-wide lock for the whole call (kernel.rs:1827, acquire_execute_lock at :1477-1490). So client.execute("wait %1") blocks every other execute() on that kernel until the job finishes — an MCP server awaiting a job that way wedges its entire shell session.

The programmatic path is fine: JobManager::wait deliberately does not hold its own lock across the await (job.rs:576-609, regression-tested at job.rs:1250-1301) and never touches the execute lock. So the capability exists; it's just unreachable from the trait, and the hazard is nowhere documented.

fork() isn't on the trait either

acquire_execute_lock's own contention warning tells callers to "use Kernel::fork() for parallelism instead of sharing" (kernel.rs:1483-1486). Kernel::fork/fork_attached are pub (kernel.rs:1284, :1296) and share the Arc<JobManager> (kernel.rs:1353) — so forking is the sanctioned way to run something concurrently while a job runs. It's reachable only by downcasting to the concrete EmbeddedClient. The trait's advertised escape hatch from its own serialization is not on the trait.

Shape of the fix

Add to KernelClient: jobs() -> ClientResult<Vec<JobInfo>>, job(JobId), job_wait(JobId) -> ClientResult<Option<ExecResult>>, job_cancel(JobId), job_output(JobId) -> ClientResult<(Vec<u8>, Vec<u8>)>, job_reap(JobId), and a fork(). Each is a thin delegate to JobManager in EmbeddedClient.

Document in EMBEDDING.md that job_wait does not take the execute lock but execute("wait %1") does.

Size: one PR. Not breaking for EmbeddedClient users; adding required methods to a public trait is breaking for any out-of-tree impl — give them default impls returning a "not supported" ClientError, or accept the break (both embedders are first-party).

Note: JobId is only unique within one JobManager

next_id starts at 1 per manager (job.rs:485, :517); session_id disambiguates output file paths (job.rs:393-398) but is not part of the job's public identity. An embedder running one kernel per MCP session has a job 1 in every session and must scope job ids itself — nothing in kaish says so. Worth a doc line at minimum.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions