Browse Source

Track runtime tasks with structured task packets

Replace the oversized packet model with the requested JSON-friendly packet shape and thread it through the in-memory task registry. Add the RunTaskPacket tool so callers can launch packet-backed tasks directly while preserving existing task creation flows.

Constraint: The existing task system and tool surface had to keep TaskCreate behavior intact while adding packet-backed execution

Rejected: Add a second parallel packet registry | would duplicate task lifecycle state

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep TaskPacket aligned with the tool schema and task registry serialization when extending the packet contract

Tested: cargo build --workspace; cargo test --workspace

Not-tested: live end-to-end invocation of RunTaskPacket through an interactive CLI session
Yeachan-Heo 2 months ago
parent
commit
dbfc9d521c

+ 1 - 2
rust/crates/runtime/src/lib.rs

@@ -135,8 +135,7 @@ pub use stale_branch::{
     StaleBranchPolicy,
 };
 pub use task_packet::{
-    validate_packet, AcceptanceTest, BranchPolicy, CommitPolicy, RepoConfig, ReportingContract,
-    TaskPacket, TaskPacketValidationError, TaskScope, ValidatedPacket,
+    validate_packet, TaskPacket, TaskPacketValidationError, ValidatedPacket,
 };
 pub use trust_resolver::{TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolver};
 pub use usage::{

+ 58 - 487
rust/crates/runtime/src/task_packet.rs

@@ -1,188 +1,16 @@
 use serde::{Deserialize, Serialize};
-use serde_json::Value as JsonValue;
-use std::collections::BTreeMap;
 use std::fmt::{Display, Formatter};
-use std::path::{Path, PathBuf};
 
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct RepoConfig {
-    pub repo_root: PathBuf,
-    pub worktree_root: Option<PathBuf>,
-}
-
-impl RepoConfig {
-    #[must_use]
-    pub fn dispatch_root(&self) -> &Path {
-        self.worktree_root
-            .as_deref()
-            .unwrap_or(self.repo_root.as_path())
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum TaskScope {
-    SingleFile { path: PathBuf },
-    Module { crate_name: String },
-    Workspace,
-    Custom { paths: Vec<PathBuf> },
-}
-
-impl TaskScope {
-    #[must_use]
-    pub fn resolve_paths(&self, repo_config: &RepoConfig) -> Vec<PathBuf> {
-        let dispatch_root = repo_config.dispatch_root();
-        match self {
-            Self::SingleFile { path } => vec![resolve_path(dispatch_root, path)],
-            Self::Module { crate_name } => vec![dispatch_root.join("crates").join(crate_name)],
-            Self::Workspace => vec![dispatch_root.to_path_buf()],
-            Self::Custom { paths } => paths
-                .iter()
-                .map(|path| resolve_path(dispatch_root, path))
-                .collect(),
-        }
-    }
-}
-
-impl Display for TaskScope {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::SingleFile { .. } => write!(f, "single_file"),
-            Self::Module { .. } => write!(f, "module"),
-            Self::Workspace => write!(f, "workspace"),
-            Self::Custom { .. } => write!(f, "custom"),
-        }
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum BranchPolicy {
-    CreateNew { prefix: String },
-    UseExisting { name: String },
-    WorktreeIsolated,
-}
-
-impl Display for BranchPolicy {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::CreateNew { .. } => write!(f, "create_new"),
-            Self::UseExisting { .. } => write!(f, "use_existing"),
-            Self::WorktreeIsolated => write!(f, "worktree_isolated"),
-        }
-    }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum CommitPolicy {
-    CommitPerTask,
-    SquashOnMerge,
-    NoAutoCommit,
-}
-
-impl Display for CommitPolicy {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::CommitPerTask => write!(f, "commit_per_task"),
-            Self::SquashOnMerge => write!(f, "squash_on_merge"),
-            Self::NoAutoCommit => write!(f, "no_auto_commit"),
-        }
-    }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum GreenLevel {
-    Package,
-    Workspace,
-    MergeReady,
-}
-
-impl Display for GreenLevel {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::Package => write!(f, "package"),
-            Self::Workspace => write!(f, "workspace"),
-            Self::MergeReady => write!(f, "merge_ready"),
-        }
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum AcceptanceTest {
-    CargoTest { filter: Option<String> },
-    CustomCommand { cmd: String },
-    GreenLevel { level: GreenLevel },
-}
-
-impl Display for AcceptanceTest {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::CargoTest { .. } => write!(f, "cargo_test"),
-            Self::CustomCommand { .. } => write!(f, "custom_command"),
-            Self::GreenLevel { .. } => write!(f, "green_level"),
-        }
-    }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum ReportingContract {
-    EventStream,
-    Summary,
-    Silent,
-}
-
-impl Display for ReportingContract {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::EventStream => write!(f, "event_stream"),
-            Self::Summary => write!(f, "summary"),
-            Self::Silent => write!(f, "silent"),
-        }
-    }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum EscalationPolicy {
-    RetryThenEscalate { max_retries: u32 },
-    AutoEscalate,
-    NeverEscalate,
-}
-
-impl Display for EscalationPolicy {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::RetryThenEscalate { .. } => write!(f, "retry_then_escalate"),
-            Self::AutoEscalate => write!(f, "auto_escalate"),
-            Self::NeverEscalate => write!(f, "never_escalate"),
-        }
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 pub struct TaskPacket {
-    pub id: String,
     pub objective: String,
-    pub scope: TaskScope,
-    pub repo_config: RepoConfig,
-    pub branch_policy: BranchPolicy,
-    pub acceptance_tests: Vec<AcceptanceTest>,
-    pub commit_policy: CommitPolicy,
-    pub reporting: ReportingContract,
-    pub escalation: EscalationPolicy,
-    pub created_at: u64,
-    pub metadata: BTreeMap<String, JsonValue>,
-}
-
-impl TaskPacket {
-    #[must_use]
-    pub fn resolve_scope_paths(&self) -> Vec<PathBuf> {
-        self.scope.resolve_paths(&self.repo_config)
-    }
+    pub scope: String,
+    pub repo: String,
+    pub branch_policy: String,
+    pub acceptance_tests: Vec<String>,
+    pub commit_policy: String,
+    pub reporting_contract: String,
+    pub escalation_policy: String,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -210,7 +38,7 @@ impl Display for TaskPacketValidationError {
 
 impl std::error::Error for TaskPacketValidationError {}
 
-#[derive(Debug, Clone, PartialEq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ValidatedPacket(TaskPacket);
 
 impl ValidatedPacket {
@@ -223,42 +51,35 @@ impl ValidatedPacket {
     pub fn into_inner(self) -> TaskPacket {
         self.0
     }
-
-    #[must_use]
-    pub fn resolve_scope_paths(&self) -> Vec<PathBuf> {
-        self.0.resolve_scope_paths()
-    }
 }
 
 pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacketValidationError> {
     let mut errors = Vec::new();
 
-    if packet.id.trim().is_empty() {
-        errors.push("packet id must not be empty".to_string());
-    }
-
-    if packet.objective.trim().is_empty() {
-        errors.push("packet objective must not be empty".to_string());
-    }
-
-    if packet.repo_config.repo_root.as_os_str().is_empty() {
-        errors.push("repo_config repo_root must not be empty".to_string());
-    }
-
-    if packet
-        .repo_config
-        .worktree_root
-        .as_ref()
-        .is_some_and(|path| path.as_os_str().is_empty())
-    {
-        errors.push("repo_config worktree_root must not be empty when present".to_string());
+    validate_required("objective", &packet.objective, &mut errors);
+    validate_required("scope", &packet.scope, &mut errors);
+    validate_required("repo", &packet.repo, &mut errors);
+    validate_required("branch_policy", &packet.branch_policy, &mut errors);
+    validate_required("commit_policy", &packet.commit_policy, &mut errors);
+    validate_required(
+        "reporting_contract",
+        &packet.reporting_contract,
+        &mut errors,
+    );
+    validate_required(
+        "escalation_policy",
+        &packet.escalation_policy,
+        &mut errors,
+    );
+
+    for (index, test) in packet.acceptance_tests.iter().enumerate() {
+        if test.trim().is_empty() {
+            errors.push(format!(
+                "acceptance_tests contains an empty value at index {index}"
+            ));
+        }
     }
 
-    validate_scope(&packet.scope, &mut errors);
-    validate_branch_policy(&packet.branch_policy, &mut errors);
-    validate_acceptance_tests(&packet.acceptance_tests, &mut errors);
-    validate_escalation_policy(packet.escalation, &mut errors);
-
     if errors.is_empty() {
         Ok(ValidatedPacket(packet))
     } else {
@@ -266,326 +87,76 @@ pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacket
     }
 }
 
-fn validate_scope(scope: &TaskScope, errors: &mut Vec<String>) {
-    match scope {
-        TaskScope::SingleFile { path } if path.as_os_str().is_empty() => {
-            errors.push("single_file scope path must not be empty".to_string());
-        }
-        TaskScope::Module { crate_name } if crate_name.trim().is_empty() => {
-            errors.push("module scope crate_name must not be empty".to_string());
-        }
-        TaskScope::Custom { paths } if paths.is_empty() => {
-            errors.push("custom scope paths must not be empty".to_string());
-        }
-        TaskScope::Custom { paths } => {
-            for (index, path) in paths.iter().enumerate() {
-                if path.as_os_str().is_empty() {
-                    errors.push(format!("custom scope contains empty path at index {index}"));
-                }
-            }
-        }
-        TaskScope::SingleFile { .. } | TaskScope::Module { .. } | TaskScope::Workspace => {}
-    }
-}
-
-fn validate_branch_policy(branch_policy: &BranchPolicy, errors: &mut Vec<String>) {
-    match branch_policy {
-        BranchPolicy::CreateNew { prefix } if prefix.trim().is_empty() => {
-            errors.push("create_new branch prefix must not be empty".to_string());
-        }
-        BranchPolicy::UseExisting { name } if name.trim().is_empty() => {
-            errors.push("use_existing branch name must not be empty".to_string());
-        }
-        BranchPolicy::CreateNew { .. }
-        | BranchPolicy::UseExisting { .. }
-        | BranchPolicy::WorktreeIsolated => {}
-    }
-}
-
-fn validate_acceptance_tests(tests: &[AcceptanceTest], errors: &mut Vec<String>) {
-    for test in tests {
-        match test {
-            AcceptanceTest::CargoTest { filter } => {
-                if filter
-                    .as_deref()
-                    .is_some_and(|value| value.trim().is_empty())
-                {
-                    errors.push("cargo_test filter must not be empty when present".to_string());
-                }
-            }
-            AcceptanceTest::CustomCommand { cmd } if cmd.trim().is_empty() => {
-                errors.push("custom_command cmd must not be empty".to_string());
-            }
-            AcceptanceTest::CustomCommand { .. } | AcceptanceTest::GreenLevel { .. } => {}
-        }
-    }
-}
-
-fn validate_escalation_policy(escalation: EscalationPolicy, errors: &mut Vec<String>) {
-    if matches!(
-        escalation,
-        EscalationPolicy::RetryThenEscalate { max_retries: 0 }
-    ) {
-        errors.push("retry_then_escalate max_retries must be greater than zero".to_string());
-    }
-}
-
-fn resolve_path(dispatch_root: &Path, path: &Path) -> PathBuf {
-    if path.is_absolute() {
-        path.to_path_buf()
-    } else {
-        dispatch_root.join(path)
+fn validate_required(field: &str, value: &str, errors: &mut Vec<String>) {
+    if value.trim().is_empty() {
+        errors.push(format!("{field} must not be empty"));
     }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
-    use serde_json::json;
-    use std::time::{SystemTime, UNIX_EPOCH};
-
-    fn now_secs() -> u64 {
-        SystemTime::now()
-            .duration_since(UNIX_EPOCH)
-            .unwrap_or_default()
-            .as_secs()
-    }
-
-    fn sample_repo_config() -> RepoConfig {
-        RepoConfig {
-            repo_root: PathBuf::from("/repo"),
-            worktree_root: Some(PathBuf::from("/repo/.worktrees/task-1")),
-        }
-    }
 
     fn sample_packet() -> TaskPacket {
-        let mut metadata = BTreeMap::new();
-        metadata.insert("attempt".to_string(), json!(1));
-        metadata.insert("lane".to_string(), json!("runtime"));
-
         TaskPacket {
-            id: "packet_001".to_string(),
             objective: "Implement typed task packet format".to_string(),
-            scope: TaskScope::Module {
-                crate_name: "runtime".to_string(),
-            },
-            repo_config: sample_repo_config(),
-            branch_policy: BranchPolicy::CreateNew {
-                prefix: "ultraclaw".to_string(),
-            },
+            scope: "runtime/task system".to_string(),
+            repo: "claw-code-parity".to_string(),
+            branch_policy: "origin/main only".to_string(),
             acceptance_tests: vec![
-                AcceptanceTest::CargoTest {
-                    filter: Some("task_packet".to_string()),
-                },
-                AcceptanceTest::GreenLevel {
-                    level: GreenLevel::Workspace,
-                },
+                "cargo build --workspace".to_string(),
+                "cargo test --workspace".to_string(),
             ],
-            commit_policy: CommitPolicy::CommitPerTask,
-            reporting: ReportingContract::EventStream,
-            escalation: EscalationPolicy::RetryThenEscalate { max_retries: 2 },
-            created_at: now_secs(),
-            metadata,
+            commit_policy: "single verified commit".to_string(),
+            reporting_contract: "print build result, test result, commit sha".to_string(),
+            escalation_policy: "stop only on destructive ambiguity".to_string(),
         }
     }
 
     #[test]
     fn valid_packet_passes_validation() {
-        // given
         let packet = sample_packet();
-
-        // when
-        let validated = validate_packet(packet);
-
-        // then
-        assert!(validated.is_ok());
+        let validated = validate_packet(packet.clone()).expect("packet should validate");
+        assert_eq!(validated.packet(), &packet);
+        assert_eq!(validated.into_inner(), packet);
     }
 
     #[test]
     fn invalid_packet_accumulates_errors() {
-        // given
         let packet = TaskPacket {
-            id: " ".to_string(),
             objective: " ".to_string(),
-            scope: TaskScope::Custom {
-                paths: vec![PathBuf::new()],
-            },
-            repo_config: RepoConfig {
-                repo_root: PathBuf::new(),
-                worktree_root: Some(PathBuf::new()),
-            },
-            branch_policy: BranchPolicy::CreateNew {
-                prefix: " ".to_string(),
-            },
-            acceptance_tests: vec![
-                AcceptanceTest::CargoTest {
-                    filter: Some(" ".to_string()),
-                },
-                AcceptanceTest::CustomCommand {
-                    cmd: " ".to_string(),
-                },
-            ],
-            commit_policy: CommitPolicy::NoAutoCommit,
-            reporting: ReportingContract::Silent,
-            escalation: EscalationPolicy::RetryThenEscalate { max_retries: 0 },
-            created_at: 0,
-            metadata: BTreeMap::new(),
+            scope: String::new(),
+            repo: String::new(),
+            branch_policy: "\t".to_string(),
+            acceptance_tests: vec!["ok".to_string(), " ".to_string()],
+            commit_policy: String::new(),
+            reporting_contract: String::new(),
+            escalation_policy: String::new(),
         };
 
-        // when
         let error = validate_packet(packet).expect_err("packet should be rejected");
 
-        // then
-        assert!(error.errors().len() >= 8);
-        assert!(error
-            .errors()
-            .contains(&"packet id must not be empty".to_string()));
+        assert!(error.errors().len() >= 7);
         assert!(error
             .errors()
-            .contains(&"packet objective must not be empty".to_string()));
+            .contains(&"objective must not be empty".to_string()));
         assert!(error
             .errors()
-            .contains(&"repo_config repo_root must not be empty".to_string()));
+            .contains(&"scope must not be empty".to_string()));
         assert!(error
             .errors()
-            .contains(&"create_new branch prefix must not be empty".to_string()));
-    }
-
-    #[test]
-    fn single_file_scope_resolves_against_worktree_root() {
-        // given
-        let repo_config = sample_repo_config();
-        let scope = TaskScope::SingleFile {
-            path: PathBuf::from("crates/runtime/src/task_packet.rs"),
-        };
-
-        // when
-        let paths = scope.resolve_paths(&repo_config);
-
-        // then
-        assert_eq!(
-            paths,
-            vec![PathBuf::from(
-                "/repo/.worktrees/task-1/crates/runtime/src/task_packet.rs"
-            )]
-        );
-    }
-
-    #[test]
-    fn workspace_scope_resolves_to_dispatch_root() {
-        // given
-        let repo_config = sample_repo_config();
-        let scope = TaskScope::Workspace;
-
-        // when
-        let paths = scope.resolve_paths(&repo_config);
-
-        // then
-        assert_eq!(paths, vec![PathBuf::from("/repo/.worktrees/task-1")]);
-    }
-
-    #[test]
-    fn module_scope_resolves_to_crate_directory() {
-        // given
-        let repo_config = sample_repo_config();
-        let scope = TaskScope::Module {
-            crate_name: "runtime".to_string(),
-        };
-
-        // when
-        let paths = scope.resolve_paths(&repo_config);
-
-        // then
-        assert_eq!(
-            paths,
-            vec![PathBuf::from("/repo/.worktrees/task-1/crates/runtime")]
-        );
-    }
-
-    #[test]
-    fn custom_scope_preserves_absolute_paths_and_resolves_relative_paths() {
-        // given
-        let repo_config = sample_repo_config();
-        let scope = TaskScope::Custom {
-            paths: vec![
-                PathBuf::from("Cargo.toml"),
-                PathBuf::from("/tmp/shared/script.sh"),
-            ],
-        };
-
-        // when
-        let paths = scope.resolve_paths(&repo_config);
-
-        // then
-        assert_eq!(
-            paths,
-            vec![
-                PathBuf::from("/repo/.worktrees/task-1/Cargo.toml"),
-                PathBuf::from("/tmp/shared/script.sh"),
-            ]
-        );
+            .contains(&"repo must not be empty".to_string()));
+        assert!(error.errors().contains(
+            &"acceptance_tests contains an empty value at index 1".to_string()
+        ));
     }
 
     #[test]
     fn serialization_roundtrip_preserves_packet() {
-        // given
         let packet = sample_packet();
-
-        // when
         let serialized = serde_json::to_string(&packet).expect("packet should serialize");
         let deserialized: TaskPacket =
             serde_json::from_str(&serialized).expect("packet should deserialize");
-
-        // then
         assert_eq!(deserialized, packet);
     }
-
-    #[test]
-    fn validated_packet_exposes_inner_packet_and_scope_paths() {
-        // given
-        let packet = sample_packet();
-
-        // when
-        let validated = validate_packet(packet.clone()).expect("packet should validate");
-        let resolved_paths = validated.resolve_scope_paths();
-        let inner = validated.into_inner();
-
-        // then
-        assert_eq!(
-            resolved_paths,
-            vec![PathBuf::from("/repo/.worktrees/task-1/crates/runtime")]
-        );
-        assert_eq!(inner, packet);
-    }
-
-    #[test]
-    fn display_impls_render_snake_case_variants() {
-        // given
-        let rendered = vec![
-            TaskScope::Workspace.to_string(),
-            BranchPolicy::WorktreeIsolated.to_string(),
-            CommitPolicy::SquashOnMerge.to_string(),
-            GreenLevel::MergeReady.to_string(),
-            AcceptanceTest::GreenLevel {
-                level: GreenLevel::Package,
-            }
-            .to_string(),
-            ReportingContract::EventStream.to_string(),
-            EscalationPolicy::AutoEscalate.to_string(),
-        ];
-
-        // when
-        let expected = vec![
-            "workspace",
-            "worktree_isolated",
-            "squash_on_merge",
-            "merge_ready",
-            "green_level",
-            "event_stream",
-            "auto_escalate",
-        ];
-
-        // then
-        assert_eq!(rendered, expected);
-    }
 }

+ 59 - 2
rust/crates/runtime/src/task_registry.rs

@@ -6,6 +6,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
 
 use serde::{Deserialize, Serialize};
 
+use crate::{validate_packet, TaskPacket, TaskPacketValidationError};
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
 pub enum TaskStatus {
@@ -33,6 +35,7 @@ pub struct Task {
     pub task_id: String,
     pub prompt: String,
     pub description: Option<String>,
+    pub task_packet: Option<TaskPacket>,
     pub status: TaskStatus,
     pub created_at: u64,
     pub updated_at: u64,
@@ -73,14 +76,40 @@ impl TaskRegistry {
     }
 
     pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
+        self.create_task(
+            prompt.to_owned(),
+            description.map(str::to_owned),
+            None,
+        )
+    }
+
+    pub fn create_from_packet(
+        &self,
+        packet: TaskPacket,
+    ) -> Result<Task, TaskPacketValidationError> {
+        let packet = validate_packet(packet)?.into_inner();
+        Ok(self.create_task(
+            packet.objective.clone(),
+            Some(packet.scope.clone()),
+            Some(packet),
+        ))
+    }
+
+    fn create_task(
+        &self,
+        prompt: String,
+        description: Option<String>,
+        task_packet: Option<TaskPacket>,
+    ) -> Task {
         let mut inner = self.inner.lock().expect("registry lock poisoned");
         inner.counter += 1;
         let ts = now_secs();
         let task_id = format!("task_{:08x}_{}", ts, inner.counter);
         let task = Task {
             task_id: task_id.clone(),
-            prompt: prompt.to_owned(),
-            description: description.map(str::to_owned),
+            prompt,
+            description,
+            task_packet,
             status: TaskStatus::Created,
             created_at: ts,
             updated_at: ts,
@@ -215,11 +244,38 @@ mod tests {
         assert_eq!(task.status, TaskStatus::Created);
         assert_eq!(task.prompt, "Do something");
         assert_eq!(task.description.as_deref(), Some("A test task"));
+        assert_eq!(task.task_packet, None);
 
         let fetched = registry.get(&task.task_id).expect("task should exist");
         assert_eq!(fetched.task_id, task.task_id);
     }
 
+    #[test]
+    fn creates_task_from_packet() {
+        let registry = TaskRegistry::new();
+        let packet = TaskPacket {
+            objective: "Ship task packet support".to_string(),
+            scope: "runtime/task system".to_string(),
+            repo: "claw-code-parity".to_string(),
+            branch_policy: "origin/main only".to_string(),
+            acceptance_tests: vec!["cargo test --workspace".to_string()],
+            commit_policy: "single commit".to_string(),
+            reporting_contract: "print commit sha".to_string(),
+            escalation_policy: "manual escalation".to_string(),
+        };
+
+        let task = registry
+            .create_from_packet(packet.clone())
+            .expect("packet-backed task should be created");
+
+        assert_eq!(task.prompt, packet.objective);
+        assert_eq!(task.description.as_deref(), Some("runtime/task system"));
+        assert_eq!(task.task_packet, Some(packet.clone()));
+
+        let fetched = registry.get(&task.task_id).expect("task should exist");
+        assert_eq!(fetched.task_packet, Some(packet));
+    }
+
     #[test]
     fn lists_tasks_with_optional_filter() {
         let registry = TaskRegistry::new();
@@ -417,6 +473,7 @@ mod tests {
         // then
         assert!(task.task_id.starts_with("task_"));
         assert_eq!(task.description, None);
+        assert_eq!(task.task_packet, None);
         assert!(task.messages.is_empty());
         assert!(task.output.is_empty());
         assert_eq!(task.team_id, None);

+ 86 - 3
rust/crates/tools/src/lib.rs

@@ -17,6 +17,7 @@ use runtime::{
     permission_enforcer::{EnforcementResult, PermissionEnforcer},
     read_file,
     summary_compression::compress_summary_text,
+    TaskPacket,
     task_registry::TaskRegistry,
     team_cron_registry::{CronRegistry, TeamRegistry},
     worker_boot::{WorkerReadySnapshot, WorkerRegistry},
@@ -755,6 +756,38 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
             }),
             required_permission: PermissionMode::DangerFullAccess,
         },
+        ToolSpec {
+            name: "RunTaskPacket",
+            description: "Create a background task from a structured task packet.",
+            input_schema: json!({
+                "type": "object",
+                "properties": {
+                    "objective": { "type": "string" },
+                    "scope": { "type": "string" },
+                    "repo": { "type": "string" },
+                    "branch_policy": { "type": "string" },
+                    "acceptance_tests": {
+                        "type": "array",
+                        "items": { "type": "string" }
+                    },
+                    "commit_policy": { "type": "string" },
+                    "reporting_contract": { "type": "string" },
+                    "escalation_policy": { "type": "string" }
+                },
+                "required": [
+                    "objective",
+                    "scope",
+                    "repo",
+                    "branch_policy",
+                    "acceptance_tests",
+                    "commit_policy",
+                    "reporting_contract",
+                    "escalation_policy"
+                ],
+                "additionalProperties": false
+            }),
+            required_permission: PermissionMode::DangerFullAccess,
+        },
         ToolSpec {
             name: "TaskGet",
             description: "Get the status and details of a background task by ID.",
@@ -1177,6 +1210,7 @@ fn execute_tool_with_enforcer(
             from_value::<AskUserQuestionInput>(input).and_then(run_ask_user_question)
         }
         "TaskCreate" => from_value::<TaskCreateInput>(input).and_then(run_task_create),
+        "RunTaskPacket" => from_value::<TaskPacket>(input).and_then(run_task_packet),
         "TaskGet" => from_value::<TaskIdInput>(input).and_then(run_task_get),
         "TaskList" => run_task_list(input.clone()),
         "TaskStop" => from_value::<TaskIdInput>(input).and_then(run_task_stop),
@@ -1285,6 +1319,24 @@ fn run_task_create(input: TaskCreateInput) -> Result<String, String> {
         "status": task.status,
         "prompt": task.prompt,
         "description": task.description,
+        "task_packet": task.task_packet,
+        "created_at": task.created_at
+    }))
+}
+
+#[allow(clippy::needless_pass_by_value)]
+fn run_task_packet(input: TaskPacket) -> Result<String, String> {
+    let registry = global_task_registry();
+    let task = registry
+        .create_from_packet(input)
+        .map_err(|error| error.to_string())?;
+
+    to_pretty_json(json!({
+        "task_id": task.task_id,
+        "status": task.status,
+        "prompt": task.prompt,
+        "description": task.description,
+        "task_packet": task.task_packet,
         "created_at": task.created_at
     }))
 }
@@ -1298,6 +1350,7 @@ fn run_task_get(input: TaskIdInput) -> Result<String, String> {
             "status": task.status,
             "prompt": task.prompt,
             "description": task.description,
+            "task_packet": task.task_packet,
             "created_at": task.created_at,
             "updated_at": task.updated_at,
             "messages": task.messages,
@@ -1318,6 +1371,7 @@ fn run_task_list(_input: Value) -> Result<String, String> {
                 "status": t.status,
                 "prompt": t.prompt,
                 "description": t.description,
+                "task_packet": t.task_packet,
                 "created_at": t.created_at,
                 "updated_at": t.updated_at,
                 "team_id": t.team_id
@@ -4897,13 +4951,14 @@ mod tests {
     use super::{
         agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
         execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs,
-        permission_mode_from_plugin, persist_agent_terminal_state, push_output_block, AgentInput,
-        AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass, SubagentToolExecutor,
+        permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
+        run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName,
+        LaneFailureClass, SubagentToolExecutor,
     };
     use api::OutputContentBlock;
     use runtime::{
         permission_enforcer::PermissionEnforcer, ApiRequest, AssistantEvent, ConversationRuntime,
-        PermissionMode, PermissionPolicy, RuntimeError, Session, ToolExecutor,
+        PermissionMode, PermissionPolicy, RuntimeError, Session, TaskPacket, ToolExecutor,
     };
     use serde_json::json;
 
@@ -6996,6 +7051,34 @@ printf 'pwsh:%s' "$1"
         assert_eq!(output["stdout"], "ok");
     }
 
+    #[test]
+    fn run_task_packet_creates_packet_backed_task() {
+        let result = run_task_packet(TaskPacket {
+            objective: "Ship packetized runtime task".to_string(),
+            scope: "runtime/task system".to_string(),
+            repo: "claw-code-parity".to_string(),
+            branch_policy: "origin/main only".to_string(),
+            acceptance_tests: vec![
+                "cargo build --workspace".to_string(),
+                "cargo test --workspace".to_string(),
+            ],
+            commit_policy: "single commit".to_string(),
+            reporting_contract: "print build/test result and sha".to_string(),
+            escalation_policy: "manual escalation".to_string(),
+        })
+        .expect("task packet should create a task");
+
+        let output: serde_json::Value = serde_json::from_str(&result).expect("json");
+        assert_eq!(output["status"], "created");
+        assert_eq!(output["prompt"], "Ship packetized runtime task");
+        assert_eq!(output["description"], "runtime/task system");
+        assert_eq!(output["task_packet"]["repo"], "claw-code-parity");
+        assert_eq!(
+            output["task_packet"]["acceptance_tests"][1],
+            "cargo test --workspace"
+        );
+    }
+
     struct TestServer {
         addr: SocketAddr,
         shutdown: Option<std::sync::mpsc::Sender<()>>,