Jelajahi Sumber

Merge pull request #3401 from deepseek-harness/issue-1424-goal-pause-stop-turn

fix(goal): abort the live turn on host-initiated pause
Xu Hanxiang 1 Minggu lalu
induk
melakukan
d3ab4ce53d

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md
+2026-09-01-host-goal-pause-aborts-turn.md: 8a6acc1403ea9d8f1dab241e7888bb98aadcde31
+2026-09-01-host-goal-pause-aborts-turn.zh.md: 60170cd1029ce210fa1002ab407b4083af36d72e

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md

@@ -0,0 +1,29 @@
+# Agent Note: Host-initiated goal pause aborts the live turn
+
+Status: implemented
+
+English | [中文](2026-09-01-host-goal-pause-aborts-turn.zh.md)
+
+## Problem
+
+Clicking "pause goal" in the Web UI moved the goal to `paused` and disarmed automatic continuation, but the model turn already running kept going. The model could keep acting and call `update_goal resume` inside that same turn, immediately undoing the pause, so a manual pause had no real control over goal execution.
+
+## Decision
+
+The goal round driver now reads the `change` on every `goal/changed` event. When `operation === 'pause'` and the pause was not initiated by the agent's own turn, the driver aborts the live turn with `agent.cancel({ kind: 'user' }, { keepInbox: true })`. The Web button runs outside any agent initiator boundary, while a model's `update_goal pause` runs with the agent as the current initiator; the driver distinguishes them with `ctx.agents.currentInitiator() !== agent`. The abort is intentionally broad — it stops any live turn, not just a goal round — because a manual pause is a strong "stop now" signal and disarming alone stops future rounds but not the execution already under way.
+
+`keepInbox` preserves pending work. A queued goal round already fails the existing pre-step reservation check once the goal is disarmed, so it cannot run after the pause.
+
+The idle handler that pauses a cancelled goal is fenced to the dropped attempt's exact `{ goalId, revision }`. A resume bumps the revision, so a pause followed by an immediate resume — before the aborted turn converges to idle — is preserved instead of being re-paused by the stale cancelled attempt.
+
+## Alternatives considered
+
+**Cancel on every pause, including the model's own.** Rejected: a model that pauses in response to a direct human request should finish its turn and report; aborting mid-tool-call cuts off that acknowledgment without adding control.
+
+**Put the cancellation in the goal service's `pause`.** Rejected: `pause` is one shared entry point for host and model callers, so the service would still need the same initiator test. Keeping control handling in the round driver leaves the goal service a durable state and event owner.
+
+**Scope the abort to a turn actually running a goal round.** Rejected: the live turn is the execution the user asked to stop, and the extra attempt-state check adds a subtle path without changing the outcome the issue asks for.
+
+## Consequences
+
+A Web "pause goal" now aborts the running turn, so the model cannot keep acting or resume the just-paused goal in that turn. A pause followed by an immediate resume keeps the resumed goal running. Model-initiated pauses are unchanged. The change is confined to the round driver and its tests; the goal domain, tool authority, and durable formats are unchanged.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 宿主发起的 goal 暂停中止当前轮次
+
+Status: implemented
+
+[English](2026-09-01-host-goal-pause-aborts-turn.md) | 中文
+
+## 问题
+
+在 Web UI 点击「暂停目标」会把 goal 改成 `paused` 并解除自动续跑的武装(disarmed),但已经在跑的模型轮次不会停止。模型还能继续行动,并在同一个轮次里调用 `update_goal resume`,立刻撤销这次暂停,因此人工暂停对 goal 执行没有真正的控制力。
+
+## 决策
+
+goal round driver 现在会读取每个 `goal/changed` 事件里的 `change`。当 `operation === 'pause'` 且暂停不是由 agent 自己的轮次发起时,driver 用 `agent.cancel({ kind: 'user' }, { keepInbox: true })` 中止当前轮次。Web 按钮运行在任何 agent initiator 边界之外,而模型调用 `update_goal pause` 时当前 initiator 就是该 agent;driver 用 `ctx.agents.currentInitiator() !== agent` 来区分两者。中止是有意放宽的——它会停掉任何正在运行的轮次,而不只是 goal round——因为人工暂停是强烈的「现在停止」信号,仅 disarmed 只能阻止后续轮次,停不掉正在进行的执行。
+
+`keepInbox` 会保留待处理工作。一旦 goal 被 disarmed,已排队的 goal round 就会在既有的 pre-step reservation 校验里失败,因此暂停后不会再运行。
+
+暂停被取消 goal 的 idle 处理器被栅栏限定到被丢弃 attempt 的精确 `{ goalId, revision }`。resume 会推进 revision,因此在被中止轮次收敛到 idle 之前「暂停后立即 resume」会被保留,而不会被过期的 cancelled attempt 再次暂停。
+
+## 考虑过的替代方案
+
+**对每次暂停都中止轮次,包括模型自己发起的。** 否决:响应人类直接请求而暂停的模型应当完成本轮并给出回复;在工具调用中途中止只会截断这层确认,却换不来更多控制力。
+
+**把中止逻辑放进 goal 服务的 `pause`。** 否决:`pause` 是宿主与模型共用的唯一入口,服务里同样需要这个 initiator 判断。把控制处理留在 round driver,可以让 goal 服务保持为持久状态与事件的拥有者。
+
+**把中止限定到真正在跑 goal round 的轮次。** 否决:正在运行的轮次正是用户要求停止的执行,额外的 attempt 状态检查只会增加一条微妙路径,却不改变本 issue 要求的结果。
+
+## 后果
+
+现在 Web 的「暂停目标」会中止正在运行的轮次,模型无法继续行动或在同一轮次里恢复刚被暂停的 goal。暂停后立即 resume 会保留被恢复的 goal 继续运行。模型发起的暂停行为不变。改动局限于 round driver 及其测试;goal 领域、工具授权与持久化格式都不变。

