Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/en/developer-guide/runtime-local-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ Wework searches device-local work with:
POST /api/runtime-work/search
```

Backend fans out `runtime.tasks.search` only to the current user's online or busy devices. It does not read central `TaskResource`, `Subtask`, or cached history rows. The executor searches local task titles and transcripts, then returns snippets, message metadata, update time, device name, workspace path, and the transient task address.
Backend fans out `runtime.tasks.search` only to the current user's online or busy devices. It does not read central `TaskResource`, `Subtask`, or cached history rows. The executor searches local task titles and transcripts, then returns snippets, message metadata, update time, device name, workspace path, and the transient task address. For Codex history threads, the transient task address must include `runtimeHandle.threadId` so opening a search result can load the original conversation through the transcript RPC without depending on a matching link already existing in the local task index.

Search results are merged by `updatedAt` descending and capped by the request `limit`. `includeArchived` is passed to the executor so it can decide whether archived LocalTasks are included. When the request includes `projectId`, Backend derives the Project from each workspace path and only returns results under that Project; `workspaceKind: chat` Conversation results have no Project owner.

The frontend search dialog opens only the `deviceId + localTaskId` address from the result, then restores workspace context from the latest runtime work list.
The frontend search dialog opens the runtime address from the result, then restores workspace context from the latest runtime work list. The dialog only keeps recent query results in memory to avoid repeating the same RPC while the dialog is open; cached results are not written to Backend and do not replace executor-side transcript reads.

## Open And Continue

Expand Down
4 changes: 2 additions & 2 deletions docs/en/user-guide/ai-devices/local-device-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,10 @@ The installer and first startup create `~/.wegent-executor/device-config.json`.

#### Claude Code Execution Timeout

When the local executor starts a Claude Code child process, it waits up to 1 hour by default. Long-running code generation, dependency installation, or file processing tasks can continue within that window. To tune the limit for a specific environment, set `WEGENT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS` before starting the executor. This setting only affects Claude Code child processes, not the native Codex app-server path; Codex RPC timeouts are controlled by `WEGENT_CODEX_RPC_TIMEOUT_SECONDS`.
When the local executor starts a Claude Code child process, it waits up to 24 hours by default. Long-running code generation, dependency installation, or file processing tasks can continue within that window. To tune the limit for a specific environment, set `WEGENT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS` before starting the executor. This setting only affects Claude Code child processes, not the native Codex app-server path; Codex RPC timeouts are controlled by `WEGENT_CODEX_RPC_TIMEOUT_SECONDS`.

```bash
export WEGENT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS=7200
export WEGENT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS=172800
wegent-executor
```

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/developer-guide/runtime-local-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ Wework 使用运行时搜索能力查找设备上的本地任务:
POST /api/runtime-work/search
```

Backend 只向当前用户在线或 busy 的设备 fan-out `runtime.tasks.search` RPC,不读取中心库 `TaskResource`、`Subtask` 或历史缓存。executor 在本机任务标题和 transcript 中搜索,并返回匹配片段、消息元数据、更新时间、设备名、工作区路径和临时任务地址。
Backend 只向当前用户在线或 busy 的设备 fan-out `runtime.tasks.search` RPC,不读取中心库 `TaskResource`、`Subtask` 或历史缓存。executor 在本机任务标题和 transcript 中搜索,并返回匹配片段、消息元数据、更新时间、设备名、工作区路径和临时任务地址。对于 Codex 历史线程,临时任务地址必须携带 `runtimeHandle.threadId`,这样点击搜索结果后可以直接通过 transcript RPC 读取原始会话,而不依赖本地任务索引中已经存在对应 link。

搜索结果按 `updatedAt` 倒序聚合,并受请求 `limit` 限制。`includeArchived` 传给 executor 决定是否包含已归档 LocalTask。请求携带 `projectId` 时,Backend 会根据工作区路径推导 Project,并只返回该 Project 下的搜索结果;`workspaceKind: chat` 的 Conversation 结果没有 Project 归属。

前端搜索框只打开结果里的 `deviceId + localTaskId` 地址,随后仍通过最新 runtime work 列表恢复工作区上下文。
前端搜索框打开结果里的运行时地址,随后仍通过最新 runtime work 列表恢复工作区上下文。搜索框只在内存中保留最近查询结果,用于避免同一会话内重复输入触发相同 RPC;缓存结果不写入 Backend,也不替代 executor 侧的 transcript 读取。

## 打开和继续任务

Expand Down
4 changes: 2 additions & 2 deletions docs/zh/user-guide/ai-devices/local-device-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,10 @@ wegent-executor

#### Claude Code 执行超时

