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/wework-cloud-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Packaged release builds of Wework must keep one active app paired with one local

Debug builds do not enable this single-instance or cleanup policy. Local development may run multiple Wework debug instances at the same time, each with its own `app-runtime/wework-.../app-ipc.sock` socket. Release cleanup must also inspect each candidate executor process environment and terminate only executors using the release fixed socket, so it does not kill executors owned by debug instances.

Closing to the tray destroys only the current WebView; the Wework process, executor, and Codex app-server keep running. After the window is recreated, the `running` field returned by `runtime.tasks.transcript` restores task execution state. That field is authoritative only when backed by an in-memory executor task or the Codex app-server's live thread status; it must not be inferred from stale `streaming` messages in transcript history. After a normal or abnormal full app exit, the new executor has no activity state from the previous process, so old messages cannot mark an interrupted task as running again.

## Local CLI Entry

On macOS, the Wework desktop app installs a user-level `wework` launcher at `~/.local/bin/wework` during startup. Wework generates and owns this file instead of symlinking it to build output or app resources, so debug target cleanup, release app updates, and bundle path changes do not leave a broken command. If that path already exists and is not a Wework-managed launcher, Wework leaves it untouched and writes an explicit warning to the app log.
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/developer-guide/wework-cloud-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Wework 云端 runtime 执行使用和本地模式一致的 app IPC 协议。前

debug 构建不启用这个单实例和清理策略。开发时可以同时启动多个 Wework debug 实例;每个实例使用 `app-runtime/wework-.../app-ipc.sock` 形式的独立 socket。release 清理旧 executor 时也必须检查进程环境中的 socket 值,只能终止使用 release 固定 socket 的 executor,不能误杀 debug 实例对应的 executor。

关闭到托盘只销毁当前 WebView,Wework 主进程、executor 和 Codex app-server 继续运行。窗口重建后,`runtime.tasks.transcript` 返回的 `running` 字段用于恢复任务运行态;该字段只以 executor 进程内的活动任务或 Codex app-server 的实时线程状态为依据,不能从历史 transcript 中残留的 `streaming` 消息推断。完整退出或异常退出后,新 executor 不保留旧进程的活动状态,因此旧消息不会把已经中断的任务重新标记为运行中。

## 本机 CLI 入口

