diff --git a/docs/verification/issue-368-waiting-continuation.md b/docs/verification/issue-368-waiting-continuation.md new file mode 100644 index 00000000..bd4f9c9b --- /dev/null +++ b/docs/verification/issue-368-waiting-continuation.md @@ -0,0 +1,30 @@ +# #368 等待中的会话可显式接续 + +## 目的 + +修复当前协调会话处于 waiting/paused 时,“准备接续”被禁用的问题。接续只迁移上下文,保留原 Task、Execution、owner、等待或暂停及其原因,不恢复业务处理。 + +## 根因与范围 + +共享 `ExecutionContinuation` 将 waiting/paused 一律视为不可接续,并提示先恢复处理。这与现有 `task.continue` 及受控维护协议不一致。主协调已在正式页面确认按钮禁用及该提示;当时相关执行已结算,上一代接续已收到材料。 + +移除状态本身造成的按钮禁用,改为说明只切换会话、保留当前等待或暂停。“恢复处理”仍为独立操作。控制尚未 applied、原执行失败或创建中、任务完成、缺少实际执行资料和非当前协调的既有边界保留;明确结束的队列继续禁用。没有修改自动唤醒、运行账本、作业结果、宿主或原业务资源。 + +历史完成通知在旧日志中排在多条自动输入之后,宿主只 claim 首项并结束 blocked。当前主干已有作业通知合并准入回归,准确结果唤醒的六项现有回归通过,本次不重复修改已修复逻辑。历史通知已经取消不能等同于当前仍可投递,结果真实可达性须由宿主正式观察确认。 + +## 步骤与记录 + +基线 `origin/main`:`00597bf`;独立分支 `fix/184-session6-continuation`。 + +1. 在生产组件测试中覆盖原生会话页和 Issue 详情页,分别准备 waiting/paused 的准确当前执行,要求按钮可用。修复前四项均因 `disabled=true` 失败,见 `client-red.log`。 +2. 修改共享入口后点击“准备接续”,要求只提交带 ref/ExecutionId/owner/epoch 的 `task.continue`,不提交业务 resume,返回的原 state/control 保持。 +3. 检查未生效 wait/pause/end 及已生效 end 的禁用;保留完成、历史关联、缺少 actual、失败和创建中回归。 +4. 复用真实宿主 waiting/paused 维护交接、结果后材料刷新和准确作业完成通知回归,再运行相关完整文件、类型检查、构建和 diff 检查。 + +本地原始日志保存在忽略目录 `docs/artifacts/dsh-368-2026-11-08/`。自动化模型由确定性 fixture 控制;真实 AgentLoop、Inbox、持久化、工具和 job 生命周期沿用既有集成夹具。开发页面、发布与原会话接任由主协调负责,测试和构建本身不能替代页面验收。 + +## 自动化结果 + +四个相关完整文件共 131 项全部通过:`execution-continuation-render`、`execution-components`、`work-continuation`、`work-execution-guard`。`pnpm typecheck`、`pnpm build`、`git diff --check` 均退出 0。未运行全套测试。 + +正式发布分支 `release/183-message-delivery-input` 在已部署的 `c17fdbb` 与主干受影响组件相同,本补丁可独立集成;不能以主干包覆盖该分支尚未合入的宿主和接续修复。 diff --git a/src/client/execution-components.tsx b/src/client/execution-components.tsx index 436e7839..e34ec200 100644 --- a/src/client/execution-components.tsx +++ b/src/client/execution-components.tsx @@ -134,15 +134,17 @@ export function ExecutionContinuation({ graph, execution, policy, busy = false, const failed = execution?.state === 'failed' const suspended = execution?.state === 'waiting' || execution?.state === 'paused' const pendingControl = execution?.control?.status === 'accepted' && execution.control.action !== 'resume' - const blocked = failed || suspended || pendingControl || execution?.state === 'accepted' || execution?.state === 'creating' + const ended = execution?.control?.action === 'end' + const blocked = failed || ended || pendingControl || execution?.state === 'accepted' || execution?.state === 'creating' return

接续

