From 33fa98b3c255d581182fcab3e88751f4f3c007de Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 1 Sep 2026 19:58:52 +0800 Subject: [PATCH] fix(goal): fence pause to the dropped attempt ref --- ...9-01-host-goal-pause-aborts-turn.i18n.yaml | 4 +- .../2026-09-01-host-goal-pause-aborts-turn.md | 8 +++- ...26-09-01-host-goal-pause-aborts-turn.zh.md | 8 +++- packages/goal/goal-round-driver/README.md | 2 +- packages/goal/goal-round-driver/README.zh.md | 2 +- packages/goal/goal-round-driver/src/index.ts | 9 +++- .../tests/goal-round-driver.spec.ts | 48 +++++++++++++++++++ 7 files changed, 71 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml index 8fde95cb81..f113c39c96 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.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 .agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md -2026-09-01-host-goal-pause-aborts-turn.md: a2748ac65645e07a8f78fa4ca9e2f96cdb94ad7f -2026-09-01-host-goal-pause-aborts-turn.zh.md: c6f9aa51e47dbc16e6ab6682d4e90cf841f033ef +2026-09-01-host-goal-pause-aborts-turn.md: 8a6acc1403ea9d8f1dab241e7888bb98aadcde31 +2026-09-01-host-goal-pause-aborts-turn.zh.md: 60170cd1029ce210fa1002ab407b4083af36d72e diff --git a/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md index a2748ac656..8a6acc1403 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md +++ b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md @@ -10,16 +10,20 @@ Clicking "pause goal" in the Web UI moved the goal to `paused` and disarmed auto ## Decision -The goal round driver now reads the `change` on every `goal/changed` event. When `operation === 'pause'`, the driver aborts the live turn with `agent.cancel({ kind: 'user' }, { keepInbox: true })` unless the pause was initiated by the agent's own turn. 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 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. 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. +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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md index c6f9aa51e4..60170cd102 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-01-host-goal-pause-aborts-turn.zh.md @@ -10,16 +10,20 @@ Status: implemented ## 决策 -goal round driver 现在会读取每个 `goal/changed` 事件里的 `change`。当 `operation === 'pause'` 时,driver 用 `agent.cancel({ kind: 'user' }, { keepInbox: true })` 中止当前轮次,除非这次暂停是由 agent 自己的轮次发起的。Web 按钮运行在任何 agent initiator 边界之外,而模型调用 `update_goal pause` 时当前 initiator 就是该 agent;driver 用 `ctx.agents.currentInitiator() !== agent` 来区分两者。 +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。模型发起的暂停行为不变。改动局限于 round driver 及其测试;goal 领域、工具授权与持久化格式都不变。 +现在 Web 的「暂停目标」会中止正在运行的轮次,模型无法继续行动或在同一轮次里恢复刚被暂停的 goal。暂停后立即 resume 会保留被恢复的 goal 继续运行。模型发起的暂停行为不变。改动局限于 round driver 及其测试;goal 领域、工具授权与持久化格式都不变。 diff --git a/packages/goal/goal-round-driver/README.md b/packages/goal/goal-round-driver/README.md index 18b086b9b2..35e768f766 100644 --- a/packages/goal/goal-round-driver/README.md +++ b/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 diff --git a/packages/goal/goal-round-driver/README.zh.md b/packages/goal/goal-round-driver/README.zh.md index ca38b5f6cc..577422ddc9 100644 --- a/packages/goal/goal-round-driver/README.zh.md +++ b/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 或卸载之后 diff --git a/packages/goal/goal-round-driver/src/index.ts b/packages/goal/goal-round-driver/src/index.ts index 5b8b715e5d..0074c5178d 100644 --- a/packages/goal/goal-round-driver/src/index.ts +++ b/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)) diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index 2b95ce9ee6..da8caac888 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -338,6 +338,54 @@ describe('same-session goal driving', () => { 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 })