Merge pull request #7768 from deepseek-harness/fix/goal-shutdown-pause

fix(goal-round-driver): keep a running goal active when its agent is disposed
This commit is contained in:
imccyu
2026-11-03 05:52:48 +00:00
committed by GitHub
parent f07af538f8
commit f26f308e5e
5 changed files with 80 additions and 4 deletions
@@ -24,8 +24,8 @@
en: a1312339977f1127
zh: 57450327ff7e7d83
/deepseek-ai-dsh-goal-round-driver/use-this-package/when-continuation-stops:
en: 32ad77251e9f5acd
zh: a72f4c3e95c1a137
en: f3cb23a11176dc4f
zh: de7aa54d5c93445d
/deepseek-ai-dsh-goal-round-driver/use-this-package/after-resume-fork-or-unload:
en: 85d5470b1b330330
zh: e761f9046ba7af8a
+1 -1
View File
@@ -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, when the subagent service is loaded, only after the agent's progressing descendants settle; 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.
A round starts only at whole-agent idle and, when the subagent service is loaded, only after the agent's progressing descendants settle; 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. Agent disposal, such as a graceful process shutdown or a reload of the plugin that owns the Agent, aborts the round with the `disposed` cause and only disarms continuation, so the goal stays active, as on plugin unload; user, parent, and hook cancellations still pause it. 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
+1 -1
View File
@@ -50,7 +50,7 @@ kind: "package-reference"
### 何时停止续行
Round 只在整个 agent 进入 idle 时启动;加载了子 agent 服务时,还要等该 agent 仍在推进的后代全部结束后才启动;完成、暂停和阻塞会阻止续行;宿主发起的暂停还会中止正在运行的轮次,而模型在自己轮次内发起的暂停会正常结束。编辑只会通过修订栅栏使进行中的 Round 失效,驱动器会继续新修订。驱动器也会在以下情况自行停止:轮次因 max tokens 结束、持久性写入失败、agent 被取消、插件卸载,或 Round 上限耗尽——上限耗尽时它会以稳定代码 `round-limit` 记录一个 blocker。取消绝不会自动重启 Round:Round 已在进行的 goal 会在下一次 idle 时被暂停;与 goal 尝试无关的取消只会停用续行。agent 进入 idle 时仍在排队的 Round 提示会从收件箱移除,后续人类输入不会排在它后面等待;被取消的轮次属于其他工作时,续行会被停用而不是暂停。
Round 只在整个 agent 进入 idle 时启动;加载了子 agent 服务时,还要等该 agent 仍在推进的后代全部结束后才启动;完成、暂停和阻塞会阻止续行;宿主发起的暂停还会中止正在运行的轮次,而模型在自己轮次内发起的暂停会正常结束。编辑只会通过修订栅栏使进行中的 Round 失效,驱动器会继续新修订。驱动器也会在以下情况自行停止:轮次因 max tokens 结束、持久性写入失败、agent 被取消、插件卸载,或 Round 上限耗尽——上限耗尽时它会以稳定代码 `round-limit` 记录一个 blocker。取消绝不会自动重启 Round:Round 已在进行的 goal 会在下一次 idle 时被暂停;与 goal 尝试无关的取消只会停用续行。Agent 被处置(例如进程正常关停,或重载拥有该 Agent 的插件)时,Round 以 `disposed` 原因中止,此时只停用续行,goal 保持 active,与插件卸载时一致;用户、父 Agent 与 hook 发起的取消仍会暂停它。agent 进入 idle 时仍在排队的 Round 提示会从收件箱移除,后续人类输入不会排在它后面等待;被取消的轮次属于其他工作时,续行会被停用而不是暂停。
### resume、fork 或卸载之后
@@ -351,6 +351,13 @@ export function apply(ctx: Context): void {
return
}
if (event.data.reason.kind !== 'aborted') return
const abortReason = event.data.reason.reason
// Agent disposal aborts the round without a pause decision, so continuation
// is only disarmed and the durable phase stays active, as on driver unload.
if (abortReason.kind === 'disposed') {
disarm(state)
return
}
if (state.attempt?.phase === 'claimed' || state.attempt?.phase === 'admitted') {
state.attempt.cancelled = true
}
@@ -150,6 +150,11 @@ async function waitForRequests(adapter: ScriptedAdapter, count: number): Promise
})
}
/** Durable goal operations recorded in one Agent's Session log. */
function goalOperations(agent: Agent): string[] {
return agent.session.snapshotEvents().flatMap(event => event.type === 'goal/change' ? [event.data.operation] : [])
}
describe('goal-round outcome policy', () => {
it('renders the objective, round budget, authority boundary, and completion protocol', () => {
const goal: GoalView = {
@@ -1025,6 +1030,70 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(1)
})
it('keeps a running goal active when its Agent is disposed mid-round', async () => {
const test = await harness(['hang'])
const handle = await test.ctx.agents.create({
sessionId: SessionId('goal-session-agent-disposed'),
agentOptions: { provider: 'mock', model: 'mock' },
})
test.ctx.goals.create(handle.agent, { objective: 'survive agent disposal' })
await waitForRequests(test.adapter, 1)
await handle.dispose()
// Disposal aborts the round with the `disposed` cause, which is no pause decision.
expect(goalOperations(handle.agent)).toEqual(['create'])
})
it('keeps a running goal active and starts no round when only its Agent owner unloads', async () => {
const test = await harness(['hang'])
let agent: Agent | undefined
// Reloading or disabling the owner plugin at runtime disposes the Agents it created.
const owner = await test.ctx.plugin({
name: 'goal-test-reloaded-owner',
inject: ['agents'],
async apply(ownerCtx: Context) {
agent = (await ownerCtx.agents.create({
sessionId: SessionId('goal-session-owner-unload'),
agentOptions: { provider: 'mock', model: 'mock' },
})).agent
},
})
if (agent === undefined) throw new Error('the owner did not create its Agent')
test.ctx.goals.create(agent, { objective: 'survive owner reload' })
await waitForRequests(test.adapter, 1)
await owner.dispose()
expect(goalOperations(agent)).toEqual(['create'])
expect(test.adapter.requests).toHaveLength(1)
})
it.each([true, false])('keeps a running goal active when root disposal unloads its Agent owner first: %s', async (ownerUnloadsFirst) => {
const test = await harness(['hang'])
// An owner depending on a root-provided service unloads before its root's other
// children, as the Web Session controller does under the Cordis teardown order.
test.ctx.provide('goalTestRootService', {})
let agent: Agent | undefined
await test.ctx.plugin({
name: 'goal-test-agent-owner',
inject: ownerUnloadsFirst ? ['goalTestRootService', 'agents'] : ['agents'],
async apply(owner: Context) {
agent = (await owner.agents.create({
sessionId: SessionId(`goal-session-root-disposal-${String(ownerUnloadsFirst)}`),
agentOptions: { provider: 'mock', model: 'mock' },
})).agent
},
})
if (agent === undefined) throw new Error('the owner did not create its Agent')
test.ctx.goals.create(agent, { objective: 'survive shutdown' })
await waitForRequests(test.adapter, 1)
await test.ctx.fiber.dispose()
expect(goalOperations(agent)).toEqual(['create'])
})
it('cancels an accepted queued round and awaits its driver task during teardown', async () => {
const test = await harness([])
let unloading: Promise<void> | undefined