+ 2 - 2
packages/goal/goal-round-driver/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/goal/goal-round-driver/README.md
-README.md: 18b086b9b2cef84861a54cbebda413cb1444d24f
-README.zh.md: ca38b5f6cc9e6a5292c91008321ea254a698f2b5
+README.md: 35e768f766b86740a65277281d73efa5c9534bc6
+README.zh.md: 577422ddc9228339c4f6544c1ce1d444032ec87e

+ 1 - 1
packages/goal/goal-round-driver/README.md

@@ -50,7 +50,7 @@ With an exact live agent idle, an active armed goal, and remaining capacity, the
 
 ### When continuation stops
 
-A round starts only at whole-agent idle, and completion, pause, and blocking suppress continuation; an edit only invalidates an in-flight round through the revision fence, and the driver continues the new revision. The driver also stops on its own when a turn ends on max tokens, a durability write fails, the agent is cancelled, the plugin unloads, or the round cap is exhausted — at the cap it records a blocker with the stable code `round-limit`. Cancellation never auto-restarts a round: a goal whose round was under way or already queued is paused at the next idle point, and a cancellation unrelated to a goal attempt only disarms continuation.
+A round starts only at whole-agent idle, and completion, pause, and blocking suppress continuation; a host-initiated pause also aborts the turn already running, while a model-initiated pause inside its own turn finishes normally. An edit only invalidates an in-flight round through the revision fence, and the driver continues the new revision. The driver also stops on its own when a turn ends on max tokens, a durability write fails, the agent is cancelled, the plugin unloads, or the round cap is exhausted — at the cap it records a blocker with the stable code `round-limit`. Cancellation never auto-restarts a round: a goal whose round was under way or already queued is paused at the next idle point, and a cancellation unrelated to a goal attempt only disarms continuation.
 
 ### After resume, fork, or unload
 

+ 1 - 1
packages/goal/goal-round-driver/README.zh.md

@@ -50,7 +50,7 @@ kind: "package-reference"
 
 ### 何时停止续行
 
-Round 只在整个 agent 进入 idle 时启动;完成、暂停和阻塞会阻止续行;编辑只会通过修订栅栏使进行中的 Round 失效,驱动器会继续新修订。驱动器也会在以下情况自行停止:轮次因 max tokens 结束、持久性写入失败、agent 被取消、插件卸载,或 Round 上限耗尽——上限耗尽时它会以稳定代码 `round-limit` 记录一个 blocker。取消绝不会自动重启 Round:Round 已在进行或已排入队列的 goal 会在下一次 idle 时被暂停;与 goal 尝试无关的取消只会停用续行。
+Round 只在整个 agent 进入 idle 时启动;完成、暂停和阻塞会阻止续行;宿主发起的暂停还会中止正在运行的轮次,而模型在自己轮次内发起的暂停会正常结束。编辑只会通过修订栅栏使进行中的 Round 失效,驱动器会继续新修订。驱动器也会在以下情况自行停止:轮次因 max tokens 结束、持久性写入失败、agent 被取消、插件卸载,或 Round 上限耗尽——上限耗尽时它会以稳定代码 `round-limit` 记录一个 blocker。取消绝不会自动重启 Round:Round 已在进行或已排入队列的 goal 会在下一次 idle 时被暂停;与 goal 尝试无关的取消只会停用续行。
 
 ### resume、fork 或卸载之后
 

+ 15 - 3
packages/goal/goal-round-driver/src/index.ts

