fix(goal): abort the live turn on host-initiated pause

This commit is contained in:
mektpoy 2026-09-01 13:56:36 +08:00
parent 714bec1316
commit 29ce849738
5 changed files with 83 additions and 1 deletions

View file

@ -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: a2748ac65645e07a8f78fa4ca9e2f96cdb94ad7f
2026-09-01-host-goal-pause-aborts-turn.zh.md: c6f9aa51e47dbc16e6ab6682d4e90cf841f033ef

View file

@ -0,0 +1,25 @@
# 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'`, 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`.
`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.
## 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.
## 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.

View file

@ -0,0 +1,25 @@
# 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'` 时,driver 用 `agent.cancel({ kind: 'user' }, { keepInbox: true })` 中止当前轮次,除非这次暂停是由 agent 自己的轮次发起的。Web 按钮运行在任何 agent initiator 边界之外,而模型调用 `update_goal pause` 时当前 initiator 就是该 agent;driver 用 `ctx.agents.currentInitiator() !== agent` 来区分两者。
`keepInbox` 会保留待处理工作。一旦 goal 被 disarmed,已排队的 goal round 就会在既有的 pre-step reservation 校验里失败,因此暂停后不会再运行。
## 考虑过的替代方案
**对每次暂停都中止轮次,包括模型自己发起的。** 否决:响应人类直接请求而暂停的模型应当完成本轮并给出回复;在工具调用中途中止只会截断这层确认,却换不来更多控制力。
**把中止逻辑放进 goal 服务的 `pause`。** 否决:`pause` 是宿主与模型共用的唯一入口,服务里同样需要这个 initiator 判断。把控制处理留在 round driver,可以让 goal 服务保持为持久状态与事件的拥有者。
## 后果
现在 Web 的「暂停目标」会中止正在运行的轮次,模型无法继续行动或在同一轮次里恢复刚被暂停的 goal。模型发起的暂停行为不变。改动局限于 round driver 及其测试;goal 领域、工具授权与持久化格式都不变。

View file

@ -275,9 +275,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)
})

View file

@ -319,6 +319,25 @@ 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('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 })