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:
execute("jobs --json") / execute("wait %1") / execute("kill %1") — build a shell command string and parse the result back.
- 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.
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-128is the wholeKernelClienttrait: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(), nojob_status(), nojob_wait(), nojob_cancel(), nojob_output().An embedder holding only a
KernelClienthas two options:execute("jobs --json")/execute("wait %1")/execute("kill %1")— build a shell command string and parse the result back.EmbeddedClient::kernel()(embedded.rs:84-86) →Kernel::jobs()(kernel.rs:5272-5274) → the realArc<JobManager>.Route 2 is correct and works — but it is undocumented.
docs/EMBEDDING.md's "Exported Types → Jobs" list (:872-873) namesBoundedStream,StreamStats,drain_to_stream,DEFAULT_STREAM_MAX_SIZE,JobFs— and notJobManager,JobId,JobInfo,JobStatus. The doc's entire job story (:788-824) is written as shell snippets andcat /v/jobs/...reads.The execute-lock hazard, currently unstated
execute_with_options_innertakes the kernel-wide lock for the whole call (kernel.rs:1827,acquire_execute_lockat:1477-1490). Soclient.execute("wait %1")blocks every otherexecute()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::waitdeliberately does not hold its own lock across the await (job.rs:576-609, regression-tested atjob.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 eitheracquire_execute_lock's own contention warning tells callers to "useKernel::fork()for parallelism instead of sharing" (kernel.rs:1483-1486).Kernel::fork/fork_attachedarepub(kernel.rs:1284,:1296) and share theArc<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 concreteEmbeddedClient. 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 afork(). Each is a thin delegate toJobManagerinEmbeddedClient.Document in EMBEDDING.md that
job_waitdoes not take the execute lock butexecute("wait %1")does.Size: one PR. Not breaking for
EmbeddedClientusers; 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:
JobIdis only unique within oneJobManagernext_idstarts at 1 per manager (job.rs:485,:517);session_iddisambiguates 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 job1in every session and must scope job ids itself — nothing in kaish says so. Worth a doc line at minimum.