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
2 changes: 2 additions & 0 deletions docs/en/developer-guide/local-device-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ The runtime task `running` field represents only whether a model turn is current

Goals have an independent lifecycle. An `active` goal means that its objective can continue in later turns; it does not mean that a model turn is currently executing. Keeping an active goal while a task is idle must not mark the task as running again. A user's next message creates a new turn directly instead of being sent as guidance to an in-progress turn.

Codex guidance is sent to the active turn through the shared app-server. If that turn finishes or changes while guidance is being sent, the executor reports the race as `no_active_turn`; Wework then sends the same content as a normal follow-up message so user input is preserved without a misleading send failure.

### Backend Device Chat Task REST Entrypoint

The web device chat page still sends messages through WebSocket. For external systems or curl-based callers that need to create the same kind of task, Backend exposes a REST entrypoint:
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/developer-guide/local-device-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Backend 是可选能力,而不是本地 app 的必需依赖。需要登录、

目标(goal)有独立的生命周期。目标为 `active` 表示其目标仍可在后续回合继续推进,不表示当前存在模型回合。因此,任务空闲时保留 active goal 不会将任务重新标记为运行中;用户发送下一条消息会直接创建新回合,而不是把消息作为对运行中回合的引导。

Codex 引导通过共享 app-server 的活跃回合发送。若回合恰好在发送期间结束或切换,executor 会将该竞态报告为 `no_active_turn`;Wework 随后把同一内容作为普通后续消息发送,避免丢失用户输入或显示误导性的发送失败。

### 后端设备对话任务 REST 入口

网页版设备对话页仍然通过 WebSocket 发送消息。对于需要从外部系统或 curl 创建同类任务的场景,Backend 提供 REST 入口:
Expand Down
46 changes: 38 additions & 8 deletions executor/src/runtime_work/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2176,14 +2176,17 @@ impl RuntimeWorkRpcHandler {
"turnId": turn_id,
"runtime": "codex",
})),
Err(error) => Ok(json!({
"success": false,
"accepted": false,
"error": error,
"code": "guidance_failed",
"taskId": local_task_id,
"runtime": "codex",
})),
Err(error) => {
let code = codex_guidance_failure_code(&error);
Ok(json!({
"success": false,
"accepted": false,
"error": error,
"code": code,
"taskId": local_task_id,
"runtime": "codex",
}))
}
}
}

Expand Down Expand Up @@ -4972,6 +4975,17 @@ fn guidance_input_items(message: &str, attachments: Option<&Value>) -> Vec<Value
inputs
}

fn codex_guidance_failure_code(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("no active turn to steer")
|| (normalized.contains("expected active turn id") && normalized.contains("but found"))
{
"no_active_turn"
} else {
"guidance_failed"
}
}

fn copy_attachment_field(source: &Map<String, Value>, target: &mut Map<String, Value>, key: &str) {
if let Some(value) = source.get(key).cloned() {
target.insert(key.to_owned(), value);
Expand Down Expand Up @@ -6598,6 +6612,22 @@ mod tests {
assert!(guidance_input_items("", None).is_empty());
}

#[test]
fn codex_guidance_turn_races_are_reported_as_no_active_turn() {
assert_eq!(
codex_guidance_failure_code("no active turn to steer"),
"no_active_turn"
);
assert_eq!(
codex_guidance_failure_code("expected active turn id `turn-1` but found `turn-2`"),
"no_active_turn"
);
assert_eq!(
codex_guidance_failure_code("turn/steer response missing turnId"),
"guidance_failed"
);
}

#[test]
fn archived_cleanup_targets_do_not_delete_regular_project_root() {
let link = RuntimeTaskLink::new_pending(
Expand Down
5 changes: 3 additions & 2 deletions wework/src/features/workbench/WorkbenchProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6765,13 +6765,13 @@ describe('WorkbenchProvider runtime tasks', () => {
expect(screen.getByTestId('queued-notices')).not.toHaveTextContent('正在引导当前对话')
})

test('sends queued guidance as a follow-up when the pane is already idle', async () => {
test('sends queued guidance as a follow-up when the active turn is unavailable', async () => {
let streamHandlers: ChatStreamHandlers = {}
const subscribe = vi.fn((handlers: ChatStreamHandlers) => {
if (hasRuntimeStreamHandler(handlers)) streamHandlers = handlers
return vi.fn()
})
const sendRuntimeMessage = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce({
const sendRuntimeMessage = vi.fn().mockResolvedValueOnce(false).mockResolvedValue({
accepted: true,
taskId: 'runtime-a',
})
Expand Down Expand Up @@ -6877,6 +6877,7 @@ describe('WorkbenchProvider runtime tasks', () => {

await userEvent.click(screen.getByText('guide first queued'))

await waitFor(() => expect(guideRuntimeTask).toHaveBeenCalledTimes(1))
await waitFor(() => expect(sendRuntimeMessage).toHaveBeenCalledTimes(2))
expect(sendRuntimeMessage).toHaveBeenLastCalledWith({
address: {
Expand Down
Loading