Merge W9D — kei-spawn drive stub

This commit is contained in:
Parfii-bot 2026-04-23 13:37:02 +08:00
commit 7dd5c0796d
4 changed files with 335 additions and 2 deletions

View 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 { .. })
));
}
}

View file

@ -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};

View file

@ -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) {

View 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}");
}