Merge pull request #5469 from deepseek-harness/turtle/followup-stop-behavior-5a63325e10fb
fix(goal-round-driver): withdraw a goal round parked by cancellation
This commit is contained in:
commit
35a733ccb5
7 files changed
+163
-8
No files matched your search
@@ -29,6 +29,7 @@ const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const SENDING_EXPECTED = join(SNAPSHOT_DIR, 'sending.expected.md')
|
||||
const WRITER_HELD_EXPECTED = join(SNAPSHOT_DIR, 'writer-held.expected.md')
|
||||
const FAILED_EXPECTED = join(SNAPSHOT_DIR, 'failed.expected.md')
|
||||
const GOAL_STOP_EXPECTED = join(SNAPSHOT_DIR, 'goal-stop.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
|
||||
@@ -421,6 +422,73 @@ describe('web e2e: queue row actions', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('runs a prompt sent after Stop when a Goal round was queued behind stopped work', async () => {
|
||||
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-goal-stop-'))
|
||||
const roundReady = join(overrideDir, '.round-ready')
|
||||
const humanReady = join(overrideDir, '.human-ready')
|
||||
const overridePath = join(overrideDir, 'replay.override.json')
|
||||
const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
|
||||
await writeFile(overridePath, JSON.stringify([
|
||||
{ kind: 'hang', readyFile: roundReady },
|
||||
{ kind: 'hang', readyFile: humanReady },
|
||||
recorded[0]!,
|
||||
] satisfies ReplayEntry[]))
|
||||
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath, compareReplaySession: false })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-stop'))
|
||||
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
const editable = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
const stop = page.getByRole('button', { name: 'Stop generating', exact: true })
|
||||
await editable.waitFor({ timeout: 10_000 })
|
||||
await input.fill('/goal Keep working after Stop')
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => existsSync(roundReady), { timeout: 15_000 }).toBe(true)
|
||||
|
||||
// Human work queued behind the running round survives the first Stop.
|
||||
await editable.waitFor({ timeout: 10_000 })
|
||||
await input.fill(REMOVE)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 10_000 })
|
||||
await stop.click()
|
||||
await page.getByRole('button', { name: 'Resume goal' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
// Resuming queues the next round behind that human prompt, which runs first.
|
||||
await page.getByRole('button', { name: 'Resume goal' }).click()
|
||||
await expect.poll(() => existsSync(humanReady), { timeout: 15_000 }).toBe(true)
|
||||
await expect.poll(() => page.locator('[data-queue-dock] li', { hasText: '<goal_round>' }).count(), { timeout: 10_000 })
|
||||
.toBe(1)
|
||||
await stop.click()
|
||||
await expect.poll(() => stop.count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await editable.waitFor({ timeout: 10_000 })
|
||||
await input.fill(WAKE)
|
||||
await input.press('Enter')
|
||||
await settled
|
||||
await expect.poll(() => turnEndReasons(sessionEvents), { timeout: 15_000 })
|
||||
.toEqual(['aborted', 'aborted', 'completed'])
|
||||
expect(sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])).toEqual([REMOVE, WAKE])
|
||||
expect(sessionEvents.some(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-turn'
|
||||
&& event.data.outcome === 'canceled')).toBe(true)
|
||||
await expect.poll(() => stop.count(), { timeout: 10_000 }).toBe(0)
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(GOAL_STOP_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(
|
||||
SNAPSHOT_DIR,
|
||||
@@ -428,6 +496,7 @@ describe('web e2e: queue row actions', () => {
|
||||
'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
|
||||
'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
|
||||
'sending.expected.md', 'failed.expected.md', 'writer-held.expected.md',
|
||||
'goal-stop.expected.md',
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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: 87b20bc93e1c6ebb
|
||||
zh: 20eebb2538da1551
|
||||
/deepseek-ai-dsh-goal-round-driver/use-this-package/after-resume-fork-or-unload:
|
||||
en: 85d5470b1b330330
|
||||
zh: e761f9046ba7af8a
|
||||
|
||||
@@ -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 is paused at the next idle point, and a cancellation unrelated to a goal attempt only disarms continuation. A round prompt still queued when the agent reaches idle is removed from the inbox, so later human input does not wait behind it; when the cancelled turn belonged to other work, continuation is disarmed rather than paused.
|
||||
|
||||
### 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 尝试无关的取消只会停用续行。agent 进入 idle 时仍在排队的 Round 提示会从收件箱移除,后续人类输入不会排在它后面等待;被取消的轮次属于其他工作时,续行会被停用而不是暂停。
|
||||
|
||||
### resume、fork 或卸载之后
|
||||
|
||||
|
||||
@@ -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 when the agent reaches idle cannot run:
|
||||
// withdraw it so human input queued behind it is not stranded.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +310,32 @@ 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([])
|
||||
// The cancelled turn belonged to human work, so continuation disarms without pausing.
|
||||
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', activation: 'disarmed' })
|
||||
expect(test.agent.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced').at(-1)?.data)
|
||||
.toEqual({ target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' })
|
||||
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' })
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy": Queue item to remove
|
||||
- text: Standard mode
|
||||
- button "More actions"
|
||||
- button "Open right sidebar"
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- navigation "Turn navigation":
|
||||
- button "Jump to turn 1"
|
||||
- button "Jump to turn 2"
|
||||
- button "Jump to turn 3"
|
||||
- group "Command input": /goal Keep working after Stop
|
||||
- 'button "goal Goal created Status: active Objective: Keep working after Stop Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"'
|
||||
- button "Continuing goal {{clock}}":
|
||||
- text: Continuing goal
|
||||
- time: {{clock}}
|
||||
- status: Stopped
|
||||
- button "Stopped" [disabled]
|
||||
- paragraph: partial
|
||||
- text: Stopped
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- text: {{clock}} Queue item to remove {{clock}}
|
||||
- button "Copy"
|
||||
- status: Stopped
|
||||
- button "Stopped" [disabled]
|
||||
- paragraph: partial
|
||||
- text: Stopped
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- text: {{clock}} Wake the preserved queue {{clock}}
|
||||
- button "Copy"
|
||||
- status: Completed
|
||||
- button "Completed in {{duration}}"
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation"
|
||||
- text: {{clock}} Inactive Goal Keep working after Stop
|
||||
- button "Resume goal"
|
||||
- button "Edit goal"
|
||||
- button "Clear goal"
|
||||
- textbox "Message or run a task, / commands, @ files or sessions"
|
||||
- button "Add files or run commands"
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash
|
||||
- button "Send message" [disabled]
|
||||
- button "3 turns 3 steps · {{throughput}} tok/s": 3 turns 3 steps{{throughput}} tok/s
|
||||
- button "7.9K tok · Cache hit 99%": 7.9K tokCache hit 99%
|
||||
- button "6% of context used": 6%
|
||||
Reference in new issue
Block a user