本地 executor 启动 Claude Code 子进程时,默认最长等待 1 小时。长时间代码生成、依赖安装或文件处理任务可以在这个时间内继续运行。若需要为特定环境调整该限制,可在启动 executor 前设置 `WEGENT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS`。该配置只影响 Claude Code 子进程,不影响 native Codex app-server;Codex RPC 超时由 `WEGENT_CODEX_RPC_TIMEOUT_SECONDS` 控制。
本地 executor 启动 Claude Code 子进程时,默认最长等待 24 小时。长时间代码生成、依赖安装或文件处理任务可以在这个时间内继续运行。若需要为特定环境调整该限制,可在启动 executor 前设置 `WEGENT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS`。该配置只影响 Claude Code 子进程,不影响 native Codex app-server;Codex RPC 超时由 `WEGENT_CODEX_RPC_TIMEOUT_SECONDS` 控制。

```bash
export WEGENT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS=7200
export WEGENT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS=172800
wegent-executor
```

Expand Down
4 changes: 2 additions & 2 deletions executor/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub use codex::{
pub use dify::{build_dify_config, saved_dify_task_id, DifyEngine};
pub use image_validator::ImageValidatorEngine;

const DEFAULT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS: u64 = 3600;
const DEFAULT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS: u64 = 24 * 60 * 60;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentCommandPlanner {
Expand Down Expand Up @@ -353,6 +353,6 @@ mod tests {
let _old_timeout = EnvGuard::set("WEGENT_EXECUTOR_PROCESS_TIMEOUT_SECONDS", "1");
let _timeout = EnvGuard::remove("WEGENT_CLAUDE_CODE_PROCESS_TIMEOUT_SECONDS");

assert_eq!(claude_code_process_timeout_seconds(), 3600);
assert_eq!(claude_code_process_timeout_seconds(), 86_400);
}
}
89 changes: 86 additions & 3 deletions executor/src/runtime_work/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ const PENDING_THREAD_EVENT_ROUTE_PREFIX: &str = "pending:";
const ACTIVE_CODEX_TURN_WAIT_ATTEMPTS: usize = 20;
const ACTIVE_CODEX_TURN_WAIT_MS: u64 = 50;
const TRANSCRIPT_NAVIGATION_PREVIEW_CHARS: usize = 96;
const SEARCH_SNIPPET_CONTEXT_CHARS: usize = 80;
const SEARCH_SNIPPET_MAX_CHARS: usize = 240;
const CODEX_OFFICIAL_PROVIDER_ID: &str = "openai";
const CODEX_OFFICIAL_PROVIDER_NAME: &str = "CodeX";

Expand Down Expand Up @@ -3220,13 +3222,14 @@ fn first_message_search_result(
let Some((match_start, match_end)) = text_match(&content, query) else {
continue;
};
let snippet = bounded_search_snippet(&content, match_start, match_end);
return Some(search_result_item(
link,
device_id,
SearchResultMatch {
snippet: content,
match_start,
match_end,
snippet: snippet.text,
match_start: snippet.match_start,
match_end: snippet.match_end,
message_id: string_field(&message, "id").unwrap_or_default(),
message_role: string_field(&message, "role")
.unwrap_or_else(|| "message".to_owned()),
Expand All @@ -3237,6 +3240,56 @@ fn first_message_search_result(
None
}

struct SearchSnippet {
text: String,
match_start: usize,
match_end: usize,
}

fn bounded_search_snippet(text: &str, match_start: usize, match_end: usize) -> SearchSnippet {
let total_chars = text.chars().count();
if total_chars <= SEARCH_SNIPPET_MAX_CHARS {
return SearchSnippet {
text: text.to_owned(),
match_start,
match_end,
};
}

let match_start_char = text[..match_start].chars().count();
let match_end_char = text[..match_end].chars().count();
let match_chars = match_end_char.saturating_sub(match_start_char);
let context_budget = SEARCH_SNIPPET_MAX_CHARS.saturating_sub(match_chars);
let before_budget = context_budget.min(SEARCH_SNIPPET_CONTEXT_CHARS);
let after_budget = context_budget.saturating_sub(before_budget);
let before_chars = before_budget.min(match_start_char);
let mut after_chars = after_budget.min(total_chars.saturating_sub(match_end_char));

let unused_before_budget = before_budget.saturating_sub(before_chars);
if unused_before_budget > 0 {
after_chars =
(after_chars + unused_before_budget).min(total_chars.saturating_sub(match_end_char));
}

let snippet_start_char = match_start_char.saturating_sub(before_chars);
let snippet_end_char = (match_end_char + after_chars).min(total_chars);
let snippet_start_byte = byte_index_for_char(text, snippet_start_char);
let snippet_end_byte = byte_index_for_char(text, snippet_end_char);

SearchSnippet {
text: text[snippet_start_byte..snippet_end_byte].to_owned(),
match_start: match_start.saturating_sub(snippet_start_byte),
match_end: match_end.saturating_sub(snippet_start_byte),
}
}

fn byte_index_for_char(text: &str, char_index: usize) -> usize {
text.char_indices()
.nth(char_index)
.map(|(byte_index, _)| byte_index)
.unwrap_or(text.len())
}

fn cached_transcript_response(
link: &RuntimeTaskLink,
messages: Vec<Value>,
Expand Down Expand Up @@ -3968,6 +4021,36 @@ mod tests {
assert_eq!(result["messages"].as_array().unwrap().len(), 0);
}

#[test]
fn first_message_search_result_returns_bounded_snippet() {
let link = RuntimeTaskLink::new_pending(
"local-task-1".to_owned(),
"/tmp/project".to_owned(),
"Long message task".to_owned(),
);
let content = format!("{}needle{}", "a".repeat(300), "b".repeat(300));

let result = first_message_search_result(
&link,
"device-1",
vec![json!({
"id": "message-1",
"role": "user",
"content": content,
"createdAt": 1780000000,
})],
"needle",
)
.expect("long matching message should produce a result");
let snippet = result["snippet"].as_str().unwrap();
let match_start = result["matchStart"].as_u64().unwrap() as usize;
let match_end = result["matchEnd"].as_u64().unwrap() as usize;

assert!(snippet.len() < 300);
assert!(snippet.contains("needle"));
assert_eq!(&snippet[match_start..match_end], "needle");
}

#[test]
fn pending_thread_event_route_promotes_on_thread_started() {
let index_path = temp_runtime_work_index_path("pending-thread-event-route");
Expand Down
50 changes: 32 additions & 18 deletions executor/src/runtime_work/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,19 +397,7 @@ pub(crate) fn search_result_item(
}

fn local_task_json(link: RuntimeTaskLink) -> Value {
let mut runtime_handle = link
.runtime_handle
.as_object()
.cloned()
.unwrap_or_else(Map::new);
runtime_handle.insert(
"threadId".to_owned(),
link.thread_id
.as_ref()
.map(|thread_id| Value::String(thread_id.clone()))
.unwrap_or(Value::Null),
);

let runtime_handle = runtime_handle_with_thread_id(&link);
let mut task = Map::new();
task.insert("taskId".to_owned(), Value::String(link.local_task_id));
task.insert(
Expand Down Expand Up @@ -461,11 +449,37 @@ fn archived_conversation_item(link: &RuntimeTaskLink, device_id: &str) -> Value
}

fn runtime_task_address(link: &RuntimeTaskLink, device_id: &str) -> Value {
json!({
"deviceId": device_id,
"workspacePath": link.workspace_path,
"taskId": link.local_task_id,
})
let mut address = Map::new();
address.insert("deviceId".to_owned(), Value::String(device_id.to_owned()));
address.insert(
"workspacePath".to_owned(),
Value::String(link.workspace_path.clone()),
);
address.insert(
"taskId".to_owned(),
Value::String(link.local_task_id.clone()),
);
address.insert(
"runtimeHandle".to_owned(),
Value::Object(runtime_handle_with_thread_id(link)),
);
Value::Object(address)
}

fn runtime_handle_with_thread_id(link: &RuntimeTaskLink) -> Map<String, Value> {
let mut runtime_handle = link
.runtime_handle
.as_object()
.cloned()
.unwrap_or_else(Map::new);
runtime_handle.insert(
"threadId".to_owned(),
link.thread_id
.as_ref()
.map(|thread_id| Value::String(thread_id.clone()))
.unwrap_or(Value::Null),
);
runtime_handle
}

fn thread_status(thread: &Value) -> String {
Expand Down
18 changes: 18 additions & 0 deletions executor/tests/app_runtime_work_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -869,10 +869,28 @@ async fn app_runtime_searches_codex_titles_and_transcripts() {
assert_eq!(title_result["success"], true);
assert_eq!(title_result["items"][0]["address"]["taskId"], "thread-1");
assert_eq!(title_result["items"][0]["address"]["deviceId"], "device-1");
assert_eq!(
title_result["items"][0]["address"]["runtimeHandle"]["threadId"],
"thread-1"
);
assert_eq!(title_result["items"][0]["title"], "Fix CI");
assert_eq!(title_result["items"][0]["snippet"], "Fix CI");
assert_eq!(title_result["items"][0]["messageRole"], "title");

let transcript_from_search_result = handler
.handle_runtime_rpc(json!({
"method": "runtime.tasks.transcript",
"payload": title_result["items"][0]["address"].clone()
}))
.await
.expect("search result address should load transcript");

assert_eq!(transcript_from_search_result["success"], true);
assert_eq!(
transcript_from_search_result["messages"][0]["content"],
"please fix ci"
);

let transcript_result = handler
.handle_runtime_rpc(json!({
"method": "runtime.tasks.search",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5785,7 +5785,7 @@ describe('DesktopWorkbenchLayout', () => {
await waitFor(() => {
expect(closeLocalTerminalMock).toHaveBeenCalledWith('local-terminal-a')
})
})
}, 10000)

test('opens the bottom workspace add menu without replacing the terminal', async () => {
renderWorkspacePanelLayout()
Expand Down
Loading
Loading