diff --git a/docs/en/developer-guide/wework-cloud-connection.md b/docs/en/developer-guide/wework-cloud-connection.md index 358240e3f90..f3080edcfad 100644 --- a/docs/en/developer-guide/wework-cloud-connection.md +++ b/docs/en/developer-guide/wework-cloud-connection.md @@ -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. diff --git a/docs/zh/developer-guide/wework-cloud-connection.md b/docs/zh/developer-guide/wework-cloud-connection.md index 1ad10022850..099b1ff35fa 100644 --- a/docs/zh/developer-guide/wework-cloud-connection.md +++ b/docs/zh/developer-guide/wework-cloud-connection.md @@ -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 不会覆盖它,而是写入明确的日志告警。 diff --git a/executor/src/runtime_work/handler.rs b/executor/src/runtime_work/handler.rs index 939f5714cc4..5e03dad2bef 100644 --- a/executor/src/runtime_work/handler.rs +++ b/executor/src/runtime_work/handler.rs @@ -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() }) { @@ -1140,6 +1141,7 @@ impl RuntimeWorkRpcHandler { link, messages, None, + local_execution_running, limit, before_cursor.as_deref(), after_cursor.as_deref(), @@ -1168,6 +1170,7 @@ impl RuntimeWorkRpcHandler { runtime, messages: Vec::new(), context_usage: None, + running: false, limit, before_cursor, after_cursor, @@ -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, @@ -1210,6 +1210,7 @@ impl RuntimeWorkRpcHandler { runtime: cached.runtime, messages, context_usage: cached.context_usage, + running, limit, before_cursor, after_cursor, @@ -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; @@ -1273,6 +1276,7 @@ impl RuntimeWorkRpcHandler { runtime: "codex".to_owned(), messages, context_usage, + running, limit, before_cursor, after_cursor, @@ -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( @@ -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 @@ -4634,6 +4640,7 @@ fn cached_transcript_response( link: &RuntimeTaskLink, messages: Vec, context_usage: Option, + running: bool, limit: Option, before_cursor: Option<&str>, after_cursor: Option<&str>, @@ -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), @@ -4657,6 +4665,7 @@ struct TranscriptResponseInput { runtime: String, messages: Vec, context_usage: Option, + running: bool, limit: Option, before_cursor: Option, after_cursor: Option, @@ -4670,6 +4679,7 @@ fn transcript_response(input: TranscriptResponseInput) -> Value { runtime, messages, context_usage, + running, limit, before_cursor, after_cursor, @@ -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), @@ -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, @@ -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!( diff --git a/wework/src/App.plugins.test.tsx b/wework/src/App.plugins.test.tsx index 0b0d30a5ff7..3df782d6f22 100644 --- a/wework/src/App.plugins.test.tsx +++ b/wework/src/App.plugins.test.tsx @@ -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(), diff --git a/wework/src/components/layout/useWorkbenchPaneSession.ts b/wework/src/components/layout/useWorkbenchPaneSession.ts index 88c6afc9917..7fac4798e19 100644 --- a/wework/src/components/layout/useWorkbenchPaneSession.ts +++ b/wework/src/components/layout/useWorkbenchPaneSession.ts @@ -125,6 +125,7 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes getRuntimeGoal, setRuntimeGoal, clearRuntimeGoal, + markRuntimeTaskStarted, sendRuntimePaneMessage, sendRuntimePaneGuidance, compactRuntimePaneTask, @@ -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) { @@ -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) @@ -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. */ diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index fc8fec82448..152dbf65741 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -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(, 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, diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index 931607cc1fc..6f03028c4e0 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -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) @@ -1154,6 +1159,7 @@ export function WorkbenchProvider({ getRuntimeGoal: runtimeTasks.getRuntimeGoal, setRuntimeGoal: runtimeTasks.setRuntimeGoal, clearRuntimeGoal: runtimeTasks.clearRuntimeGoal, + markRuntimeTaskStarted, listImPrivateSessions, bindRuntimeTaskToImSessions, getImNotificationSettings, @@ -1236,6 +1242,7 @@ export function WorkbenchProvider({ getRuntimeGoal: stableGetRuntimeGoal, setRuntimeGoal: stableSetRuntimeGoal, clearRuntimeGoal: stableClearRuntimeGoal, + markRuntimeTaskStarted: stableMarkRuntimeTaskStarted, listImPrivateSessions: stableListImPrivateSessions, bindRuntimeTaskToImSessions: stableBindRuntimeTaskToImSessions, getImNotificationSettings: stableGetImNotificationSettings, @@ -1325,6 +1332,7 @@ export function WorkbenchProvider({ stableLoadEnvironmentInfo, stableLoadRuntimeTranscriptForPane, stableLoadTurnFileChangesDiff, + stableMarkRuntimeTaskStarted, stableOpenRuntimeTask, stableOpenStandaloneWorkspace, stablePauseCurrentResponse, diff --git a/wework/src/features/workbench/runtimePaneStatus.test.ts b/wework/src/features/workbench/runtimePaneStatus.test.ts index e2f30a8c3f7..8b46da84fac 100644 --- a/wework/src/features/workbench/runtimePaneStatus.test.ts +++ b/wework/src/features/workbench/runtimePaneStatus.test.ts @@ -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) + }) }) diff --git a/wework/src/features/workbench/useWorkbenchRuntimeTasks.ts b/wework/src/features/workbench/useWorkbenchRuntimeTasks.ts index f83cc67511d..e171fe5bb7e 100644 --- a/wework/src/features/workbench/useWorkbenchRuntimeTasks.ts +++ b/wework/src/features/workbench/useWorkbenchRuntimeTasks.ts @@ -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 diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts index 8ae3b53fe0d..ef7e5d1dd09 100644 --- a/wework/src/features/workbench/workbenchContextTypes.ts +++ b/wework/src/features/workbench/workbenchContextTypes.ts @@ -184,6 +184,7 @@ export interface WorkbenchContextValue { getRuntimeGoal: (address: RuntimeTaskAddress) => Promise setRuntimeGoal: (request: RuntimeGoalSetRequest) => Promise clearRuntimeGoal: (address: RuntimeTaskAddress) => Promise + markRuntimeTaskStarted: (address: RuntimeTaskAddress) => void listImPrivateSessions: () => Promise bindRuntimeTaskToImSessions: ( address: RuntimeTaskAddress, diff --git a/wework/src/features/workbench/workbenchReducer.ts b/wework/src/features/workbench/workbenchReducer.ts index 9416fce4a0c..d98dc9580f0 100644 --- a/wework/src/features/workbench/workbenchReducer.ts +++ b/wework/src/features/workbench/workbenchReducer.ts @@ -297,7 +297,12 @@ function mergeRuntimeTasks( const merged = currentTasks .map(task => { const nextTask = nextById.get(task.taskId) - if (nextTask) return nextTask + if (nextTask) { + if (task.running && !nextTask.running && !isTerminalRuntimeTaskStatus(nextTask.status)) { + return { ...nextTask, running: true } + } + return nextTask + } if ( isFreshOptimisticRuntimeTask(task) && !resolvedTaskKeys.has(runtimeTaskKey(deviceId, task)) && @@ -317,6 +322,21 @@ function mergeRuntimeTasks( return merged } +function isTerminalRuntimeTaskStatus(status: string | null | undefined): boolean { + const normalized = status?.replaceAll(/[_-]/g, '').toLowerCase() + return ( + normalized === 'done' || + normalized === 'complete' || + normalized === 'completed' || + normalized === 'failed' || + normalized === 'error' || + normalized === 'cancelled' || + normalized === 'canceled' || + normalized === 'archived' || + normalized === 'deleted' + ) +} + function isOptimisticRuntimeTask(task: RuntimeTaskSummary): boolean { return task.status === 'creating' || (task.optimistic === true && task.status === 'failed') } diff --git a/wework/src/types/api.ts b/wework/src/types/api.ts index 2a6248aec49..f86d3da8a4b 100644 --- a/wework/src/types/api.ts +++ b/wework/src/types/api.ts @@ -525,6 +525,7 @@ export interface RuntimeTranscriptResponse { taskId?: string workspacePath: string runtime: RuntimeName + running?: boolean title?: string | null messages: NormalizedRuntimeMessage[] contextUsage?: RuntimeContextUsage | null diff --git a/wework/src/types/workbench.ts b/wework/src/types/workbench.ts index cab79eac996..af6cbd64927 100644 --- a/wework/src/types/workbench.ts +++ b/wework/src/types/workbench.ts @@ -102,6 +102,7 @@ export interface GuidanceWorkbenchMessage { export interface RuntimePaneTranscript { messages: WorkbenchMessage[] + running?: boolean contextUsage?: RuntimeContextUsage | null turnNavigation?: RuntimeTurnNavigationItem[] fullContent?: boolean