feat(goal): persist thread goals through app server

This commit is contained in:
Hunter B
2026-06-12 06:28:47 -07:00
parent 8f265e204f
commit cf910b7da2
10 changed files with 676 additions and 9 deletions
+4
View File
@@ -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 - **Goal lifecycle controls.** `/goal` is now the primary command surface for
session goals, with `pause`, `resume`, `complete`, `blocked`, and `clear` session goals, with `pause`, `resume`, `complete`, `blocked`, and `clear`
controls while `/hunt` remains a compatibility alias. 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 - **Command-boundary ownership layers (#2888/#3055).** Built-in slash command
metadata now lives in `commands/registry.rs`, slash parsing in metadata now lives in `commands/registry.rs`, slash parsing in
`commands/parse.rs`, and handlers under group-owned command areas, preserving `commands/parse.rs`, and handlers under group-owned command areas, preserving
+107 -1
View File
@@ -15,7 +15,8 @@ use codewhale_core::Runtime;
use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink}; use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink};
use codewhale_mcp::McpManager; use codewhale_mcp::McpManager;
use codewhale_protocol::{ use codewhale_protocol::{
AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadRequest, ThreadResponse, AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadGoalClearParams,
ThreadGoalGetParams, ThreadGoalSetParams, ThreadRequest, ThreadResponse,
}; };
use codewhale_state::StateStore; use codewhale_state::StateStore;
use codewhale_tools::{ToolCall, ToolRegistry}; use codewhale_tools::{ToolCall, ToolRegistry};
@@ -241,6 +242,7 @@ async fn thread_handler(
status: format!("error:{err}"), status: format!("error:{err}"),
thread: None, thread: None,
threads: Vec::new(), threads: Vec::new(),
goal: None,
model: None, model: None,
model_provider: None, model_provider: None,
cwd: None, cwd: None,
@@ -568,6 +570,9 @@ async fn dispatch_stdio_request(
"thread/list", "thread/list",
"thread/read", "thread/read",
"thread/set_name", "thread/set_name",
"thread/goal/set",
"thread/goal/get",
"thread/goal/clear",
"thread/archive", "thread/archive",
"thread/unarchive", "thread/unarchive",
"thread/message", "thread/message",
@@ -598,6 +603,9 @@ async fn dispatch_stdio_request(
"thread/list", "thread/list",
"thread/read", "thread/read",
"thread/set_name", "thread/set_name",
"thread/goal/set",
"thread/goal/get",
"thread/goal/clear",
"thread/archive", "thread/archive",
"thread/unarchive", "thread/unarchive",
"thread/message" "thread/message"
@@ -688,6 +696,39 @@ async fn dispatch_stdio_request(
should_exit: false, should_exit: false,
} }
} }
"thread/goal/set" | "thread/goal_set" | "thread/goal-set" => {
let request = ThreadRequest::GoalSet(parse_params::<ThreadGoalSetParams>(
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::<ThreadGoalGetParams>(
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::<ThreadGoalClearParams>(
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" => { "thread/archive" => {
let parsed: ThreadIdParams = parse_params(params_or_object(params))?; let parsed: ThreadIdParams = parse_params(params_or_object(params))?;
let response = handle_thread_request( let response = handle_thread_request(
@@ -1134,6 +1175,71 @@ mod tests {
assert_eq!(response.data["value"], "sk-deepseek-secret"); 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 ───────────────────────────────────────────── // ── resolve_auth_token ─────────────────────────────────────────────
#[test] #[test]
+155 -3
View File
@@ -15,12 +15,14 @@ use codewhale_mcp::{
}; };
use codewhale_protocol::{ use codewhale_protocol::{
AppResponse, EventFrame, ExecApprovalRequestEvent, PromptRequest, PromptResponse, 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, ThreadRequest, ThreadResponse, ThreadResumeParams, ThreadSetNameParams, ThreadStatus,
ToolPayload, ToolPayload,
}; };
use codewhale_state::{ use codewhale_state::{
JobStateRecord, JobStateStatus, SessionSource, StateStore, ThreadListFilters, ThreadMetadata, JobStateRecord, JobStateStatus, SessionSource, StateStore, ThreadGoalRecord,
ThreadGoalStatus as PersistedThreadGoalStatus, ThreadListFilters, ThreadMetadata,
ThreadStatus as PersistedThreadStatus, ThreadStatus as PersistedThreadStatus,
}; };
use codewhale_tools::{ToolCall, ToolRegistry}; use codewhale_tools::{ToolCall, ToolRegistry};
@@ -644,6 +646,40 @@ impl ThreadManager {
Ok(Some(updated)) Ok(Some(updated))
} }
/// Sets or replaces the persisted goal for a thread.
pub fn set_thread_goal(&mut self, params: &ThreadGoalSetParams) -> Result<Option<ThreadGoal>> {
if self.store.get_thread(&params.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<Option<ThreadGoal>> {
Ok(self
.store
.get_thread_goal(&params.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<bool> {
self.store.delete_thread_goal(&params.thread_id)
}
/// Archives a thread so it no longer appears in default listings. /// Archives a thread so it no longer appears in default listings.
pub fn archive_thread(&mut self, thread_id: &str) -> Result<()> { pub fn archive_thread(&mut self, thread_id: &str) -> Result<()> {
self.store.mark_archived(thread_id)?; 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!({ Ok(json!({
"history": history, "history": history,
"checkpoint": checkpoint "checkpoint": checkpoint,
"goal": goal
})) }))
} }
@@ -858,6 +901,7 @@ impl Runtime {
status: "missing".to_string(), status: "missing".to_string(),
thread: None, thread: None,
threads: Vec::new(), threads: Vec::new(),
goal: None,
model: None, model: None,
model_provider: None, model_provider: None,
cwd: None, cwd: None,
@@ -880,6 +924,7 @@ impl Runtime {
status: "missing".to_string(), status: "missing".to_string(),
thread: None, thread: None,
threads: Vec::new(), threads: Vec::new(),
goal: None,
model: None, model: None,
model_provider: None, model_provider: None,
cwd: None, cwd: None,
@@ -895,6 +940,7 @@ impl Runtime {
status: "ok".to_string(), status: "ok".to_string(),
thread: None, thread: None,
threads: self.thread_manager.list_threads(&params)?, threads: self.thread_manager.list_threads(&params)?,
goal: None,
model: None, model: None,
model_provider: None, model_provider: None,
cwd: None, cwd: None,
@@ -911,6 +957,9 @@ impl Runtime {
status: "ok".to_string(), status: "ok".to_string(),
thread: self.thread_manager.read_thread(&params)?, thread: self.thread_manager.read_thread(&params)?,
threads: Vec::new(), threads: Vec::new(),
goal: self.thread_manager.get_thread_goal(&ThreadGoalGetParams {
thread_id: params.thread_id,
})?,
model: None, model: None,
model_provider: None, model_provider: None,
cwd: None, cwd: None,
@@ -925,6 +974,7 @@ impl Runtime {
status: "ok".to_string(), status: "ok".to_string(),
thread: self.thread_manager.set_thread_name(&params)?, thread: self.thread_manager.set_thread_name(&params)?,
threads: Vec::new(), threads: Vec::new(),
goal: None,
model: None, model: None,
model_provider: None, model_provider: None,
cwd: None, cwd: None,
@@ -933,6 +983,79 @@ impl Runtime {
events: Vec::new(), events: Vec::new(),
data: json!({}), data: json!({}),
}), }),
ThreadRequest::GoalSet(params) => {
let thread_id = params.thread_id.clone();
if let Some(goal) = self.thread_manager.set_thread_goal(&params)? {
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(&params)?;
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(&params)?;
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 } => { ThreadRequest::Archive { thread_id } => {
self.thread_manager.archive_thread(&thread_id)?; self.thread_manager.archive_thread(&thread_id)?;
Ok(ThreadResponse { Ok(ThreadResponse {
@@ -940,6 +1063,7 @@ impl Runtime {
status: "archived".to_string(), status: "archived".to_string(),
thread: None, thread: None,
threads: Vec::new(), threads: Vec::new(),
goal: None,
model: None, model: None,
model_provider: None, model_provider: None,
cwd: None, cwd: None,
@@ -956,6 +1080,7 @@ impl Runtime {
status: "unarchived".to_string(), status: "unarchived".to_string(),
thread: None, thread: None,
threads: Vec::new(), threads: Vec::new(),
goal: None,
model: None, model: None,
model_provider: None, model_provider: None,
cwd: None, cwd: None,
@@ -984,6 +1109,7 @@ impl Runtime {
status: "accepted".to_string(), status: "accepted".to_string(),
thread: None, thread: None,
threads: Vec::new(), threads: Vec::new(),
goal: None,
model: None, model: None,
model_provider: None, model_provider: None,
cwd: None, cwd: None,
@@ -1476,6 +1602,7 @@ fn thread_response_from_new(status: &str, new: NewThread) -> ThreadResponse {
status: status.to_string(), status: status.to_string(),
thread: Some(new.thread), thread: Some(new.thread),
threads: Vec::new(), threads: Vec::new(),
goal: None,
model: Some(new.model), model: Some(new.model),
model_provider: Some(new.model_provider), model_provider: Some(new.model_provider),
cwd: Some(new.cwd), 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 { fn to_persisted_status(status: &ThreadStatus) -> PersistedThreadStatus {
match status { match status {
ThreadStatus::Running => PersistedThreadStatus::Running, ThreadStatus::Running => PersistedThreadStatus::Running,
+53
View File
@@ -80,6 +80,31 @@ pub struct Thread {
pub name: Option<String>, pub name: Option<String>,
} }
#[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<i64>,
pub tokens_used: i64,
pub time_used_seconds: i64,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadStartParams { pub struct ThreadStartParams {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -165,6 +190,24 @@ pub struct ThreadSetNameParams {
pub name: String, 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<i64>,
}
#[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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum ThreadRequest { pub enum ThreadRequest {
@@ -178,6 +221,9 @@ pub enum ThreadRequest {
List(ThreadListParams), List(ThreadListParams),
Read(ThreadReadParams), Read(ThreadReadParams),
SetName(ThreadSetNameParams), SetName(ThreadSetNameParams),
GoalSet(ThreadGoalSetParams),
GoalGet(ThreadGoalGetParams),
GoalClear(ThreadGoalClearParams),
Archive { Archive {
thread_id: String, thread_id: String,
}, },
@@ -203,6 +249,9 @@ pub struct ThreadResponse {
/// List of threads, populated by `List` requests. /// List of threads, populated by `List` requests.
#[serde(default)] #[serde(default)]
pub threads: Vec<Thread>, pub threads: Vec<Thread>,
/// Thread goal returned by goal get/set requests.
#[serde(skip_serializing_if = "Option::is_none")]
pub goal: Option<ThreadGoal>,
/// The model used for the thread, if applicable. /// The model used for the thread, if applicable.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
@@ -592,6 +641,10 @@ pub enum EventFrame {
TurnComplete { turn_id: String }, TurnComplete { turn_id: String },
/// A turn was aborted before completion. /// A turn was aborted before completion.
TurnAborted { turn_id: String, reason: String }, 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. /// An error occurred during processing.
Error { Error {
response_id: String, response_id: String,
+44 -1
View File
@@ -1,5 +1,6 @@
use codewhale_protocol::{ use codewhale_protocol::{
EventFrame, ThreadListParams, ThreadRequest, ThreadResumeParams, EventFrame, ThreadGoal, ThreadGoalSetParams, ThreadGoalStatus, ThreadListParams, ThreadRequest,
ThreadResumeParams,
runtime::{RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeEventEnvelope}, runtime::{RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeEventEnvelope},
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -53,6 +54,48 @@ fn event_frame_serialization_contains_expected_tag() {
assert!(encoded.contains("turn_complete")); 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] #[test]
fn runtime_event_envelope_roundtrip() { fn runtime_event_envelope_roundtrip() {
let input = json!({ let input = json!({
+295
View File
@@ -195,6 +195,47 @@ pub struct JobStateRecord {
pub updated_at: i64, 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<i64>,
/// 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. /// Filters for listing conversation threads.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ThreadListFilters { pub struct ThreadListFilters {
@@ -475,6 +516,37 @@ impl StateStore {
"#, "#,
) )
.context("failed to initialize workflow trace schema")?; .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(()) Ok(())
} }
@@ -661,6 +733,82 @@ impl StateStore {
.map(Option::flatten) .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<i64> = 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<Option<ThreadGoalRecord>> {
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<bool> {
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. /// List all leaf messages in a thread.
/// ///
/// A leaf message is one that has no other message referencing it as a parent. /// 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<ThreadMetadata> { fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
let status_raw: String = row.get(7)?; let status_raw: String = row.get(7)?;
let source_raw: String = row.get(11)?; let source_raw: String = row.get(11)?;
@@ -1462,3 +1633,127 @@ fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
current_leaf_id: row.get(21)?, current_leaf_id: row.get(21)?,
}) })
} }
fn row_to_thread_goal(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadGoalRecord> {
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"));
}
}
+2 -1
View File
@@ -16,7 +16,7 @@ fn assert_workflow_trace_schema(conn: &Connection) {
let user_version: u32 = conn let user_version: u32 = conn
.query_row("PRAGMA user_version;", [], |row| row.get(0)) .query_row("PRAGMA user_version;", [], |row| row.get(0))
.expect("read user_version"); .expect("read user_version");
assert_eq!(user_version, 2); assert_eq!(user_version, 3);
for table in [ for table in [
"workflow_runs", "workflow_runs",
@@ -24,6 +24,7 @@ fn assert_workflow_trace_schema(conn: &Connection) {
"leaf_runs", "leaf_runs",
"control_node_runs", "control_node_runs",
"teacher_candidates", "teacher_candidates",
"thread_goals",
] { ] {
let exists: bool = conn let exists: bool = conn
.query_row( .query_row(
+4
View File
@@ -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 - **Goal lifecycle controls.** `/goal` is now the primary command surface for
session goals, with `pause`, `resume`, `complete`, `blocked`, and `clear` session goals, with `pause`, `resume`, `complete`, `blocked`, and `clear`
controls while `/hunt` remains a compatibility alias. 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 - **Command-boundary ownership layers (#2888/#3055).** Built-in slash command
metadata now lives in `commands/registry.rs`, slash parsing in metadata now lives in `commands/registry.rs`, slash parsing in
`commands/parse.rs`, and handlers under group-owned command areas, preserving `commands/parse.rs`, and handlers under group-owned command areas, preserving
+6
View File
@@ -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 approval mode, or model route. This remains distinct from `--model auto`, which
only controls model and thinking selection. 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 ## Compatibility Notes
- Older settings files with `default_mode = "normal"` still load as `agent`; saving rewrites the normalized value. - Older settings files with `default_mode = "normal"` still load as `agent`; saving rewrites the normalized value.
+6 -3
View File
@@ -200,8 +200,10 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`}
q: "What does /goal do?", q: "What does /goal do?",
a: ( a: (
<> <>
<code className="inline">/goal</code> is a simple goal-setter for the current session. <code className="inline">/goal</code> sets a goal for the current TUI session.
It does not add another app mode; the mode switcher remains Plan, Agent, and YOLO. App-server clients can also persist a thread-scoped goal through the
<code className="inline">thread/goal/*</code> methods. It does not add another
app mode; the mode switcher remains Plan, Agent, and YOLO.
Track progress in <a href="https://github.com/Hmbown/CodeWhale/issues/891" className="body-link">#891</a>. Track progress in <a href="https://github.com/Hmbown/CodeWhale/issues/891" className="body-link">#891</a>.
</> </>
), ),
@@ -514,7 +516,8 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`}
a: ( a: (
<> <>
Goal / <code className="inline">/goal</code> Goal / <code className="inline">/goal</code>
<code className="inline">/goal</code> Goal / <code className="inline">/goal</code> TUI app-server <code className="inline">thread/goal/*</code> 线
Goal / UI
<a href="https://github.com/Hmbown/CodeWhale/issues/891" className="body-link">#891</a> <a href="https://github.com/Hmbown/CodeWhale/issues/891" className="body-link">#891</a>
</> </>
), ),