refactor agent pre-step inbox lifecycle

This commit is contained in:
_Kerman
2026-07-31 19:21:16 +08:00
parent c2ff9ddec8
commit fcc2b5e282
267 changed files with 2052 additions and 1546 deletions
@@ -16,7 +16,7 @@ interface Harness {
readonly plugin: Awaited<ReturnType<Context['plugin']>>
}
/** Append one idle injection using the public Agent contract (idle inject wraps in a one-shot injection turn, per turn enclosure). */
/** Commit one injected message as an already admitted turn for the Agent test double. */
function appendInjection(session: Session, input: UserMessage): void {
const lastStart = session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
@@ -34,7 +34,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
id: session.id,
options: {},
session,
inbox: new Inbox(session),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
ctx: new Context(),
get status() { return status },
send: () => {},
+2 -2
View File
@@ -2,5 +2,5 @@
# 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:
# pnpm run verify-translation-pairing --write packages/goal/goal-session/README.md
README.md: 7c56f295bb7a587913b201db8860d11605886a64
README.zh.md: e41afbc4142eee6a8a50e43b4fa6ca34ecc28641
README.md: 89413062de8cbb49d7066ec3ae42769a99c939a2
README.zh.md: b7298774399d1cd32bb27880179a79ecac06f074
+2 -2
View File
@@ -21,9 +21,9 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def
## Round contract
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. The `agent/pre-step` listener verifies the complete claimed record and current goal both before and after downstream listeners; only an entered `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
`MessageId` identifies the reserved message through durable inbox insertion and admission; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint.
`MessageId` identifies the reserved message through durable inbox insertion and claim; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint.
The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`.
+2 -2
View File
@@ -21,9 +21,9 @@
## Round 契约
当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource`。通过 `agent/prompt-submit` 准入时,会在下游提示词钩子前后验证完整的排队记录与当前 goal;只有被接受的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。
当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource`。`agent/pre-step` 监听器会在下游监听器前后验证完整的已领取记录与当前 goal;只有进入步骤的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。
`MessageId` 通过持久 inbox 插入和准入来标识预留消息;它不标识轮次结果。用户消息不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有完成该检查点后才重新预留。
`MessageId` 通过持久 inbox 插入和领取来标识预留消息;它不标识轮次结果。用户消息不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有完成该检查点后才重新预留。
保留的提示词会点明经过 JSON 引用的目标与 `round/maxGoalRounds`,将当前工作区、工具结果和持久会话状态视为权威信息,要求在完成前提供证据,并要求在工作仍未完成时保持目标 active。引用可将多行或形似标签的目标文本保留为数据。goal 生命周期变更仍必须通过 `dsh-tool-goal` 的独立权限检查。
+12 -23
View File
@@ -6,7 +6,7 @@
import { isDeepStrictEqual } from 'node:util'
import { FiberState } from 'cordis'
import type { Context } from 'cordis'
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
@@ -18,8 +18,6 @@ export { renderGoalRoundPrompt } from './prompt.ts'
export const name = 'goal-session'
export const inject = ['agents', 'goals', 'sessions']
const STALE_ROUND_REASON = 'stale goal-round reservation'
/** Identity reserved before a goal continuation enters the agent inbox. */
interface RoundIdentity {
readonly goalId: GoalRef['id']
@@ -125,12 +123,6 @@ export function apply(ctx: Context): void {
}
}
/** Remove only this driver's still-pending reservation. */
function cancelReservation(agent: Agent, attempt: RoundAttempt): void {
const index = agent.inbox.nextTurn.findIndex(message => message.id === attempt.messageId)
if (index >= 0) agent.inbox.splice('next-turn', index, 1, [], 'canceled')
}
/** Process admitted work at quiescence, then reserve at most one next round. */
async function drive(state: DriverState): Promise<void> {
const { agent } = state
@@ -204,7 +196,7 @@ export function apply(ctx: Context): void {
/** Coalesce triggers onto one agent-local serialized driver. */
function requestDrive(state: DriverState): void {
/* v8 ignore next -- teardown may race a final trigger after synchronously closing admission */
/* v8 ignore next -- teardown may race a final trigger after synchronously closing the step fence */
if (state.stopping) return
state.requested = true
if (state.run !== undefined) return
@@ -238,7 +230,7 @@ export function apply(ctx: Context): void {
})
}
// One composite effect keeps the admission fence installed until this
// One composite effect keeps the step fence installed until this
// plugin's own scheduling tasks settle.
ctx.effect(function* () {
ctx.on('agent/error', (agent) => {
@@ -329,7 +321,7 @@ export function apply(ctx: Context): void {
&& source.round === goal.roundsStarted + 1
}
ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise<PromptDecision> => {
ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => {
const submitted = messages.find(message => isGoalRoundSource(message.source))
if (submitted === undefined) return next()
const { content, source } = submitted
@@ -339,7 +331,7 @@ export function apply(ctx: Context): void {
try {
valid = validReservation(state, content, source)
} catch (error: unknown) {
ctx.logger.warn(`goal-session: admission check failed for agent "${agent.id}": ${renderThrown(error)}`)
ctx.logger.warn(`goal-session: pre-step check failed for agent "${agent.id}": ${renderThrown(error)}`)
disarm(state)
}
if (!valid) {
@@ -347,30 +339,28 @@ export function apply(ctx: Context): void {
if (attempt !== undefined && sameRound(source, attempt)) {
attempt.stale = true
state.attempt = undefined
cancelReservation(agent, attempt)
}
requestDrive(state)
return { kind: 'block', reason: STALE_ROUND_REASON, discardClaimed: false }
return { kind: 'reject' }
}
let decision: PromptDecision
let decision: PreStepDecision
try {
decision = await next()
} catch (error: unknown) {
if (signal.aborted) throw error
// A throwing downstream hook drops the whole admission: the loop
// A throwing downstream hook drops the whole step proposal: the loop
// returns to idle without a turn, so a still-queued reservation would
// starve every later drive pass. Clear it and let the driver
// reschedule the round.
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt) && attempt.phase === 'queued') {
state.attempt = undefined
cancelReservation(agent, attempt)
requestDrive(state)
}
throw error
}
if (signal.aborted) return decision
if (decision.kind === 'block') {
if (decision.kind === 'reject') {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined
const goal = currentGoal(state)
@@ -378,7 +368,7 @@ export function apply(ctx: Context): void {
&& goal.phase === 'active' && goal.activation === 'armed') {
ctx.goals.block(agent, goalRef(goal), {
code: 'prompt-rejected',
message: decision.reason,
message: 'Goal round was rejected before entering its step.',
})
}
return decision
@@ -386,7 +376,7 @@ export function apply(ctx: Context): void {
try {
valid = validReservation(state, content, source)
} catch (error: unknown) {
ctx.logger.warn(`goal-session: post-admission check failed for agent "${agent.id}": ${renderThrown(error)}`)
ctx.logger.warn(`goal-session: post-decision check failed for agent "${agent.id}": ${renderThrown(error)}`)
disarm(state)
valid = false
}
@@ -395,10 +385,9 @@ export function apply(ctx: Context): void {
if (attempt !== undefined && sameRound(source, attempt)) {
attempt.stale = true
state.attempt = undefined
cancelReservation(agent, attempt)
}
requestDrive(state)
return { kind: 'block', reason: STALE_ROUND_REASON, discardClaimed: false }
return { kind: 'reject' }
}
return decision
})
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -235,25 +235,28 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(1)
})
it('maps a downstream prompt veto to blocked without admitting the round', async () => {
it('maps a downstream step rejection to blocked without entering the round', async () => {
const test = await harness([])
test.ctx.on('agent/prompt-submit', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal'
? Promise.resolve({ kind: 'block', reason: 'deployment policy', discardClaimed: true })
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal'
? Promise.resolve({ kind: 'reject' as const })
: next())
test.ctx.goals.create(test.agent, { objective: 'respect policy' })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
expect(goal?.roundsStarted).toBe(0)
expect(goal?.blockedReason).toEqual({ code: 'prompt-rejected', message: 'deployment policy' })
expect(goal?.blockedReason).toEqual({
code: 'prompt-rejected',
message: 'Goal round was rejected before entering its step.',
})
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
})
it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => {
const test = await harness([textResponse('human follow-up')])
test.ctx.on('agent/prompt-submit', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal'
? Promise.resolve({ kind: 'block', reason: 'stop this round', discardClaimed: true })
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal'
? Promise.resolve({ kind: 'reject' as const })
: next())
test.ctx.on('goal/changed', (agent, change) => {
if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } }))
@@ -268,7 +271,7 @@ describe('same-session goal driving', () => {
.toEqual([{ type: 'text', text: 'inspect the blocker' }])
})
it('pauses and drops a reserved round when cancellation lands before admission', async () => {
it('pauses and drops a reserved round when cancellation lands before pre-step', async () => {
const test = await harness([])
const cancel = onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind === 'goal') {
@@ -357,7 +360,7 @@ describe('same-session goal driving', () => {
it('rechecks revision after downstream prompt hooks before admitting', async () => {
const test = await harness([textResponse('new revision')])
let edited = false
test.ctx.on('agent/prompt-submit', (agent, messages, _signal, next) => {
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !edited) {
edited = true
const current = test.ctx.goals.get(agent)
@@ -366,7 +369,7 @@ describe('same-session goal driving', () => {
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'edit during admission', maxGoalRounds: 1 })
test.ctx.goals.create(test.agent, { objective: 'edit during pre-step', maxGoalRounds: 1 })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
@@ -464,7 +467,7 @@ describe('same-session goal driving', () => {
// attempt through cancel-requested) and THEN throws: the catch finds no
// matching reservation and must not reschedule a paused goal.
let fired = false
test.ctx.on('agent/prompt-submit', async (agent, messages, _signal, next) => {
test.ctx.on('agent/pre-step', async (agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !fired) {
fired = true
agent.cancel({ kind: 'user' })
@@ -483,15 +486,15 @@ describe('same-session goal driving', () => {
expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused' })
})
it('fails closed when a downstream admission hook throws', async () => {
it('fails closed when a downstream pre-step hook throws', async () => {
const test = await harness([])
// Registered after goal-session's own listener: the throw propagates back
// through goal-session's next() await, dropping the whole admission.
// through goal-session's next() await, dropping the whole step proposal.
let threw = false
test.ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => {
test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !threw) {
threw = true
throw new Error('downstream admission hook exploded')
throw new Error('downstream pre-step hook exploded')
}
return next()
})
@@ -610,20 +613,20 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('fails a pre-admission read closed even when the first disarm attempt throws', async () => {
it('fails an initial pre-step read closed even when the first disarm attempt throws', async () => {
const test = await harness([textResponse('retry after containment')])
let armed = true
onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || message.source.round <= 0 || !armed) return
armed = false
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
throw new Error('admission projection failed')
throw new Error('pre-step projection failed')
})
vi.spyOn(test.ctx.goals, 'disarm').mockImplementationOnce(() => {
throw 'disarm failed'
})
})
test.ctx.goals.create(test.agent, { objective: 'retry stale admission', maxGoalRounds: 1 })
test.ctx.goals.create(test.agent, { objective: 'retry stale pre-step', maxGoalRounds: 1 })
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
@@ -633,7 +636,7 @@ describe('same-session goal driving', () => {
it('fails a post-hook read closed before the prompt can enter history', async () => {
const test = await harness([])
let armed = true
test.ctx.on('agent/prompt-submit', (_agent, messages, _signal, next) => {
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && armed) {
armed = false
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
@@ -705,17 +708,17 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('blocks admission when downstream cancellation clears the reservation', async () => {
it('rejects the step when downstream cancellation clears the reservation', async () => {
const test = await harness([])
let cancelled = false
test.ctx.on('agent/prompt-submit', (agent, messages, _signal, next) => {
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !cancelled) {
cancelled = true
agent.cancel({ kind: 'user' })
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'cancel during admission' })
test.ctx.goals.create(test.agent, { objective: 'cancel during pre-step' })
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused')
await test.agent.whenIdle()
@@ -881,14 +884,12 @@ describe('same-session goal driving', () => {
it('does not re-block a goal the downstream veto already saw cancelled', async () => {
const test = await harness([])
let vetoed = false
test.ctx.on('agent/prompt-submit', (agent, messages, _signal, next) => {
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && !vetoed) {
vetoed = true
agent.cancel({ kind: 'user' })
return Promise.resolve<PromptDecision>({
kind: 'block',
reason: 'cancelled by policy',
discardClaimed: true,
return Promise.resolve<PreStepDecision>({
kind: 'reject',
})
}
return next()
@@ -905,16 +906,16 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('awaits an unadmitted reservation stuck in admission during teardown without cancelling', async () => {
it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => {
const test = await harness([])
let release: (() => void) | undefined
test.ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => {
test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
if (messages[0]?.source.kind === 'goal' && release === undefined) {
await new Promise<void>((resolve) => { release = resolve })
}
return next()
})
test.ctx.goals.create(test.agent, { objective: 'unload during admission' })
test.ctx.goals.create(test.agent, { objective: 'unload during pre-step' })
await vi.waitFor(() => { expect(release).toBeDefined() })
const disposal = Promise.resolve(test.driver.dispose())
+2 -2
View File
@@ -2,5 +2,5 @@
# 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:
# pnpm run verify-translation-pairing --write packages/goal/goal/README.md
README.md: 45d7ac2e1c8fbd60ab2bc8b917b326d20da02ec1
README.zh.md: b657d1d52f75657ba44b99306e4621aa82688e96
README.md: cba28b2de6cb59932f25ad57a32bc25e0d5e287a
README.zh.md: aebb3a1e773038888a109dc040d3389988231953
+1 -1
View File
@@ -21,7 +21,7 @@ Event-sourced same-session goal state. The service retains one current completio
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The model-visible `user/message` content and its typed `{ kind: 'goal', change }` source must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
Every mutation queues a complete versioned snapshot through `agent.inject()`; clear uses a revisioned tombstone. A later entering pre-step records it as a model-visible `user/message`, whose content and typed `{ kind: 'goal', change }` source must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
+1 -1
View File
@@ -21,7 +21,7 @@
最多只有一个当前目标。创建操作会生成 revision 为 1、phase 为 active 的目标并启用续行。未完成的目标必须编辑、转换或清除;已完成目标可以由拥有全局未使用过的 id 的目标替换。编辑会保留 phase、blocker reason 与 activation。暂停、完成、阻塞和清除都会停用续行。阻塞会记录策略自有的 lower-kebab-case 代码和规范化的自由文本说明;提供方限制、配置预算、执行错误与请求人工输入都使用这一种持久 phase,不会扩增生命周期状态。只有配置的 Round 上限仍有剩余容量时,resume 才接受已停止 phase 或 phase 为 active 但已停用续行的目标;它会清除原 blocker reason。phase 为 active 且已启用续行的目标会拒绝冗余操作。
每次非 clear 变更都会通过 `agent.inject()` 追加完整的版本化快照;clear 则追加带 revision 的 tombstone。模型可见的 `user/message` 内容与其带类型的 `{ kind: 'goal', change }` 来源必须完全一致。回放会拒绝形状错误、来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的 Goal Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。
每次变更都会通过 `agent.inject()` 把完整的版本化快照排队;clear 使用带 revision 的 tombstone。后续返回 enter 的 pre-step 会把它记录为模型可见的 `user/message`,其内容与带类型的 `{ kind: 'goal', change }` 来源必须完全一致。回放会拒绝形状错误、来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的 Goal Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。
注入可以立即追加,也可能在活跃工具批次 FIFO 中等待。服务会在内存中叠加已接受的待处理变更,并在每个完全一致的载荷进入日志时逐一完成对账,因此连续的模型工具变更可以看到自身最新 revision,而不会把尚未记录的缓存当作持久状态。可重入追加观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处。追加或入队成功后才触发 `goal/changed`;监听器失败会被隔离处理。
+1 -1
View File
@@ -560,7 +560,7 @@ export class GoalService extends Service {
return view
}
/** Accept one mutation into the agent log/FIFO, cache, and live event stream. */
/** Accept one mutation into the agent injection queue, cache, and live event stream. */
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
const ref = goalChangeRef(change)
const pending: PendingGoalChange = { change, activation, applied: false }
+2 -2
View File
@@ -24,7 +24,7 @@ interface StubAgent {
drain(): void
}
/** Number the next balanced one-shot injection turn. */
/** Number the next balanced test-fixture turn. */
function nextTurn(session: Session): number {
return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1
}
@@ -44,7 +44,7 @@ function stubAgentForSession(session: Session): StubAgent {
id,
options: {},
session,
inbox: new Inbox(session),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
ctx: new Context(),
get status() { return status },
send: () => {},
+1 -1
View File
@@ -35,7 +35,7 @@ function liveAgent(ctx: Context, session: Session): Agent {
id: session.id,
options: {},
session,
inbox: new Inbox(session),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
ctx,
get status() { return status },
send: () => {},
@@ -29,7 +29,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
id: session.id,
options: {},
session,
inbox: new Inbox(session),
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
get status() { return status },
ctx: new Context(),
send: () => {},