@@ -262,8 +262,13 @@ export function apply(ctx: Context): void {
         state.competingQueued = false
         const attempt = state.attempt
         const goal = currentGoal(state)
-        if ((attempt?.phase === 'queued' || attempt?.phase === 'claimed' || attempt?.cancelled)
-          && goal?.phase === 'active' && goal.activation === 'armed') {
+        // Fence the pause to the exact dropped attempt's ref. A resume bumps
+        // the revision, so a host pause followed by an immediate resume (before
+        // the aborted turn converges to idle) must not re-pause the resumed goal.
+        if (attempt !== undefined
+          && (attempt.phase === 'queued' || attempt.phase === 'claimed' || attempt.cancelled)
+          && goal !== undefined && goal.phase === 'active' && goal.activation === 'armed'
+          && attempt.goalId === goal.id && attempt.revision === goal.revision) {
           state.attempt = undefined
           try {
             ctx.goals.pause(agent, goalRef(goal))
@@ -275,9 +280,16 @@ export function apply(ctx: Context): void {
         requestDrive(state)
       }
     })
-    ctx.on('goal/changed', ({ agent }) => {
+    ctx.on('goal/changed', ({ agent, change }) => {
       const state = stateFor(agent)
       state.needsCheckpoint = true
+      // A host-initiated pause stops goal execution: abort the live turn so the
+      // model cannot keep acting or resume in the same turn. A model-initiated
+      // pause (update_goal inside its own turn) finishes normally.
+      if (change.operation === 'pause' && agent.status === 'running'
+        && ctx.agents.currentInitiator() !== agent) {
+        agent.cancel({ kind: 'user' }, { keepInbox: true })
+      }
       requestDrive(state)
     })
 

+ 67 - 0
packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts

@@ -319,6 +319,73 @@ describe('same-session goal driving', () => {
     expect(test.adapter.requests).toHaveLength(1)
   })
 
+  it('aborts an in-flight round when a host-initiated pause lands mid-step', async () => {
+    const test = await harness(['hang'])
+    test.ctx.goals.create(test.agent, { objective: 'stop on host pause' })
+    await waitForRequests(test.adapter, 1)
+
+    // A host pause (Web button) runs outside the agent's own turn, so the
+    // round driver must stop the live round rather than let the model keep
+    // acting or resume the just-paused goal.
+    const current = test.ctx.goals.get(test.agent)
+    if (current === undefined) throw new Error('missing goal before host pause')
+    test.ctx.goals.pause(test.agent, { id: current.id, revision: current.revision })
+
+    await test.agent.whenIdle()
+    const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
+
+    expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' })
+    expect(test.adapter.requests).toHaveLength(1)
+  })
+
+  it('keeps a resumed goal running when the pause-turn has not yet converged', async () => {
+    const test = await harness(['hang', textResponse('resumed round')])
+    test.ctx.goals.create(test.agent, { objective: 'pause then resume', maxGoalRounds: 2 })
+    await waitForRequests(test.adapter, 1)
+
+    const current = test.ctx.goals.get(test.agent)
+    if (current === undefined) throw new Error('missing goal before pause')
+    const paused = test.ctx.goals.pause(test.agent, { id: current.id, revision: current.revision })
+    // Resume before the aborted turn converges to idle. The revision fence in the
+    // idle handler must not re-pause this freshly resumed goal.
+    test.ctx.goals.resume(test.agent, { id: paused.id, revision: paused.revision })
+
+    const goal = await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
+
+    expect(goal).toMatchObject({ phase: 'blocked', roundsStarted: 2 })
+    expect(goal?.blockedReason?.code).toBe('round-limit')
+    expect(test.adapter.requests).toHaveLength(2)
+  })
+
+  it('lets a model-initiated pause finish its own turn', async () => {
+    const holder: { ctx?: Context; agent?: Agent } = {}
+    const test = await harness([
+      () => {
+        if (holder.ctx !== undefined && holder.agent !== undefined) {
+          const goal = holder.ctx.goals.get(holder.agent)
+          if (goal !== undefined) {
+            holder.ctx.goals.pause(holder.agent, { id: goal.id, revision: goal.revision })
+          }
+        }
+        return textResponse('paused myself')
+      },
+    ])
+    holder.ctx = test.ctx
+    holder.agent = test.agent
+
+    test.ctx.goals.create(test.agent, { objective: 'pause myself', maxGoalRounds: 2 })
+
+    const goal = await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'paused')
+    await test.agent.whenIdle()
+
+    expect(goal).toMatchObject({ phase: 'paused', roundsStarted: 1 })
+    expect(test.adapter.requests).toHaveLength(1)
+    const turnEndKinds = test.agent.session.snapshotEvents().flatMap(event =>
+      event.type === 'turn/end' ? [event.data.reason.kind] : [])
+    expect(turnEndKinds).toContain('completed')
+    expect(turnEndKinds).not.toContain('aborted')
+  })
+
   it('lets already-queued human work finish before reserving the next round', async () => {
     const test = await harness([textResponse('human answer'), textResponse('goal answer')])
     test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 })