{(onContinue || completed) && } {!completed && failed && onRecover && } - {!completed && suspended && !pendingControl && onResume && } + {!completed && suspended && !ended && !pendingControl && onResume && }
{!completed && failed &&

原执行恢复失败:{execution?.error ? `${execution.error.code}:${execution.error.message}` : '原因未知'}。请先重试原执行,恢复成功后再准备接续。

} - {!completed && suspended &&

原执行{execution?.state === 'paused' ? '已暂停' : '正在等待回复'};请先恢复处理,再准备接续。

} + {!completed && suspended && !ended &&

仅切换会话,保留当前等待或暂停;恢复处理需单独操作。

} + {!completed && ended &&

本项队列处理已结束,不能继续接续。

} {!completed && pendingControl && !failed &&

原执行的等待或暂停请求尚未生效,请等待结算后刷新观察。

} {!completed && (execution?.state === 'accepted' || execution?.state === 'creating') &&

原执行尚未就绪,请等待创建或恢复后刷新观察。

} {completed &&

任务阶段:done(已验收完成) · 任务已验收完成,不能继续接续

} diff --git a/tests/client/execution-continuation-render.spec.ts b/tests/client/execution-continuation-render.spec.ts index 387c44dd..e1ff7547 100644 --- a/tests/client/execution-continuation-render.spec.ts +++ b/tests/client/execution-continuation-render.spec.ts @@ -84,7 +84,7 @@ it.each(['idle', 'inactive'] as const)('keeps an unfinished current Task availab expect(view.text()).not.toContain('已验收完成') }) -it.each(['failed', 'waiting', 'paused', 'accepted', 'creating'] as const)('explains why %s execution cannot prepare continuation', async state => { +it.each(['failed', 'accepted', 'creating'] as const)('explains why %s execution cannot prepare continuation', async state => { const detail = taskDetail() detail.execution!.state = state if (state === 'failed') detail.execution!.error = { code: 'incompatible-session', message: 'Existing Session model differs' } @@ -93,9 +93,6 @@ it.each(['failed', 'waiting', 'paused', 'accepted', 'creating'] as const)('expla if (state === 'failed') { expect(view.text()).toContain('原执行恢复失败:incompatible-session') expect(view.nodes('button').some(node => node.props.children === '重试原执行')).toBe(true) - } else if (state === 'waiting' || state === 'paused') { - expect(view.text()).toContain('请先恢复处理,再准备接续') - expect(view.nodes('button').some(node => node.props.children === '恢复处理')).toBe(true) } else expect(view.text()).toContain('原执行尚未就绪') expect(controller.client.command).not.toHaveBeenCalled() }) @@ -144,3 +141,53 @@ it.each(['task.completion', 'progress.completed'])('Issue continuation shows the expect(view.text()).toContain('任务已验收完成,不能继续接续') expect(controller.client.command).not.toHaveBeenCalled() }) + + +it.each([ + { state: 'waiting', surface: 'native' }, { state: 'paused', surface: 'native' }, + { state: 'waiting', surface: 'issue' }, { state: 'paused', surface: 'issue' }, +] as const)('prepares maintenance continuation from $state in the $surface view without resuming business', async ({ state, surface }) => { + const detail = taskDetail() + detail.execution!.state = state + const control = { commandId: 'hold', action: state === 'waiting' ? 'wait' as const : 'pause' as const, status: 'applied' as const, reason: 'Keep the original condition' } + detail.execution!.control = control + let view: ReturnType, controller: WorkPanelController + if (surface === 'native') ({ view, controller } = await nativeView(detail)) + else { + controller = controllerFor(detail) + await controller.openTask(ref) + controller.setDetailTab('continuation') + view = mountProductionComponent({ controller }, 'WorkPanel', undefined, { document: { addEventListener() {}, removeEventListener() {} } }) + mounted.push(view) + } + expect(prepareButton(view)?.props.disabled).toBe(false) + expect(view.text()).toContain('仅切换会话,保留当前等待或暂停') + const command = vi.fn(async (_value: unknown) => detail) + controller.client.command = command + view.click('准备接续') + await expect.poll(() => command.mock.calls.length).toBe(1) + expect(command.mock.calls[0]![0]).toMatchObject({ type: 'task.continue', ref, executionId: 'e', owner: 'owner', epoch: 1 }) + expect(detail.execution).toMatchObject({ state, control }) +}) + +it.each(['wait', 'pause', 'end'] as const)('keeps continuation disabled while %s control is still accepted', async action => { + const detail = taskDetail() + detail.execution!.state = action === 'wait' ? 'waiting' : 'paused' + detail.execution!.control = { commandId: 'pending', action, status: 'accepted', reason: 'Not settled yet' } + const { view, controller } = await nativeView(detail) + expect(prepareButton(view)?.props.disabled).toBe(true) + expect(view.text()).toContain('请求尚未生效') + expect(controller.client.command).not.toHaveBeenCalled() +}) + + +it('keeps ended queue work disabled after its control has applied', async () => { + const detail = taskDetail() + detail.execution!.state = 'paused' + detail.execution!.control = { commandId: 'ended', action: 'end', status: 'applied', reason: 'Queue item ended' } + const { view, controller } = await nativeView(detail) + expect(prepareButton(view)?.props.disabled).toBe(true) + expect(view.text()).toContain('本项队列处理已结束') + expect(view.nodes('button').some(node => node.props.children === '恢复处理')).toBe(false) + expect(controller.client.command).not.toHaveBeenCalled() +})