diff --git a/docs/en/developer-guide/runtime-local-work.md b/docs/en/developer-guide/runtime-local-work.md index a6c6292193..adf938adc5 100644 --- a/docs/en/developer-guide/runtime-local-work.md +++ b/docs/en/developer-guide/runtime-local-work.md @@ -113,6 +113,8 @@ Backend forwards `runtime.tasks.send`. The executor resumes the runtime session Before `turn/start`, the executor starts a watchdog for the first meaningful progress event so a Codex turn that stalls during startup, such as MCP initialization, cannot leave Wework showing "Thinking" forever. The default timeout is 180 seconds and can be adjusted with `WEGENT_CODEX_TURN_STARTUP_TIMEOUT_SECONDS`. A user-input echo, an error marked for retry, or a subagent event does not count as progress. The watchdog is disabled as soon as the first assistant, reasoning, or tool item arrives, so a long-running tool that has already started is not terminated. When startup times out, the executor stops the stalled shared app-server, completes the current turn with an explicit error, and starts a new process when the user retries or sends another message. Wework must preserve the original user message and failure card, and retry the exact input associated with the failed turn instead of leaving a blank assistant message or sending an older prompt. +To diagnose a reply whose text is complete while the sidebar and composer still show a running state, correlate packaged-app logs by the same `deviceId + taskId + subtaskId`. Tauri first records receiving and forwarding `response.completed`, `response.failed`, or `response.incomplete`; the local chat stream then records how many subscriptions matched the terminal event; the pane layer records whether it accepted the terminal event or dropped it because the task or device did not match; finally, Workbench Provider records dispatch of `runtime_task_settled`. These entries contain only runtime identities, event types, and block counts, never response content or credentials. The first missing entry identifies the boundary before which terminal-state propagation stopped. + Every continuation request must carry the current model selection. The Wework model selector is the source of truth for the turn being sent: whichever model the user selects for that turn becomes the `modelId`, `modelType`, and model options in `runtime.tasks.send`. The executor must not restore a model from a previous request and must not cache model selection. If a request has neither a full `executionRequest` nor a `modelId`, the executor must return `bad_request` instead of falling back to a default model. In packaged local app mode, `createLocalAppServices()` is the single boundary that normalizes local Codex model names: the UI may display `codex-gpt-5.5`, but the value sent to Codex app-server must be the real model id `gpt-5.5`. Task creation and continuation must reuse that same normalization path. If the current LocalTask is still replying, Wework queues new user input locally instead of sending concurrent `runtime.tasks.send` calls. Users can remove queued messages, or choose to stop the current reply and send the queued message from the queue panel. That first calls: diff --git a/docs/zh/developer-guide/runtime-local-work.md b/docs/zh/developer-guide/runtime-local-work.md index 497f6a790a..ad6f17a7b1 100644 --- a/docs/zh/developer-guide/runtime-local-work.md +++ b/docs/zh/developer-guide/runtime-local-work.md @@ -113,6 +113,8 @@ Backend 转发 `runtime.tasks.send`。executor 根据本地 LocalTask 的 opaque executor 在 `turn/start` 前启动首个有效进展的看门狗,避免 Codex 卡在 MCP 初始化等启动阶段时让 Wework 永久显示“正在思考”。默认超时为 180 秒,可以通过 `WEGENT_CODEX_TURN_STARTUP_TIMEOUT_SECONDS` 调整。用户输入回显、声明仍会重试的错误和子 agent 事件不算有效进展;首个 assistant、reasoning 或 tool item 到达后立即关闭这个启动看门狗,因此已经开始执行的长工具调用不会被误杀。启动超时后,executor 会结束卡住的共享 app-server、让当前 turn 以明确错误结束,并在用户重试或发送下一条消息时启动新进程。Wework 必须保留原用户消息和失败卡片,重试时发送与失败 turn 绑定的同一条用户输入,不能留下空白 assistant 消息或误发更早的消息。 +排查“回复文本已经完整,但侧栏和 composer 仍显示运行中”时,应按同一组 `deviceId + taskId + subtaskId` 串联正式版日志。Tauri 先记录收到并转发 `response.completed`、`response.failed` 或 `response.incomplete`;本地 chat stream 随后记录终止事件命中的订阅数量;pane 层记录终止事件是被接受,还是因为 task/device 不匹配而被丢弃;最后 Workbench Provider 记录 `runtime_task_settled` 已分发。日志只记录运行时身份、事件类型和 block 数量,不记录回复正文或凭据。缺少哪一段日志,就表示终止状态停在对应边界之前。 + 每一次继续 LocalTask 的请求都必须携带当前模型选择。Wework 的模型选择器是本轮发送的事实来源:用户本轮选择哪个模型,`runtime.tasks.send` 就传哪个 `modelId`、`modelType` 和模型选项。executor 不从上一次请求恢复模型,也不缓存模型选择;如果请求没有完整 `executionRequest` 且没有 `modelId`,executor 必须返回 `bad_request`,而不是回退到默认模型。打包本地 app 的 `createLocalAppServices()` 是本机 Codex 模型名规范化的唯一边界:UI 可以展示 `codex-gpt-5.5`,但发送到 Codex app-server 前必须统一转换成真实模型 id `gpt-5.5`。新建任务和继续任务都必须复用同一套规范化逻辑。 如果当前 LocalTask 仍在回复,Wework 会把新的用户输入放入本地队列,而不是并发调用 `runtime.tasks.send`。用户可以取消队列中的消息;也可以在队列面板中选择“暂停当前回复并发送”,这会先调用: diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index 6312b4f766..d83d5d26cc 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -1103,6 +1103,11 @@ export function WorkbenchProvider({ handlers.onContextUsageUpdated?.(contextUsage) }, onAssistantSettled: () => { + console.info('[Wework] Runtime task settlement dispatched', { + deviceId: address.deviceId, + taskId: address.taskId, + workspacePath: address.workspacePath ?? null, + }) dispatch({ type: 'runtime_task_settled', address }) handlers.onAssistantSettled?.() }, diff --git a/wework/src/features/workbench/runtimePaneMessages.test.ts b/wework/src/features/workbench/runtimePaneMessages.test.ts index d2a5f2ea2d..f5370665f4 100644 --- a/wework/src/features/workbench/runtimePaneMessages.test.ts +++ b/wework/src/features/workbench/runtimePaneMessages.test.ts @@ -345,6 +345,7 @@ describe('createRuntimeTaskStreamHandlers', () => { }) test('strips Codex UI directives from completed assistant content', () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined) const address: RuntimeTaskAddress = { deviceId: 'device-1', taskId: 'runtime-task-1', @@ -374,6 +375,40 @@ describe('createRuntimeTaskStreamHandlers', () => { subtaskId: 'subtask-9', content: '当前分支比 origin/main ahead 1,可以直接 push。', }) + expect(info).toHaveBeenCalledWith( + '[Wework] Runtime terminal event accepted', + expect.objectContaining({ + event: 'chat:done', + payloadTaskId: 'runtime-task-1', + payloadSubtaskId: 'subtask-9', + }) + ) + }) + + test('warns when a terminal event does not match the subscribed runtime task', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const actions: RuntimePaneMessageAction[] = [] + const handlers = createRuntimeTaskStreamHandlers( + { deviceId: 'device-1', taskId: 'runtime-task-1' }, + { onMessageAction: action => actions.push(action) } + ) + + handlers.onChatDone?.({ + taskId: 'runtime-task-2', + subtaskId: 'subtask-9', + deviceId: 'device-1', + result: { value: 'complete' }, + }) + + expect(actions).toHaveLength(0) + expect(warn).toHaveBeenCalledWith( + '[Wework] Dropped mismatched runtime terminal event', + expect.objectContaining({ + event: 'chat:done', + payloadTaskId: 'runtime-task-2', + payloadSubtaskId: 'subtask-9', + }) + ) }) test('settles runtime streams without forwarding empty final content', () => { diff --git a/wework/src/features/workbench/runtimePaneMessages.ts b/wework/src/features/workbench/runtimePaneMessages.ts index 780f17a107..4d20814fe7 100644 --- a/wework/src/features/workbench/runtimePaneMessages.ts +++ b/wework/src/features/workbench/runtimePaneMessages.ts @@ -114,7 +114,10 @@ export function createRuntimeTaskStreamHandlers( }) }, onChatDone: payload => { - if (!isRuntimeTaskStreamPayload(address, payload)) return + if (!isRuntimeTaskStreamPayload(address, payload)) { + warnAndDropMismatchedRuntimeTerminalEvent('chat:done', address, payload) + return + } const identity = runtimeStreamTaskSubtaskIdentity(payload) if (!identity) { warnAndDropRuntimeStreamEvent('chat:done', address, payload) @@ -130,6 +133,10 @@ export function createRuntimeTaskStreamHandlers( hasFileChanges: Boolean(fileChanges), blockCount: blocks?.length ?? 0, }) + logAcceptedRuntimeTerminalEvent('chat:done', address, payload, { + hasFileChanges: Boolean(fileChanges), + blockCount: blocks?.length ?? 0, + }) handlers.onAssistantSettled?.() if (payload.result.contextUsage) { handlers.onContextUsageUpdated?.(payload.result.contextUsage) @@ -144,7 +151,10 @@ export function createRuntimeTaskStreamHandlers( handlers.onRefreshWorkLists?.() }, onChatError: payload => { - if (!isRuntimeTaskStreamPayload(address, payload)) return + if (!isRuntimeTaskStreamPayload(address, payload)) { + warnAndDropMismatchedRuntimeTerminalEvent('chat:error', address, payload) + return + } const identity = runtimeStreamTaskSubtaskIdentity(payload) if (!identity) { warnAndDropRuntimeStreamEvent('chat:error', address, payload, { @@ -156,6 +166,9 @@ export function createRuntimeTaskStreamHandlers( error: payload.error, errorType: payload.type, }) + logAcceptedRuntimeTerminalEvent('chat:error', address, payload, { + errorType: payload.type ?? null, + }) handlers.onAssistantSettled?.() if (isCancelledRuntimeError(payload)) { handlers.onMessageAction({ @@ -517,6 +530,36 @@ function warnAndDropRuntimeStreamEvent( }) } +function warnAndDropMismatchedRuntimeTerminalEvent( + event: 'chat:done' | 'chat:error', + address: RuntimeTaskAddress, + payload: { taskId?: string; deviceId?: string; subtaskId?: string } +): void { + console.warn('[Wework] Dropped mismatched runtime terminal event', { + event, + currentRuntimeTask: runtimeAddressDebug(address), + payloadTaskId: payload.taskId ?? null, + payloadDeviceId: payload.deviceId ?? null, + payloadSubtaskId: payload.subtaskId ?? null, + }) +} + +function logAcceptedRuntimeTerminalEvent( + event: 'chat:done' | 'chat:error', + address: RuntimeTaskAddress, + payload: { taskId?: string; deviceId?: string; subtaskId?: string }, + details: Record +): void { + console.info('[Wework] Runtime terminal event accepted', { + event, + currentRuntimeTask: runtimeAddressDebug(address), + payloadTaskId: payload.taskId ?? null, + payloadDeviceId: payload.deviceId ?? null, + payloadSubtaskId: payload.subtaskId ?? null, + ...details, + }) +} + function warnAndDropEmptyRuntimeChunk( address: RuntimeTaskAddress, payload: ChatChunkPayload,