feat: durable command lifecycle logging in the executor (command/run + command/done)

CommandService.execute appends the log-only pair around every resolved
handler — run before invocation, done at settlement, including thrown and
aborted handlers (kind:'error'); admission misses log nothing. commandId is
minted monotonically per instance; per-session appends serialize through a
tail queue over SessionStore.appendOutOfBand (zero-step wrap on an idle log,
direct join inside an open turn). The invariant companion now asserts the
pairing relation (unique run ids; a done requires a prior in-log run).
CommandSource is a minimal merge-extensible map (user variant only).
Dependent benches mount SessionStore; TUI/e2e snapshots re-recorded for the
executor's durable-append timing and the /status event counts.
This commit is contained in:
imccyu
2026-07-27 17:37:41 +08:00
parent e900ebd4c6
commit 4e50369eb6
15 changed files with 384 additions and 110 deletions

View File

@@ -6,7 +6,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import GoalService from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
import { Session, SessionId, type UserMessageData } from '@deepseek-ai/dsh-session'
import SessionStore, { Session, SessionId, type UserMessageData } from '@deepseek-ai/dsh-session'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
interface Harness {
@@ -22,8 +22,9 @@ function appendInjection(session: Session, input: UserMessageData): void {
}
/** Build a live idle agent accepted by the exact-identity goal service. */
function stubAgent(id: string): { agent: Agent; session: Session } {
const session = new Session(SessionId(id))
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
// Store-created: the command executor durably logs lifecycle events on it.
const session = ctx.sessions.create(SessionId(id))
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
@@ -45,15 +46,31 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
/** Mount the real command registry, goal domain, and producer. */
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(GoalService)
const plugin = await ctx.plugin(commandGoal)
const { agent, session } = stubAgent(`command-goal-${Math.random()}`)
const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`)
ctx.agents.register(agent)
return { ctx, agent, session, plugin }
}
/** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */
function domainEvents(session: Session): readonly Session['events'][number][] {
const lifecycle = new Set<number>()
for (const event of session.events) {
if (event.type !== 'command/run' && event.type !== 'command/done') continue
lifecycle.add(event.seq)
// The zero-step wrap around a lifecycle event is bookkeeping too.
const before = session.events[event.seq - 1]
const after = session.events[event.seq + 1]
if (before?.type === 'turn/start') lifecycle.add(before.seq)
if (after?.type === 'turn/end') lifecycle.add(after.seq)
}
return session.events.filter(event => !lifecycle.has(event.seq))
}
/** Execute `/goal` through the same registry boundary as a UI adapter. */
async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>> {
const result = await test.ctx.commands.execute(
@@ -98,7 +115,7 @@ describe('/goal human command', () => {
kind: 'success',
text: 'No goal is currently set.\nUsage: /goal [<objective>|clear|edit <objective>|pause|resume]',
})
expect(test.session.events).toEqual([])
expect(domainEvents(test.session)).toEqual([])
})
it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => {
@@ -110,14 +127,14 @@ describe('/goal human command', () => {
expect(created.text).toContain('Rounds: 0/256')
expect(created.text).toContain('Activation: armed')
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
expect(test.session.events.map(event => event.type)).toEqual(['user/message'])
expect(domainEvents(test.session).map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
const count = test.session.events.length
const count = domainEvents(test.session).length
await expect(run(test, ' replacement')).resolves.toEqual({
kind: 'error',
text: 'A goal is already active. Use /goal edit <objective> to change it or /goal clear before replacing it.',
})
expect(test.session.events).toHaveLength(count)
expect(domainEvents(test.session)).toHaveLength(count)
})
it('treats only exact control words as controls', async () => {