diff --git a/packages/goal/goal-round-driver/README.i18n.yaml b/packages/goal/goal-round-driver/README.i18n.yaml index 23adabf2c0..19e3ebc68a 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: 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 diff --git a/packages/goal/goal-round-driver/README.md b/packages/goal/goal-round-driver/README.md index 536c1f8256..1302bc6fdc 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, 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 diff --git a/packages/goal/goal-round-driver/README.zh.md b/packages/goal/goal-round-driver/README.zh.md index 685368655c..05905c7043 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 时启动;加载了子 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 或卸载之后 diff --git a/packages/goal/goal-round-driver/src/index.ts b/packages/goal/goal-round-driver/src/index.ts index 2d20ee08d9..c84f9c26d1 100644 --- a/packages/goal/goal-round-driver/src/index.ts +++ b/packages/goal/goal-round-driver/src/index.ts @@ -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 } 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 3af8fb2496..6aeb67163d 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 @@ -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 | undefined