fix(goal): fence pause to the dropped attempt ref

This commit is contained in:
mektpoy 2026-09-01 19:58:52 +08:00
parent 29ce849738
commit 33fa98b3c2
7 changed files with 71 additions and 10 deletions

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: 8a6acc1403ea9d8f1dab241e7888bb98aadcde31
2026-09-01-host-goal-pause-aborts-turn.zh.md: c6f9aa51e47dbc16e6ab6682d4e90cf841f033ef 2026-09-01-host-goal-pause-aborts-turn.zh.md: 60170cd1029ce210fa1002ab407b4083af36d72e

View file

@ -10,16 +10,20 @@ Clicking "pause goal" in the Web UI moved the goal to `paused` and disarmed auto
## Decision ## 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. `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 ## 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. **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. **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 ## 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.

View file

@ -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 校验里失败,因此暂停后不会再运行。 `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 服务的 `pause`。** 否决:`pause` 是宿主与模型共用的唯一入口,服务里同样需要这个 initiator 判断。把控制处理留在 round driver,可以让 goal 服务保持为持久状态与事件的拥有者。
**把中止限定到真正在跑 goal round 的轮次。** 否决:正在运行的轮次正是用户要求停止的执行,额外的 attempt 状态检查只会增加一条微妙路径,却不改变本 issue 要求的结果。
## 后果 ## 后果
现在 Web 的「暂停目标」会中止正在运行的轮次,模型无法继续行动或在同一轮次里恢复刚被暂停的 goal。模型发起的暂停行为不变。改动局限于 round driver 及其测试;goal 领域、工具授权与持久化格式都不变。 现在 Web 的「暂停目标」会中止正在运行的轮次,模型无法继续行动或在同一轮次里恢复刚被暂停的 goal。暂停后立即 resume 会保留被恢复的 goal 继续运行。模型发起的暂停行为不变。改动局限于 round driver 及其测试;goal 领域、工具授权与持久化格式都不变。

View file

@ -50,7 +50,7 @@ With an exact live agent idle, an active armed goal, and remaining capacity, the
### When continuation stops ### 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 ### After resume, fork, or unload

View file

@ -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 或卸载之后 ### resume、fork 或卸载之后

View file

@ -262,8 +262,13 @@ export function apply(ctx: Context): void {
state.competingQueued = false state.competingQueued = false
const attempt = state.attempt const attempt = state.attempt
const goal = currentGoal(state) const goal = currentGoal(state)
if ((attempt?.phase === 'queued' || attempt?.phase === 'claimed' || attempt?.cancelled) // Fence the pause to the exact dropped attempt's ref. A resume bumps
&& goal?.phase === 'active' && goal.activation === 'armed') { // 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 state.attempt = undefined
try { try {
ctx.goals.pause(agent, goalRef(goal)) ctx.goals.pause(agent, goalRef(goal))

View file

@ -338,6 +338,54 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(1) 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 () => { it('lets already-queued human work finish before reserving the next round', async () => {
const test = await harness([textResponse('human answer'), textResponse('goal answer')]) const test = await harness([textResponse('human answer'), textResponse('goal answer')])
test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 }) test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 })