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
|
- **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
|
||||||
|
|||||||
@@ -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
@@ -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(¶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.
|
/// 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(¶ms)?,
|
threads: self.thread_manager.list_threads(¶ms)?,
|
||||||
|
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(¶ms)?,
|
thread: self.thread_manager.read_thread(¶ms)?,
|
||||||
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(¶ms)?,
|
thread: self.thread_manager.set_thread_name(¶ms)?,
|
||||||
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(¶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 } => {
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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> 的进展。
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user