feat(goal): persist thread goals through app server
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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::<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" => {
|
||||
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]
|
||||
|
||||
+155
-3
@@ -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<Option<ThreadGoal>> {
|
||||
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<Option<ThreadGoal>> {
|
||||
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<bool> {
|
||||
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,
|
||||
|
||||
@@ -80,6 +80,31 @@ pub struct Thread {
|
||||
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)]
|
||||
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<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)]
|
||||
#[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>,
|
||||
/// 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.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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<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.
|
||||
#[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<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.
|
||||
///
|
||||
/// 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> {
|
||||
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<ThreadMetadata> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -200,8 +200,10 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`}
|
||||
q: "What does /goal do?",
|
||||
a: (
|
||||
<>
|
||||
<code className="inline">/goal</code> is a simple goal-setter for the current session.
|
||||
It does not add another app mode; the mode switcher remains Plan, Agent, and YOLO.
|
||||
<code className="inline">/goal</code> sets a goal for the current TUI session.
|
||||
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>.
|
||||
</>
|
||||
),
|
||||
@@ -514,7 +516,8 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`}
|
||||
a: (
|
||||
<>
|
||||
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> 的进展。
|
||||
</>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user