macOS 桌面版 Wework 启动时会安装用户级 `wework` launcher 到 `~/.local/bin/wework`。该文件由 Wework 生成和维护,不是指向构建产物或 app resource 的符号链接,因此 debug target 清理、release app 更新或 bundle 路径变化后不会形成断链。若该路径已经存在且不是 Wework 管理的 launcher,Wework 不会覆盖它,而是写入明确的日志告警。
Expand Down
50 changes: 43 additions & 7 deletions executor/src/runtime_work/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,7 @@ impl RuntimeWorkRpcHandler {
.and_then(runtime_session_id_from_link)
.or_else(|| runtime_session_id_from_payload(&payload));
let running_hint = local_link.as_ref().is_some_and(|link| link.running);
let local_execution_running = self.is_active_local_task(&local_task_id);
if let Some(link) = local_link.as_ref().filter(|link| {
!runtime_has_provider_transcript_reader(&link.runtime) || session_id.is_none()
}) {
Expand All @@ -1140,6 +1141,7 @@ impl RuntimeWorkRpcHandler {
link,
messages,
None,
local_execution_running,
limit,
before_cursor.as_deref(),
after_cursor.as_deref(),
Expand Down Expand Up @@ -1168,6 +1170,7 @@ impl RuntimeWorkRpcHandler {
runtime,
messages: Vec::new(),
context_usage: None,
running: false,
limit,
before_cursor,
after_cursor,
Expand All @@ -1186,11 +1189,8 @@ impl RuntimeWorkRpcHandler {
)
})
.unwrap_or_else(|| cached.messages.clone());
let running = transcript_running(
local_link.as_ref(),
running_hint || cached.running,
&messages,
);
let running = !local_link.as_ref().is_some_and(local_task_finished)
&& (local_execution_running || cached.running);
log_runtime_transcript_finished(RuntimeTranscriptLog {
started_at,
local_task_id: &local_task_id,
Expand All @@ -1210,6 +1210,7 @@ impl RuntimeWorkRpcHandler {
runtime: cached.runtime,
messages,
context_usage: cached.context_usage,
running,
limit,
before_cursor,
after_cursor,
Expand Down Expand Up @@ -1243,13 +1244,15 @@ impl RuntimeWorkRpcHandler {

if refresh && !include_full_content {
if let Some(cached) = self.transcript_cache.peek(&thread_id) {
if let Some(updated) = self.incremental_cached_transcript(
if let Some(mut updated) = self.incremental_cached_transcript(
cached,
&thread,
local_link.as_ref(),
running_hint,
&workspace_path,
) {
updated.running = !local_link.as_ref().is_some_and(local_task_finished)
&& (local_execution_running || codex_thread_is_running(&thread));
let messages = updated.messages.clone();
let context_usage = updated.context_usage.clone();
let running = updated.running;
Expand All @@ -1273,6 +1276,7 @@ impl RuntimeWorkRpcHandler {
runtime: "codex".to_owned(),
messages,
context_usage,
running,
limit,
before_cursor,
after_cursor,
Expand All @@ -1298,7 +1302,8 @@ impl RuntimeWorkRpcHandler {
)
})
.unwrap_or(transcript_messages);
let running = transcript_running(local_link.as_ref(), running_hint, &messages);
let running = !local_link.as_ref().is_some_and(local_task_finished)
&& (local_execution_running || codex_thread_is_running(&thread));
let message_count = messages.len();
if !include_full_content {
self.transcript_cache.insert(
Expand Down Expand Up @@ -1334,6 +1339,7 @@ impl RuntimeWorkRpcHandler {
runtime: "codex".to_owned(),
messages,
context_usage,
running,
limit: if include_full_content { None } else { limit },
before_cursor: if include_full_content {
None
Expand Down Expand Up @@ -4634,6 +4640,7 @@ fn cached_transcript_response(
link: &RuntimeTaskLink,
messages: Vec<Value>,
context_usage: Option<Value>,
running: bool,
limit: Option<usize>,
before_cursor: Option<&str>,
after_cursor: Option<&str>,
Expand All @@ -4644,6 +4651,7 @@ fn cached_transcript_response(
runtime: link.runtime.clone(),
messages,
context_usage,
running,
limit,
before_cursor: before_cursor.map(ToOwned::to_owned),
after_cursor: after_cursor.map(ToOwned::to_owned),
Expand All @@ -4657,6 +4665,7 @@ struct TranscriptResponseInput {
runtime: String,
messages: Vec<Value>,
context_usage: Option<Value>,
running: bool,
limit: Option<usize>,
before_cursor: Option<String>,
after_cursor: Option<String>,
Expand All @@ -4670,6 +4679,7 @@ fn transcript_response(input: TranscriptResponseInput) -> Value {
runtime,
messages,
context_usage,
running,
limit,
before_cursor,
after_cursor,
Expand All @@ -4687,6 +4697,7 @@ fn transcript_response(input: TranscriptResponseInput) -> Value {
"taskId": local_task_id,
"workspacePath": workspace_path,
"runtime": runtime,
"running": running,
"messages": page.messages,
"fullContent": full_content,
"contextUsage": context_usage.unwrap_or(Value::Null),
Expand Down Expand Up @@ -4805,6 +4816,20 @@ fn runtime_message_running(message: &Value) -> bool {
.unwrap_or(false)
}

fn codex_thread_is_running(thread: &Value) -> bool {
let Some(status) = thread.get("status") else {
return false;
};
status
.as_str()
.is_some_and(|value| value.eq_ignore_ascii_case("active"))
|| status
.as_object()
.and_then(|value| value.get("type"))
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("active"))
}

fn transcript_running(
local_link: Option<&RuntimeTaskLink>,
running_hint: bool,
Expand Down Expand Up @@ -6506,6 +6531,17 @@ mod tests {
assert!(!transcript_running(Some(&link), true, &messages));
}

#[test]
fn codex_thread_running_state_requires_an_active_app_server_thread() {
assert!(codex_thread_is_running(&json!({"status": "active"})));
assert!(codex_thread_is_running(
&json!({"status": {"type": "active", "activeFlags": []}})
));
assert!(!codex_thread_is_running(&json!({"status": "idle"})));
assert!(!codex_thread_is_running(&json!({"status": "notLoaded"})));
assert!(!codex_thread_is_running(&json!({})));
}

#[test]
fn incremental_transcript_rejects_changed_rollout_path() {
let old_path = std::env::temp_dir().join(format!(
Expand Down
1 change: 1 addition & 0 deletions wework/src/App.plugins.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ const workbenchValue: WorkbenchContextValue = {
archiveProjectsConversations: vi.fn(),
archiveChatConversations: vi.fn(),
forkCurrentRuntimeTask: vi.fn(),
markRuntimeTaskStarted: vi.fn(),
listImPrivateSessions: vi.fn(),
bindRuntimeTaskToImSessions: vi.fn(),
getImNotificationSettings: vi.fn(),
Expand Down
7 changes: 6 additions & 1 deletion wework/src/components/layout/useWorkbenchPaneSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes
getRuntimeGoal,
setRuntimeGoal,
clearRuntimeGoal,
markRuntimeTaskStarted,
sendRuntimePaneMessage,
sendRuntimePaneGuidance,
compactRuntimePaneTask,
Expand Down Expand Up @@ -297,6 +298,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes
[currentRuntimeTask, messages, sendPhase, taskExecution]
)
const activeAssistantMessage = paneStatus.activeAssistantMessage

const goal = useMemo(() => {
let resolvedGoal: RuntimeGoal | null
if (!currentRuntimeTaskLoadTarget) {
Expand Down Expand Up @@ -489,6 +491,9 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes
.current(address, { limit: RUNTIME_TRANSCRIPT_PAGE_SIZE })
.then(transcript => {
if (!cancelled) {
if (transcript.running) {
markRuntimeTaskStarted(address)
}
const nextMessages = transcript.messages.length > 0 ? transcript.messages : seededMessages
loadedRuntimeTranscriptKeyRef.current = loadKey
setTranscriptFullContent(transcript.fullContent === true)
Expand Down Expand Up @@ -538,7 +543,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes
return () => {
cancelled = true
}
}, [dispatchMessages, runtimeTaskLoadTarget])
}, [dispatchMessages, markRuntimeTaskStarted, runtimeTaskLoadTarget])
/* eslint-enable react-hooks/set-state-in-effect */

/* eslint-disable react-hooks/set-state-in-effect -- Queued runtime messages are advanced when the active runtime response becomes idle. */
Expand Down
74 changes: 74 additions & 0 deletions wework/src/features/workbench/WorkbenchProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5800,6 +5800,80 @@ describe('WorkbenchProvider runtime tasks', () => {
expect(screen.getByTestId('runtime-goal-status')).toHaveTextContent('active')
})

test('restores a goal task as running when reopened with a streaming transcript', async () => {
const runtimeWorkApi = createRuntimeWorkApiMock({
listRuntimeWork: vi.fn().mockResolvedValue(
createRuntimeWork({
projects: [
{
project: { id: 7, name: 'Wegent' },
deviceWorkspaces: [
{
id: 22,
projectId: 7,
deviceId: 'device-1',
deviceName: 'Project Device',
deviceStatus: 'online',
workspacePath: '/workspace/project-alpha',
mapped: true,
available: true,
tasks: [
{
taskId: 'runtime-a',
workspacePath: '/workspace/project-alpha',
title: 'Runtime A',
runtime: 'codex',
running: false,
status: 'active',
},
],
},
],
},
],
totalTasks: 1,
})
),
getRuntimeTranscript: vi.fn().mockResolvedValue({
taskId: 'runtime-a',
workspacePath: '/workspace/project-alpha',
runtime: 'codex',
running: true,
messages: [
{ id: 'runtime-a:user:1', role: 'user', content: '继续实现目标' },
{
id: 'runtime-a:assistant:1',
role: 'assistant',
content: '正在输出',
status: 'streaming',
subtaskId: '101',
},
],
}),
getRuntimeGoal: vi.fn().mockResolvedValue({
accepted: true,
goal: createRuntimeGoal({ status: 'active' }),
}),
})
const services = createWorkbenchServices({
runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'],
})

renderWorkbench(<RuntimeOpenProbe />, services)

await userEvent.click(await screen.findByText('open runtime a'))

await waitFor(() =>
expect(screen.getByTestId('runtime-message-statuses')).toHaveTextContent(
'assistant:streaming'
)
)
await waitFor(() =>
expect(screen.getByTestId('current-runtime-task-running')).toHaveTextContent('running')
)
expect(screen.getByTestId('runtime-goal-status')).toHaveTextContent('active')
})

test('resumes a paused runtime goal when editing and sending its objective', async () => {
const setRuntimeGoal = vi.fn().mockResolvedValue({
accepted: true,
Expand Down
8 changes: 8 additions & 0 deletions wework/src/features/workbench/WorkbenchProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,12 @@ export function WorkbenchProvider({
(error: string | null) => dispatch({ type: 'error_set', error }),
[dispatch]
)
const markRuntimeTaskStarted = useCallback(
(address: RuntimeTaskAddress) => dispatch({ type: 'runtime_task_started', address }),
[dispatch]
)
const stableSetWorkbenchError = useStableEvent(setWorkbenchError)
const stableMarkRuntimeTaskStarted = useStableEvent(markRuntimeTaskStarted)
const stableSetProjectWorktreeBranch = useStableEvent(setProjectWorktreeBranch)
const stableSelectProjectWorkspace = useStableEvent(selectProjectWorkspace)
const stableSelectStandaloneDevice = useStableEvent(selectStandaloneDevice)
Expand Down Expand Up @@ -1154,6 +1159,7 @@ export function WorkbenchProvider({
getRuntimeGoal: runtimeTasks.getRuntimeGoal,
setRuntimeGoal: runtimeTasks.setRuntimeGoal,
clearRuntimeGoal: runtimeTasks.clearRuntimeGoal,
markRuntimeTaskStarted,
listImPrivateSessions,
bindRuntimeTaskToImSessions,
getImNotificationSettings,
Expand Down Expand Up @@ -1236,6 +1242,7 @@ export function WorkbenchProvider({
getRuntimeGoal: stableGetRuntimeGoal,
setRuntimeGoal: stableSetRuntimeGoal,
clearRuntimeGoal: stableClearRuntimeGoal,
markRuntimeTaskStarted: stableMarkRuntimeTaskStarted,
listImPrivateSessions: stableListImPrivateSessions,
bindRuntimeTaskToImSessions: stableBindRuntimeTaskToImSessions,
getImNotificationSettings: stableGetImNotificationSettings,
Expand Down Expand Up @@ -1325,6 +1332,7 @@ export function WorkbenchProvider({
stableLoadEnvironmentInfo,
stableLoadRuntimeTranscriptForPane,
stableLoadTurnFileChangesDiff,
stableMarkRuntimeTaskStarted,
stableOpenRuntimeTask,
stableOpenStandaloneWorkspace,
stablePauseCurrentResponse,
Expand Down
16 changes: 16 additions & 0 deletions wework/src/features/workbench/runtimePaneStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,20 @@ describe('runtime pane status', () => {
expect(status.isBusy).toBe(false)
expect(status.canSendQueuedMessage).toBe(true)
})

test('does not let a stale streaming transcript revive an idle non-terminal task', () => {
const taskExecution = getRuntimePaneTaskExecution(runtimeWork(false, 'active'), runtimeAddress)
const status = deriveRuntimePaneStatus({
messages: [assistantMessage('streaming')],
sendPhase: 'idle',
currentRuntimeTask: runtimeAddress,
taskExecution,
})

expect(status.taskExecution).toEqual({ known: true, running: false, status: 'active' })
expect(status.activeAssistantMessage).toBeNull()
expect(status.isAssistantStreaming).toBe(false)
expect(status.isBusy).toBe(false)
expect(status.canSendQueuedMessage).toBe(true)
})
})
1 change: 1 addition & 0 deletions wework/src/features/workbench/useWorkbenchRuntimeTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export function useWorkbenchRuntimeTasks({
messages: runtimeMessagesToWorkbenchMessages(
Array.isArray(transcript.messages) ? transcript.messages : []
),
running: transcript.running === true,
contextUsage: transcript.contextUsage ?? null,
turnNavigation: Array.isArray(transcript.turnNavigation)
? transcript.turnNavigation
Expand Down
1 change: 1 addition & 0 deletions wework/src/features/workbench/workbenchContextTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export interface WorkbenchContextValue {
getRuntimeGoal: (address: RuntimeTaskAddress) => Promise<RuntimeGoalGetResponse>
setRuntimeGoal: (request: RuntimeGoalSetRequest) => Promise<RuntimeGoalSetResponse>
clearRuntimeGoal: (address: RuntimeTaskAddress) => Promise<RuntimeGoalClearResponse>
markRuntimeTaskStarted: (address: RuntimeTaskAddress) => void
listImPrivateSessions: () => Promise<IMPrivateSessionListResponse>
bindRuntimeTaskToImSessions: (
address: RuntimeTaskAddress,
Expand Down
Loading
Loading