diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6b7e9d..95e8aa77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Goal lifecycle controls.** `/goal` is now the primary command surface for session goals, with `pause`, `resume`, `complete`, `blocked`, and `clear` controls while `/hunt` remains a compatibility alias. +- **Persistent thread-goal API.** App-server clients can now set, get, and clear + durable thread goals through `thread/goal/set`, `thread/goal/get`, and + `thread/goal/clear`, backed by the state store with Codex-style status and + token/time accounting fields. - **Command-boundary ownership layers (#2888/#3055).** Built-in slash command metadata now lives in `commands/registry.rs`, slash parsing in `commands/parse.rs`, and handlers under group-owned command areas, preserving diff --git a/crates/app-server/src/lib.rs b/crates/app-server/src/lib.rs index 262a920c..9a8546a0 100644 --- a/crates/app-server/src/lib.rs +++ b/crates/app-server/src/lib.rs @@ -15,7 +15,8 @@ use codewhale_core::Runtime; use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink}; use codewhale_mcp::McpManager; use codewhale_protocol::{ - AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadRequest, ThreadResponse, + AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadGoalClearParams, + ThreadGoalGetParams, ThreadGoalSetParams, ThreadRequest, ThreadResponse, }; use codewhale_state::StateStore; use codewhale_tools::{ToolCall, ToolRegistry}; @@ -241,6 +242,7 @@ async fn thread_handler( status: format!("error:{err}"), thread: None, threads: Vec::new(), + goal: None, model: None, model_provider: None, cwd: None, @@ -568,6 +570,9 @@ async fn dispatch_stdio_request( "thread/list", "thread/read", "thread/set_name", + "thread/goal/set", + "thread/goal/get", + "thread/goal/clear", "thread/archive", "thread/unarchive", "thread/message", @@ -598,6 +603,9 @@ async fn dispatch_stdio_request( "thread/list", "thread/read", "thread/set_name", + "thread/goal/set", + "thread/goal/get", + "thread/goal/clear", "thread/archive", "thread/unarchive", "thread/message" @@ -688,6 +696,39 @@ async fn dispatch_stdio_request( should_exit: false, } } + "thread/goal/set" | "thread/goal_set" | "thread/goal-set" => { + let request = ThreadRequest::GoalSet(parse_params::( + params_or_object(params), + )?); + let response = handle_thread_request(state, request).await?; + StdioDispatchResult { + result: serde_json::to_value(response) + .map_err(|err| JsonRpcError::internal(err.to_string()))?, + should_exit: false, + } + } + "thread/goal/get" | "thread/goal_get" | "thread/goal-get" => { + let request = ThreadRequest::GoalGet(parse_params::( + params_or_object(params), + )?); + let response = handle_thread_request(state, request).await?; + StdioDispatchResult { + result: serde_json::to_value(response) + .map_err(|err| JsonRpcError::internal(err.to_string()))?, + should_exit: false, + } + } + "thread/goal/clear" | "thread/goal_clear" | "thread/goal-clear" => { + let request = ThreadRequest::GoalClear(parse_params::( + params_or_object(params), + )?); + let response = handle_thread_request(state, request).await?; + StdioDispatchResult { + result: serde_json::to_value(response) + .map_err(|err| JsonRpcError::internal(err.to_string()))?, + should_exit: false, + } + } "thread/archive" => { let parsed: ThreadIdParams = parse_params(params_or_object(params))?; let response = handle_thread_request( @@ -1134,6 +1175,71 @@ mod tests { assert_eq!(response.data["value"], "sk-deepseek-secret"); } + #[tokio::test] + async fn stdio_thread_goal_methods_round_trip_persisted_goal() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write(&config_path, "").expect("write config"); + let state = build_state(Some(config_path), None).expect("state"); + + let capabilities = dispatch_stdio_request(&state, "thread/capabilities", json!({})) + .await + .expect("thread capabilities"); + assert!( + capabilities.result["methods"] + .as_array() + .expect("methods") + .iter() + .any(|method| method == "thread/goal/set") + ); + + let started = dispatch_stdio_request(&state, "thread/start", json!({})) + .await + .expect("start thread"); + let thread_id = started.result["thread_id"] + .as_str() + .expect("thread id") + .to_string(); + + let set = dispatch_stdio_request( + &state, + "thread/goal/set", + json!({ + "thread_id": thread_id, + "objective": "Release 0.8.59", + "token_budget": 59000 + }), + ) + .await + .expect("set goal"); + assert_eq!(set.result["status"], "ok"); + assert_eq!(set.result["goal"]["objective"], "Release 0.8.59"); + assert_eq!(set.result["goal"]["status"], "active"); + + let got = dispatch_stdio_request( + &state, + "thread/goal/get", + json!({ + "thread_id": thread_id + }), + ) + .await + .expect("get goal"); + assert_eq!(got.result["goal"]["token_budget"], 59000); + + let cleared = dispatch_stdio_request( + &state, + "thread/goal/clear", + json!({ + "thread_id": thread_id + }), + ) + .await + .expect("clear goal"); + assert_eq!(cleared.result["status"], "cleared"); + assert_eq!(cleared.result["data"]["cleared"], true); + } + // ── resolve_auth_token ───────────────────────────────────────────── #[test] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 75382f9d..4aa569b4 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -15,12 +15,14 @@ use codewhale_mcp::{ }; use codewhale_protocol::{ AppResponse, EventFrame, ExecApprovalRequestEvent, PromptRequest, PromptResponse, - ResponseChannel, ReviewDecision, Thread, ThreadForkParams, ThreadListParams, ThreadReadParams, + ResponseChannel, ReviewDecision, Thread, ThreadForkParams, ThreadGoal, ThreadGoalClearParams, + ThreadGoalGetParams, ThreadGoalSetParams, ThreadGoalStatus, ThreadListParams, ThreadReadParams, ThreadRequest, ThreadResponse, ThreadResumeParams, ThreadSetNameParams, ThreadStatus, ToolPayload, }; use codewhale_state::{ - JobStateRecord, JobStateStatus, SessionSource, StateStore, ThreadListFilters, ThreadMetadata, + JobStateRecord, JobStateStatus, SessionSource, StateStore, ThreadGoalRecord, + ThreadGoalStatus as PersistedThreadGoalStatus, ThreadListFilters, ThreadMetadata, ThreadStatus as PersistedThreadStatus, }; use codewhale_tools::{ToolCall, ToolRegistry}; @@ -644,6 +646,40 @@ impl ThreadManager { Ok(Some(updated)) } + /// Sets or replaces the persisted goal for a thread. + pub fn set_thread_goal(&mut self, params: &ThreadGoalSetParams) -> Result> { + if self.store.get_thread(¶ms.thread_id)?.is_none() { + return Ok(None); + } + let now = chrono::Utc::now().timestamp(); + let goal = ThreadGoalRecord { + thread_id: params.thread_id.clone(), + goal_id: format!("goal-{}", Uuid::new_v4()), + objective: params.objective.clone(), + status: PersistedThreadGoalStatus::Active, + token_budget: params.token_budget, + tokens_used: 0, + time_used_seconds: 0, + created_at: now, + updated_at: now, + }; + self.store.upsert_thread_goal(&goal)?; + Ok(Some(to_protocol_goal(goal))) + } + + /// Reads the persisted goal for a thread. + pub fn get_thread_goal(&self, params: &ThreadGoalGetParams) -> Result> { + Ok(self + .store + .get_thread_goal(¶ms.thread_id)? + .map(to_protocol_goal)) + } + + /// Clears the persisted goal for a thread, returning whether one existed. + pub fn clear_thread_goal(&mut self, params: &ThreadGoalClearParams) -> Result { + self.store.delete_thread_goal(¶ms.thread_id) + } + /// Archives a thread so it no longer appears in default listings. pub fn archive_thread(&mut self, thread_id: &str) -> Result<()> { self.store.mark_archived(thread_id)?; @@ -792,9 +828,16 @@ impl Runtime { }) }); + let goal = self + .thread_manager + .state_store() + .get_thread_goal(thread_id)? + .map(to_protocol_goal); + Ok(json!({ "history": history, - "checkpoint": checkpoint + "checkpoint": checkpoint, + "goal": goal })) } @@ -858,6 +901,7 @@ impl Runtime { status: "missing".to_string(), thread: None, threads: Vec::new(), + goal: None, model: None, model_provider: None, cwd: None, @@ -880,6 +924,7 @@ impl Runtime { status: "missing".to_string(), thread: None, threads: Vec::new(), + goal: None, model: None, model_provider: None, cwd: None, @@ -895,6 +940,7 @@ impl Runtime { status: "ok".to_string(), thread: None, threads: self.thread_manager.list_threads(¶ms)?, + goal: None, model: None, model_provider: None, cwd: None, @@ -911,6 +957,9 @@ impl Runtime { status: "ok".to_string(), thread: self.thread_manager.read_thread(¶ms)?, threads: Vec::new(), + goal: self.thread_manager.get_thread_goal(&ThreadGoalGetParams { + thread_id: params.thread_id, + })?, model: None, model_provider: None, cwd: None, @@ -925,6 +974,7 @@ impl Runtime { status: "ok".to_string(), thread: self.thread_manager.set_thread_name(¶ms)?, threads: Vec::new(), + goal: None, model: None, model_provider: None, cwd: None, @@ -933,6 +983,79 @@ impl Runtime { events: Vec::new(), data: json!({}), }), + ThreadRequest::GoalSet(params) => { + let thread_id = params.thread_id.clone(); + if let Some(goal) = self.thread_manager.set_thread_goal(¶ms)? { + Ok(ThreadResponse { + thread_id, + status: "ok".to_string(), + thread: None, + threads: Vec::new(), + goal: Some(goal.clone()), + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + events: vec![EventFrame::ThreadGoalUpdated { goal: goal.clone() }], + data: json!({ "goal": goal }), + }) + } else { + Ok(ThreadResponse { + thread_id, + status: "missing".to_string(), + thread: None, + threads: Vec::new(), + goal: None, + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + events: Vec::new(), + data: json!({"error":"thread not found"}), + }) + } + } + ThreadRequest::GoalGet(params) => { + let goal = self.thread_manager.get_thread_goal(¶ms)?; + Ok(ThreadResponse { + thread_id: params.thread_id, + status: "ok".to_string(), + thread: None, + threads: Vec::new(), + goal: goal.clone(), + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + events: Vec::new(), + data: json!({ "goal": goal }), + }) + } + ThreadRequest::GoalClear(params) => { + let thread_id = params.thread_id.clone(); + let cleared = self.thread_manager.clear_thread_goal(¶ms)?; + Ok(ThreadResponse { + thread_id: thread_id.clone(), + status: if cleared { "cleared" } else { "empty" }.to_string(), + thread: None, + threads: Vec::new(), + goal: None, + model: None, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + events: if cleared { + vec![EventFrame::ThreadGoalCleared { thread_id }] + } else { + Vec::new() + }, + data: json!({ "cleared": cleared }), + }) + } ThreadRequest::Archive { thread_id } => { self.thread_manager.archive_thread(&thread_id)?; Ok(ThreadResponse { @@ -940,6 +1063,7 @@ impl Runtime { status: "archived".to_string(), thread: None, threads: Vec::new(), + goal: None, model: None, model_provider: None, cwd: None, @@ -956,6 +1080,7 @@ impl Runtime { status: "unarchived".to_string(), thread: None, threads: Vec::new(), + goal: None, model: None, model_provider: None, cwd: None, @@ -984,6 +1109,7 @@ impl Runtime { status: "accepted".to_string(), thread: None, threads: Vec::new(), + goal: None, model: None, model_provider: None, cwd: None, @@ -1476,6 +1602,7 @@ fn thread_response_from_new(status: &str, new: NewThread) -> ThreadResponse { status: status.to_string(), thread: Some(new.thread), threads: Vec::new(), + goal: None, model: Some(new.model), model_provider: Some(new.model_provider), cwd: Some(new.cwd), @@ -1556,6 +1683,31 @@ fn to_protocol_thread(thread: ThreadMetadata) -> Thread { } } +fn to_protocol_goal(goal: ThreadGoalRecord) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id, + goal_id: goal.goal_id, + objective: goal.objective, + status: to_protocol_goal_status(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at, + updated_at: goal.updated_at, + } +} + +fn to_protocol_goal_status(status: PersistedThreadGoalStatus) -> ThreadGoalStatus { + match status { + PersistedThreadGoalStatus::Active => ThreadGoalStatus::Active, + PersistedThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + PersistedThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + PersistedThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, + PersistedThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + PersistedThreadGoalStatus::Complete => ThreadGoalStatus::Complete, + } +} + fn to_persisted_status(status: &ThreadStatus) -> PersistedThreadStatus { match status { ThreadStatus::Running => PersistedThreadStatus::Running, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 2e940e19..e6864307 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -80,6 +80,31 @@ pub struct Thread { pub name: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ThreadGoalStatus { + Active, + Paused, + Blocked, + UsageLimited, + BudgetLimited, + Complete, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ThreadGoal { + pub thread_id: String, + pub goal_id: String, + pub objective: String, + pub status: ThreadGoalStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_budget: Option, + pub tokens_used: i64, + pub time_used_seconds: i64, + pub created_at: i64, + pub updated_at: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThreadStartParams { #[serde(skip_serializing_if = "Option::is_none")] @@ -165,6 +190,24 @@ pub struct ThreadSetNameParams { pub name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThreadGoalSetParams { + pub thread_id: String, + pub objective: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_budget: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThreadGoalGetParams { + pub thread_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThreadGoalClearParams { + pub thread_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ThreadRequest { @@ -178,6 +221,9 @@ pub enum ThreadRequest { List(ThreadListParams), Read(ThreadReadParams), SetName(ThreadSetNameParams), + GoalSet(ThreadGoalSetParams), + GoalGet(ThreadGoalGetParams), + GoalClear(ThreadGoalClearParams), Archive { thread_id: String, }, @@ -203,6 +249,9 @@ pub struct ThreadResponse { /// List of threads, populated by `List` requests. #[serde(default)] pub threads: Vec, + /// Thread goal returned by goal get/set requests. + #[serde(skip_serializing_if = "Option::is_none")] + pub goal: Option, /// The model used for the thread, if applicable. #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, @@ -592,6 +641,10 @@ pub enum EventFrame { TurnComplete { turn_id: String }, /// A turn was aborted before completion. TurnAborted { turn_id: String, reason: String }, + /// A thread goal was set or updated. + ThreadGoalUpdated { goal: ThreadGoal }, + /// A thread goal was cleared. + ThreadGoalCleared { thread_id: String }, /// An error occurred during processing. Error { response_id: String, diff --git a/crates/protocol/tests/parity_protocol.rs b/crates/protocol/tests/parity_protocol.rs index 1a05026e..1fbe21e3 100644 --- a/crates/protocol/tests/parity_protocol.rs +++ b/crates/protocol/tests/parity_protocol.rs @@ -1,5 +1,6 @@ use codewhale_protocol::{ - EventFrame, ThreadListParams, ThreadRequest, ThreadResumeParams, + EventFrame, ThreadGoal, ThreadGoalSetParams, ThreadGoalStatus, ThreadListParams, ThreadRequest, + ThreadResumeParams, runtime::{RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeEventEnvelope}, }; use serde_json::{Value, json}; @@ -53,6 +54,48 @@ fn event_frame_serialization_contains_expected_tag() { assert!(encoded.contains("turn_complete")); } +#[test] +fn thread_goal_set_request_round_trip() { + let request = ThreadRequest::GoalSet(ThreadGoalSetParams { + thread_id: "thread-123".to_string(), + objective: "Release 0.8.59".to_string(), + token_budget: Some(42_000), + }); + + let encoded = serde_json::to_string(&request).expect("serialize goal request"); + assert!(encoded.contains("goal_set")); + let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request"); + match decoded { + ThreadRequest::GoalSet(params) => { + assert_eq!(params.thread_id, "thread-123"); + assert_eq!(params.objective, "Release 0.8.59"); + assert_eq!(params.token_budget, Some(42_000)); + } + other => panic!("unexpected request: {other:?}"), + } +} + +#[test] +fn thread_goal_event_serializes_status_and_accounting() { + let goal = ThreadGoal { + thread_id: "thread-123".to_string(), + goal_id: "goal-1".to_string(), + objective: "Release 0.8.59".to_string(), + status: ThreadGoalStatus::BudgetLimited, + token_budget: Some(42_000), + tokens_used: 42_001, + time_used_seconds: 3600, + created_at: 1, + updated_at: 2, + }; + + let frame = EventFrame::ThreadGoalUpdated { goal }; + let encoded = serde_json::to_value(&frame).expect("serialize goal event"); + assert_eq!(encoded["event"], "thread_goal_updated"); + assert_eq!(encoded["goal"]["status"], "budget_limited"); + assert_eq!(encoded["goal"]["tokens_used"], 42_001); +} + #[test] fn runtime_event_envelope_roundtrip() { let input = json!({ diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 825eff94..b788408b 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -195,6 +195,47 @@ pub struct JobStateRecord { pub updated_at: i64, } +/// Persisted lifecycle status for a thread goal. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ThreadGoalStatus { + /// Goal is active and should continue receiving work. + Active, + /// Goal is paused by the user. + Paused, + /// Goal is blocked and cannot make meaningful progress. + Blocked, + /// Goal stopped because account/service usage limits were reached. + UsageLimited, + /// Goal stopped because its explicit token budget was reached. + BudgetLimited, + /// Goal has been completed. + Complete, +} + +/// Persisted goal state attached to a thread. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ThreadGoalRecord { + /// Thread this goal belongs to. + pub thread_id: String, + /// Stable identifier for this goal revision. + pub goal_id: String, + /// User-visible objective. + pub objective: String, + /// Current lifecycle status. + pub status: ThreadGoalStatus, + /// Optional token budget requested by the user. + pub token_budget: Option, + /// Tokens consumed while pursuing the goal. + pub tokens_used: i64, + /// Elapsed wall-clock work time in seconds. + pub time_used_seconds: i64, + /// Unix timestamp (seconds) when the goal was created. + pub created_at: i64, + /// Unix timestamp (seconds) when the goal was last updated. + pub updated_at: i64, +} + /// Filters for listing conversation threads. #[derive(Debug, Clone)] pub struct ThreadListFilters { @@ -475,6 +516,37 @@ impl StateStore { "#, ) .context("failed to initialize workflow trace schema")?; + user_version = 2; + } + if user_version < 3 { + conn.execute_batch( + r#" + BEGIN; + CREATE TABLE IF NOT EXISTS thread_goals ( + thread_id TEXT PRIMARY KEY NOT NULL, + goal_id TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ( + 'active', + 'paused', + 'blocked', + 'usage_limited', + 'budget_limited', + 'complete' + )), + token_budget INTEGER, + tokens_used INTEGER NOT NULL DEFAULT 0, + time_used_seconds INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE + ); + + PRAGMA user_version = 3; + COMMIT; + "#, + ) + .context("failed to initialize thread goal schema")?; } Ok(()) } @@ -661,6 +733,82 @@ impl StateStore { .map(Option::flatten) } + /// Insert or replace the persisted goal for a thread. + pub fn upsert_thread_goal(&self, goal: &ThreadGoalRecord) -> Result<()> { + let conn = self.conn()?; + let exists: Option = conn + .query_row( + "SELECT 1 FROM threads WHERE id = ?1", + params![goal.thread_id], + |row| row.get(0), + ) + .optional() + .context("failed to verify thread before saving goal")?; + if exists.is_none() { + anyhow::bail!("thread {} not found", goal.thread_id); + } + + conn.execute( + r#" + INSERT INTO thread_goals ( + thread_id, goal_id, objective, status, token_budget, tokens_used, + time_used_seconds, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(thread_id) DO UPDATE SET + goal_id=excluded.goal_id, + objective=excluded.objective, + status=excluded.status, + token_budget=excluded.token_budget, + tokens_used=excluded.tokens_used, + time_used_seconds=excluded.time_used_seconds, + created_at=excluded.created_at, + updated_at=excluded.updated_at + "#, + params![ + goal.thread_id, + goal.goal_id, + goal.objective, + thread_goal_status_to_str(&goal.status), + goal.token_budget, + goal.tokens_used, + goal.time_used_seconds, + goal.created_at, + goal.updated_at, + ], + ) + .context("failed to upsert thread goal")?; + Ok(()) + } + + /// Retrieve the persisted goal for a thread. + pub fn get_thread_goal(&self, thread_id: &str) -> Result> { + let conn = self.conn()?; + conn.query_row( + r#" + SELECT thread_id, goal_id, objective, status, token_budget, tokens_used, + time_used_seconds, created_at, updated_at + FROM thread_goals + WHERE thread_id = ?1 + "#, + params![thread_id], + row_to_thread_goal, + ) + .optional() + .context("failed to read thread goal") + } + + /// Delete the persisted goal for a thread. + pub fn delete_thread_goal(&self, thread_id: &str) -> Result { + let conn = self.conn()?; + let changed = conn + .execute( + "DELETE FROM thread_goals WHERE thread_id = ?1", + params![thread_id], + ) + .context("failed to delete thread goal")?; + Ok(changed > 0) + } + /// List all leaf messages in a thread. /// /// A leaf message is one that has no other message referencing it as a parent. @@ -1432,6 +1580,29 @@ fn job_state_status_from_str(value: &str) -> JobStateStatus { } } +fn thread_goal_status_to_str(status: &ThreadGoalStatus) -> &'static str { + match status { + ThreadGoalStatus::Active => "active", + ThreadGoalStatus::Paused => "paused", + ThreadGoalStatus::Blocked => "blocked", + ThreadGoalStatus::UsageLimited => "usage_limited", + ThreadGoalStatus::BudgetLimited => "budget_limited", + ThreadGoalStatus::Complete => "complete", + } +} + +fn thread_goal_status_from_str(value: &str) -> ThreadGoalStatus { + match value { + "active" => ThreadGoalStatus::Active, + "paused" => ThreadGoalStatus::Paused, + "blocked" => ThreadGoalStatus::Blocked, + "usage_limited" => ThreadGoalStatus::UsageLimited, + "budget_limited" => ThreadGoalStatus::BudgetLimited, + "complete" => ThreadGoalStatus::Complete, + _ => ThreadGoalStatus::Active, + } +} + fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result { let status_raw: String = row.get(7)?; let source_raw: String = row.get(11)?; @@ -1462,3 +1633,127 @@ fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result { current_leaf_id: row.get(21)?, }) } + +fn row_to_thread_goal(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let status_raw: String = row.get(3)?; + Ok(ThreadGoalRecord { + thread_id: row.get(0)?, + goal_id: row.get(1)?, + objective: row.get(2)?, + status: thread_goal_status_from_str(&status_raw), + token_budget: row.get(4)?, + tokens_used: row.get(5)?, + time_used_seconds: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_state_store(name: &str) -> StateStore { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-state-{name}-{}-{suffix}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create temp state dir"); + StateStore::open(Some(dir.join("state.db"))).expect("open state store") + } + + fn test_thread(id: &str) -> ThreadMetadata { + ThreadMetadata { + id: id.to_string(), + rollout_path: None, + preview: "test thread".to_string(), + ephemeral: false, + model_provider: "deepseek".to_string(), + created_at: 10, + updated_at: 10, + status: ThreadStatus::Running, + path: None, + cwd: PathBuf::from("/tmp/codewhale"), + cli_version: "0.0.0-test".to_string(), + source: SessionSource::Interactive, + name: None, + sandbox_policy: None, + approval_mode: None, + archived: false, + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + memory_mode: None, + current_leaf_id: None, + } + } + + fn test_goal(thread_id: &str, objective: &str) -> ThreadGoalRecord { + ThreadGoalRecord { + thread_id: thread_id.to_string(), + goal_id: "goal-1".to_string(), + objective: objective.to_string(), + status: ThreadGoalStatus::Active, + token_budget: Some(123), + tokens_used: 7, + time_used_seconds: 11, + created_at: 100, + updated_at: 101, + } + } + + #[test] + fn thread_goal_crud_round_trips_and_replaces() { + let store = temp_state_store("thread-goal-crud"); + store + .upsert_thread(&test_thread("thread-1")) + .expect("upsert thread"); + + let goal = test_goal("thread-1", "Ship v0.8.59"); + store.upsert_thread_goal(&goal).expect("upsert goal"); + assert_eq!( + store + .get_thread_goal("thread-1") + .expect("read goal") + .as_ref(), + Some(&goal) + ); + + let mut replacement = test_goal("thread-1", "Ship v0.8.59 safely"); + replacement.goal_id = "goal-2".to_string(); + replacement.status = ThreadGoalStatus::BudgetLimited; + replacement.token_budget = None; + replacement.updated_at = 202; + store + .upsert_thread_goal(&replacement) + .expect("replace goal"); + assert_eq!( + store.get_thread_goal("thread-1").expect("read replacement"), + Some(replacement) + ); + + assert!(store.delete_thread_goal("thread-1").expect("delete goal")); + assert!( + store + .get_thread_goal("thread-1") + .expect("read empty") + .is_none() + ); + assert!(!store.delete_thread_goal("thread-1").expect("delete empty")); + } + + #[test] + fn thread_goal_requires_existing_thread() { + let store = temp_state_store("thread-goal-missing-thread"); + let err = store + .upsert_thread_goal(&test_goal("missing-thread", "nope")) + .expect_err("goal without a thread should fail"); + assert!(err.to_string().contains("thread missing-thread not found")); + } +} diff --git a/crates/state/tests/parity_state.rs b/crates/state/tests/parity_state.rs index 69481e08..b34c6436 100644 --- a/crates/state/tests/parity_state.rs +++ b/crates/state/tests/parity_state.rs @@ -16,7 +16,7 @@ fn assert_workflow_trace_schema(conn: &Connection) { let user_version: u32 = conn .query_row("PRAGMA user_version;", [], |row| row.get(0)) .expect("read user_version"); - assert_eq!(user_version, 2); + assert_eq!(user_version, 3); for table in [ "workflow_runs", @@ -24,6 +24,7 @@ fn assert_workflow_trace_schema(conn: &Connection) { "leaf_runs", "control_node_runs", "teacher_candidates", + "thread_goals", ] { let exists: bool = conn .query_row( diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index a039edcd..5165f8f8 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -44,6 +44,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Goal lifecycle controls.** `/goal` is now the primary command surface for session goals, with `pause`, `resume`, `complete`, `blocked`, and `clear` controls while `/hunt` remains a compatibility alias. +- **Persistent thread-goal API.** App-server clients can now set, get, and clear + durable thread goals through `thread/goal/set`, `thread/goal/get`, and + `thread/goal/clear`, backed by the state store with Codex-style status and + token/time accounting fields. - **Command-boundary ownership layers (#2888/#3055).** Built-in slash command metadata now lives in `commands/registry.rs`, slash parsing in `commands/parse.rs`, and handlers under group-owned command areas, preserving diff --git a/docs/MODES.md b/docs/MODES.md index fb64fa8a..c5e823e9 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -56,6 +56,12 @@ the turn, `/goal complete` marks it done, `/goal blocked` marks it blocked, and approval mode, or model route. This remains distinct from `--model auto`, which only controls model and thinking selection. +App-server clients can persist a thread-scoped goal with `thread/goal/set`, read +it with `thread/goal/get`, and clear it with `thread/goal/clear`. That persisted +record carries `active`, `paused`, `blocked`, `usage_limited`, `budget_limited`, +or `complete` status plus token/time accounting fields for clients that need +thread resume semantics. + ## Compatibility Notes - Older settings files with `default_mode = "normal"` still load as `agent`; saving rewrites the normalized value. diff --git a/web/app/[locale]/faq/page.tsx b/web/app/[locale]/faq/page.tsx index 6ed64751..6725cc90 100644 --- a/web/app/[locale]/faq/page.tsx +++ b/web/app/[locale]/faq/page.tsx @@ -200,8 +200,10 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} q: "What does /goal do?", a: ( <> - /goal is a simple goal-setter for the current session. - It does not add another app mode; the mode switcher remains Plan, Agent, and YOLO. + /goal sets a goal for the current TUI session. + App-server clients can also persist a thread-scoped goal through the + thread/goal/* methods. It does not add another + app mode; the mode switcher remains Plan, Agent, and YOLO. Track progress in #891. ), @@ -514,7 +516,8 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} a: ( <> Goal 模式是未来的工作流/标签页方向,用于长时间运行的多步目标——不是当前的 /goal 命令。 - 当前的 /goal 是一个简单的目标设置器。完整的 Goal 模式(自主多回合任务执行,支持检查点/恢复)已规划但尚未实现。 + 当前的 /goal 是当前 TUI 会话的目标设置器;app-server 客户端也可以通过 thread/goal/* 方法持久化线程目标。 + 完整的 Goal 工作区(自主多回合任务执行,带更完整的检查点/恢复 UI)仍在规划中。 关注 #891 的进展。 ),