fix(goal): fence pause to the dropped attempt ref
This commit is contained in:
parent
29ce849738
commit
33fa98b3c2
7 changed files with 71 additions and 10 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 领域、工具授权与持久化格式都不变。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 或卸载之后
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue