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
30 changes: 30 additions & 0 deletions docs/verification/issue-368-waiting-continuation.md
Original file line number Diff line number Diff line change
@@ -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` 与主干受影响组件相同,本补丁可独立集成;不能以主干包覆盖该分支尚未合入的宿主和接续修复。
8 changes: 5 additions & 3 deletions src/client/execution-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <section className={styles.surface} aria-label="接续">
<div className={styles.sectionHead}><h2>接续</h2>
{(onContinue || completed) && <button type="button" className={styles.primaryAction} disabled={completed || busy || blocked || !execution?.actual || !onContinue} onClick={completed ? undefined : onContinue}>{completed ? '准备接续' : busy ? '正在准备接续…' : op?.phase === 'taken' ? '重试内容投递' : execution && continuationInFlight(execution) ? '重试本次接续' : '准备接续'}</button>}
{!completed && failed && onRecover && <button type="button" disabled={busy} onClick={onRecover}>重试原执行</button>}
{!completed && suspended && !pendingControl && onResume && <button type="button" disabled={busy} onClick={onResume}>恢复处理</button>}
{!completed && suspended && !ended && !pendingControl && onResume && <button type="button" disabled={busy} onClick={onResume}>恢复处理</button>}
</div>
{!completed && failed && <p role="alert">原执行恢复失败:{execution?.error ? `${execution.error.code}:${execution.error.message}` : '原因未知'}。请先重试原执行,恢复成功后再准备接续。</p>}
{!completed && suspended && <p role="status">原执行{execution?.state === 'paused' ? '已暂停' : '正在等待回复'};请先恢复处理,再准备接续。</p>}
{!completed && suspended && !ended && <p role="status">仅切换会话,保留当前等待或暂停;恢复处理需单独操作。</p>}
{!completed && ended && <p role="status">本项队列处理已结束,不能继续接续。</p>}
{!completed && pendingControl && !failed && <p role="status">原执行的等待或暂停请求尚未生效,请等待结算后刷新观察。</p>}
{!completed && (execution?.state === 'accepted' || execution?.state === 'creating') && <p role="status">原执行尚未就绪,请等待创建或恢复后刷新观察。</p>}
{completed && <p role="status">任务阶段:done(已验收完成) · 任务已验收完成,不能继续接续</p>}
Expand Down
55 changes: 51 additions & 4 deletions tests/client/execution-continuation-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand All @@ -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()
})
Expand Down Expand Up @@ -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<typeof mountProductionComponent>, 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()
})
Loading