Merge W9D — kei-spawn drive stub
This commit is contained in:
commit
7dd5c0796d
4 changed files with 335 additions and 2 deletions
152
_primitives/_rust/kei-spawn/src/drive.rs
Normal file
152
_primitives/_rust/kei-spawn/src/drive.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
//! drive — design-as-stubbed Anthropic-API driver for `kei-spawn drive`.
|
||||
//!
|
||||
//! The `drive` subcommand is the future one-call replacement for the current
|
||||
//! two-step dance (`kei-spawn spawn` → orchestrator pastes Agent invocation).
|
||||
//! Wiring it to a live Anthropic HTTP endpoint is a breaking change (adds
|
||||
//! `reqwest` + tokio + a secrets contract), so v0.1 ships a stub: the
|
||||
//! pipeline, types, and trait are defined; the HTTP impl returns
|
||||
//! `NotImplemented` via `ManualDriver`.
|
||||
//!
|
||||
//! Exit-code contract (mirrors `kei-runtime::InvokeError::NotImplemented`):
|
||||
//! - 64 (EX_USAGE range) when the driver yields `NotImplemented`
|
||||
//! - 1 on spawn failure (same as `kei-spawn spawn`)
|
||||
//! - 0 only when a real driver returns Ok (HttpDriver future path)
|
||||
//!
|
||||
//! Constructor Pattern: one trait + two zero-state impls + one helper fn.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Success envelope for a future `HttpDriver` (and the contract
|
||||
/// `ManualDriver` deliberately never fulfils).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AgentResult {
|
||||
pub agent_id: String,
|
||||
pub transcript: String,
|
||||
pub finish_reason: String,
|
||||
}
|
||||
|
||||
/// Errors surfaced from driver invocation. `NotImplemented` is retained as
|
||||
/// the v0.1 escape hatch; `Transport` is reserved for the HTTP impl.
|
||||
#[derive(Debug)]
|
||||
pub enum DriveError {
|
||||
NotImplemented { reason: String },
|
||||
Transport { message: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DriveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotImplemented { reason } => {
|
||||
write!(f, "kei-spawn drive: {reason}")
|
||||
}
|
||||
Self::Transport { message } => {
|
||||
write!(f, "kei-spawn drive transport: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DriveError {}
|
||||
|
||||
/// Abstraction over "how does an agent invocation actually happen."
|
||||
///
|
||||
/// v0.1 has one impl: `ManualDriver` (prints instructions, returns
|
||||
/// `NotImplemented`). Future: `HttpDriver` backed by `reqwest` +
|
||||
/// `KEI_ANTHROPIC_KEY` + POST `https://api.anthropic.com/v1/messages`.
|
||||
pub trait AnthropicDriver {
|
||||
fn invoke(
|
||||
&self,
|
||||
prompt: &str,
|
||||
subagent_type: &str,
|
||||
isolation: Option<&str>,
|
||||
) -> Result<AgentResult, DriveError>;
|
||||
}
|
||||
|
||||
/// v0.1 driver — returns `NotImplemented` unconditionally.
|
||||
///
|
||||
/// Intentional: lets `kei-spawn drive` ship a complete CLI surface
|
||||
/// (help, argument parsing, JSON emission) before the HTTP dep is taken.
|
||||
pub struct ManualDriver;
|
||||
|
||||
impl AnthropicDriver for ManualDriver {
|
||||
fn invoke(
|
||||
&self,
|
||||
_prompt: &str,
|
||||
_subagent_type: &str,
|
||||
_isolation: Option<&str>,
|
||||
) -> Result<AgentResult, DriveError> {
|
||||
Err(DriveError::NotImplemented {
|
||||
reason: not_implemented_message(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder for the future HTTP-backed driver.
|
||||
///
|
||||
/// Deliberately kept dep-free: adding `reqwest` + tokio here would force a
|
||||
/// breaking change on every consumer of `kei-spawn` today. When the HTTP
|
||||
/// impl lands, this struct gains fields (`api_key`, `endpoint`, `client`)
|
||||
/// and the `invoke` body is replaced.
|
||||
pub struct HttpDriver;
|
||||
|
||||
impl AnthropicDriver for HttpDriver {
|
||||
fn invoke(
|
||||
&self,
|
||||
_prompt: &str,
|
||||
_subagent_type: &str,
|
||||
_isolation: Option<&str>,
|
||||
) -> Result<AgentResult, DriveError> {
|
||||
Err(DriveError::NotImplemented {
|
||||
reason: "HttpDriver not wired in v0.1 — add reqwest + tokio in a dedicated PR"
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical stderr message for the v0.1 stub. Kept as a fn so both the
|
||||
/// driver impl and the CLI layer emit the exact same string (and so tests
|
||||
/// can assert on one fixture).
|
||||
pub fn not_implemented_message() -> String {
|
||||
"HTTP Anthropic-API integration not yet wired; use spawn then manual \
|
||||
Agent-tool invocation (see printed instructions)"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Drive helper — orchestrator-facing entry that dispatches to a driver.
|
||||
///
|
||||
/// Kept thin on purpose: the real work (prepare + ledger fork) happens in
|
||||
/// `spawn_from_task`. `drive` only layers the driver call on top.
|
||||
pub fn drive_with<D: AnthropicDriver>(
|
||||
driver: &D,
|
||||
prompt: &str,
|
||||
subagent_type: &str,
|
||||
isolation: Option<&str>,
|
||||
) -> Result<AgentResult, DriveError> {
|
||||
driver.invoke(prompt, subagent_type, isolation)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manual_driver_returns_not_implemented() {
|
||||
let d = ManualDriver;
|
||||
let err = d.invoke("p", "code-implementer", Some("worktree")).unwrap_err();
|
||||
match err {
|
||||
DriveError::NotImplemented { reason } => {
|
||||
assert!(reason.contains("HTTP"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected NotImplemented, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_driver_also_not_implemented_in_v01() {
|
||||
let d = HttpDriver;
|
||||
assert!(matches!(
|
||||
d.invoke("p", "x", None),
|
||||
Err(DriveError::NotImplemented { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -25,9 +25,14 @@
|
|||
//! owns git state. kei-spawn only writes into `tasks/<agent-id>/` and invokes
|
||||
//! `kei-ledger` (which itself only writes to SQLite).
|
||||
|
||||
pub mod drive;
|
||||
pub mod ledger_sh;
|
||||
pub mod spawn;
|
||||
pub mod verify;
|
||||
|
||||
pub use drive::{
|
||||
drive_with, not_implemented_message, AgentResult, AnthropicDriver, DriveError, HttpDriver,
|
||||
ManualDriver,
|
||||
};
|
||||
pub use spawn::{spawn_from_task, SpawnOutput};
|
||||
pub use verify::{verify_agent, VerifyOutput};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,26 @@
|
|||
//! kei-spawn — CLI dispatcher.
|
||||
//!
|
||||
//! Three subcommands:
|
||||
//! Four subcommands:
|
||||
//! - `spawn <task.toml>` — prepare invocation + ledger fork, emit JSON
|
||||
//! - `drive <task.toml>` — spawn + attempt driver invocation (v0.1: stub,
|
||||
//! returns exit 64 NotImplemented after emitting SpawnOutput JSON)
|
||||
//! - `verify <agent-id> <worktree>` — run verify pipeline, update ledger
|
||||
//! - `list-pending` — forward `kei-ledger list --status running`
|
||||
//!
|
||||
//! Exit codes:
|
||||
//! 0 success (spawn, verify-clean, list-pending)
|
||||
//! 1 generic failure (any Err from the pipeline)
|
||||
//! 2 verify-failed (capabilities failed but pipeline ran)
|
||||
//! 64 drive NotImplemented (v0.1 stub path)
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use kei_spawn::{ledger_sh, spawn_from_task, verify_agent};
|
||||
use kei_spawn::{
|
||||
drive_with, ledger_sh, not_implemented_message, spawn_from_task, verify_agent, DriveError,
|
||||
ManualDriver, SpawnOutput,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
|
|
@ -32,6 +43,14 @@ enum Cmd {
|
|||
#[arg(long)]
|
||||
kit_root: Option<PathBuf>,
|
||||
},
|
||||
/// Spawn + invoke driver (v0.1: stub — emits SpawnOutput then exit 64).
|
||||
Drive {
|
||||
/// Path to task.toml.
|
||||
task: PathBuf,
|
||||
/// kit root (default: cwd).
|
||||
#[arg(long)]
|
||||
kit_root: Option<PathBuf>,
|
||||
},
|
||||
/// Run verify pipeline + update ledger status.
|
||||
Verify {
|
||||
/// agent-id previously emitted by `kei-spawn spawn`.
|
||||
|
|
@ -49,6 +68,7 @@ fn main() -> ExitCode {
|
|||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::Spawn { task, kit_root } => run_spawn(task, kit_root),
|
||||
Cmd::Drive { task, kit_root } => run_drive(task, kit_root),
|
||||
Cmd::Verify { agent_id, worktree, kit_root } => {
|
||||
run_verify(agent_id, worktree, kit_root)
|
||||
}
|
||||
|
|
@ -64,6 +84,32 @@ fn run_spawn(task: PathBuf, kit_root: Option<PathBuf>) -> ExitCode {
|
|||
}
|
||||
}
|
||||
|
||||
fn run_drive(task: PathBuf, kit_root: Option<PathBuf>) -> ExitCode {
|
||||
let kit = kit_root_or_cwd(kit_root);
|
||||
let out = match spawn_from_task(&task, &kit) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return err("drive", e),
|
||||
};
|
||||
// Always emit SpawnOutput JSON first so callers can pipe it regardless
|
||||
// of the driver outcome. Drive-only failure modes come via stderr.
|
||||
if emit_json(&out) != ExitCode::SUCCESS {
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
dispatch_driver(&out)
|
||||
}
|
||||
|
||||
fn dispatch_driver(out: &SpawnOutput) -> ExitCode {
|
||||
let driver = ManualDriver;
|
||||
match drive_with(&driver, &out.prompt, &out.subagent_type, out.isolation.as_deref()) {
|
||||
Ok(_) => ExitCode::SUCCESS,
|
||||
Err(DriveError::NotImplemented { .. }) => {
|
||||
eprintln!("kei-spawn drive: {}", not_implemented_message());
|
||||
ExitCode::from(64)
|
||||
}
|
||||
Err(e) => err("drive", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_verify(agent_id: String, worktree: PathBuf, kit_root: Option<PathBuf>) -> ExitCode {
|
||||
let kit = kit_root_or_cwd(kit_root);
|
||||
match verify_agent(&agent_id, &worktree, &kit) {
|
||||
|
|
|
|||
130
_primitives/_rust/kei-spawn/tests/drive_smoke.rs
Normal file
130
_primitives/_rust/kei-spawn/tests/drive_smoke.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//! drive_smoke — integration tests for `kei-spawn drive` subcommand.
|
||||
//!
|
||||
//! The drive subcommand shells the full pipeline:
|
||||
//! 1. spawn_from_task (prepare + ledger fork)
|
||||
//! 2. SpawnOutput JSON → stdout
|
||||
//! 3. ManualDriver::invoke → NotImplemented → stderr + exit 64
|
||||
//!
|
||||
//! Because exit-code assertions require invoking the real binary, these
|
||||
//! tests use `CARGO_BIN_EXE_kei-spawn` (populated by cargo for integration
|
||||
//! tests) with `KEI_SPAWN_LEDGER_NOOP=1` set so the ledger subprocess is
|
||||
//! a no-op.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_capability(root: &Path, cat: &str, slug: &str, body: &str) {
|
||||
let dir = root.join("_capabilities").join(cat).join(slug);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("text.md"), body).unwrap();
|
||||
}
|
||||
|
||||
fn write_role(root: &Path, name: &str, toml: &str) {
|
||||
std::fs::create_dir_all(root.join("_roles")).unwrap();
|
||||
std::fs::write(root.join("_roles").join(format!("{name}.toml")), toml).unwrap();
|
||||
}
|
||||
|
||||
fn write_task(root: &Path, toml: &str) -> std::path::PathBuf {
|
||||
let path = root.join("task.toml");
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn minimal_kit(root: &Path) {
|
||||
write_capability(root, "policy", "no-git-ops", "## Never git.\n");
|
||||
write_capability(root, "output", "report-format", "## Report fields.\n");
|
||||
write_role(
|
||||
root,
|
||||
"edit-local",
|
||||
r#"
|
||||
[role]
|
||||
name = "edit-local"
|
||||
spawnable = true
|
||||
claude-subagent-type = "code-implementer"
|
||||
|
||||
[capabilities]
|
||||
required = ["policy::no-git-ops", "output::report-format"]
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
fn bin() -> Command {
|
||||
let mut c = Command::new(env!("CARGO_BIN_EXE_kei-spawn"));
|
||||
c.env("KEI_SPAWN_LEDGER_NOOP", "1");
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_on_valid_task_emits_spawn_json_and_exits_64() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
minimal_kit(root);
|
||||
let task_path = write_task(
|
||||
root,
|
||||
r#"
|
||||
[task]
|
||||
role = "edit-local"
|
||||
|
||||
[body]
|
||||
text = "Drive smoke test."
|
||||
"#,
|
||||
);
|
||||
|
||||
let output = bin()
|
||||
.arg("drive")
|
||||
.arg(&task_path)
|
||||
.arg("--kit-root")
|
||||
.arg(root)
|
||||
.output()
|
||||
.expect("run kei-spawn drive");
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Stdout must be SpawnOutput JSON (contain agent_id + prompt fields).
|
||||
assert!(stdout.contains("\"agent_id\""), "stdout missing agent_id: {stdout}");
|
||||
assert!(stdout.contains("\"subagent_type\""), "stdout missing subagent_type: {stdout}");
|
||||
assert!(stdout.contains("\"prompt\""), "stdout missing prompt: {stdout}");
|
||||
// Stderr must explain the v0.1 NotImplemented state.
|
||||
assert!(
|
||||
stderr.contains("HTTP Anthropic-API integration not yet wired"),
|
||||
"stderr missing NotImplemented msg: {stderr}"
|
||||
);
|
||||
// Exit code 64 (EX_USAGE range, NotImplemented convention).
|
||||
assert_eq!(output.status.code(), Some(64), "exit code must be 64, got: {:?}; stderr={stderr}", output.status.code());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_on_unknown_role_exits_nonzero_with_spawn_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
// NO minimal_kit — role cannot resolve.
|
||||
let task_path = write_task(
|
||||
root,
|
||||
r#"
|
||||
[task]
|
||||
role = "ghost-role"
|
||||
|
||||
[body]
|
||||
text = "x"
|
||||
"#,
|
||||
);
|
||||
|
||||
let output = bin()
|
||||
.arg("drive")
|
||||
.arg(&task_path)
|
||||
.arg("--kit-root")
|
||||
.arg(root)
|
||||
.output()
|
||||
.expect("run kei-spawn drive");
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("drive") || stderr.contains("role") || stderr.contains("ghost-role"),
|
||||
"stderr should reference the spawn failure: {stderr}"
|
||||
);
|
||||
// Spawn error surfaces as exit 1 (not 64); must NOT be success.
|
||||
let code = output.status.code();
|
||||
assert_ne!(code, Some(0), "unknown role must fail");
|
||||
assert_ne!(code, Some(64), "unknown role must not be NotImplemented: stderr={stderr}");
|
||||
}
|
||||
Loading…
Reference in a new issue