From 9a8d21dfe750845856b46f5fe70a2f6bb0d82f77 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 29 Sep 2026 15:58:09 +0800 Subject: [PATCH] fix(goal-round-driver): withdraw a goal round parked by cancellation A goal round queued behind human work stayed in the inbox after Stop. The next human prompt claimed that stale round first, pre-step rejected it, and the driver exited with the human prompt still parked until another send. --- .../goal/goal-round-driver/README.i18n.yaml | 4 ++-- 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 | 12 ++++++---- .../tests/goal-round-driver.spec.ts | 22 +++++++++++++++++++ 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/goal/goal-round-driver/README.i18n.yaml b/packages/goal/goal-round-driver/README.i18n.yaml index 5ccda2b766..4ea142b57e 100644 --- a/packages/goal/goal-round-driver/README.i18n.yaml +++ b/packages/goal/goal-round-driver/README.i18n.yaml @@ -24,8 +24,8 @@ en: a1312339977f1127 zh: 57450327ff7e7d83 /deepseek-ai-dsh-goal-round-driver/use-this-package/when-continuation-stops: - en: fc22b566b233d3a1 - zh: 276ee5f35d4524f5 + en: 53614c11e3095ea2 + zh: eaf6933baa1b122c /deepseek-ai-dsh-goal-round-driver/use-this-package/after-resume-fork-or-unload: en: 85d5470b1b330330 zh: e761f9046ba7af8a diff --git a/packages/goal/goal-round-driver/README.md b/packages/goal/goal-round-driver/README.md index c80def6393..c5a2fe2571 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; 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. +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. A round prompt still queued when cancellation reaches idle is removed from the inbox, so later human input does not wait behind it. ### 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 92f793ac8e..4d0ab69d0f 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 尝试无关的取消只会停用续行。取消到达 idle 时仍在排队的 Round 提示会从收件箱移除,后续人类输入不会排在它后面等待。 ### resume、fork 或卸载之后 diff --git a/packages/goal/goal-round-driver/src/index.ts b/packages/goal/goal-round-driver/src/index.ts index b8cffbfc29..8d815d29b9 100644 --- a/packages/goal/goal-round-driver/src/index.ts +++ b/packages/goal/goal-round-driver/src/index.ts @@ -264,15 +264,19 @@ export function apply(ctx: Context): void { // 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 + const pause = 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) { + && attempt.goalId === goal.id && attempt.revision === goal.revision + // A reservation still queued at idle was parked by cancellation. Withdraw + // it: pre-step would reject the stale round and strand human input queued behind it. + if (pause || attempt?.phase === 'queued') { state.attempt = undefined try { - ctx.goals.pause(agent, goalRef(goal)) + if (attempt.phase === 'queued') agent.inbox.remove(attempt.messageId) + if (pause) ctx.goals.pause(agent, goalRef(goal)) } catch (error: unknown) { - ctx.logger.warn(`goal-round-driver: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) + ctx.logger.warn(`goal-round-driver: could not settle cancelled goal round for agent "${agent.id}": ${renderThrown(error)}`) disarm(state) } } 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 e9b9edc481..040abe6726 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 @@ -310,6 +310,28 @@ describe('same-session goal driving', () => { && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) + it('withdraws a round parked behind cancelled human work so later input runs', async () => { + const test = await harness(['hang', 'hang', textResponse('answered 1')]) + test.ctx.goals.create(test.agent, { objective: 'park behind human work' }) + await waitForRequests(test.adapter, 1) + test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human X' }], source: { kind: 'user' } })) + test.agent.cancel({ kind: 'user' }, { keepInbox: true }) + const paused = await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'paused') + // Resuming reserves the next round behind the parked human prompt. + test.ctx.goals.resume(test.agent, { id: paused!.id, revision: paused!.revision }) + await waitForRequests(test.adapter, 2) + expect(test.agent.inbox.nextTurn.map(message => message.source.kind)).toEqual(['goal']) + + test.agent.cancel({ kind: 'user' }, { keepInbox: true }) + await test.agent.whenIdle() + expect(test.agent.inbox.nextTurn).toEqual([]) + test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human 1' }], source: { kind: 'user' } })) + await waitForRequests(test.adapter, 3) + + expect(requestText(test.adapter.requests[2]!)).toContain('human 1') + expect(test.agent.inbox.nextTurn).toEqual([]) + }) + it('pauses an admitted round when cancellation aborts an active step', async () => { const test = await harness(['hang']) test.ctx.goals.create(test.agent, { objective: 'stop in flight' })