Merge branch 'rfc/loop' into codex/goal-domain
# Conflicts: # docs/architecture.md # docs/capability-seams.md # docs/config-catalog.md # docs/event-producer-consumer.md # docs/glossary.md # docs/module-graph.md # examples/package.json # pnpm-lock.yaml # website/.vitepress/config/api-sidebar.json # website/zh-CN/api/harness/events.md
This commit is contained in:
@@ -19,7 +19,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 raw `context/message`, its `{ kind: 'goal' }` source, and its metadata 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 non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata 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.
|
||||
|
||||
|
||||
@@ -325,8 +325,7 @@ export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | un
|
||||
if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) {
|
||||
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
|
||||
}
|
||||
if (event.data.envelope !== 'raw'
|
||||
|| JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) {
|
||||
if (JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) {
|
||||
throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`)
|
||||
}
|
||||
return change
|
||||
|
||||
@@ -482,7 +482,6 @@ export class GoalService extends Service {
|
||||
try {
|
||||
agent.inject(renderGoalChange(change), {
|
||||
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { GoalChangeMeta } from './types.ts'
|
||||
/**
|
||||
* Render a complete goal snapshot or clear tombstone without hidden prose.
|
||||
* @param change - durable goal change metadata.
|
||||
* @returns the single raw context block logged for model reconstruction.
|
||||
* @returns the single context block logged and projected verbatim for model reconstruction.
|
||||
*/
|
||||
export function renderGoalChange(change: GoalChangeMeta): ContentBlock[] {
|
||||
const payload = change.operation === 'clear'
|
||||
|
||||
@@ -117,7 +117,6 @@ describe('goal domain through a real cordis.yml and stdio process', () => {
|
||||
maxGoalRounds: 7,
|
||||
},
|
||||
})
|
||||
expect(context.data.envelope).toBe('raw')
|
||||
expect(context.data.content).toEqual(renderGoalChange(change))
|
||||
expect(JSON.stringify(context)).not.toContain('activation')
|
||||
expect(events.filter(event => event.type === 'user/message'
|
||||
|
||||
@@ -38,7 +38,6 @@ function appendInjection(session: Session, content: ContentBlock[], options?: In
|
||||
const context = {
|
||||
content,
|
||||
source,
|
||||
...options?.envelope === undefined ? {} : { envelope: options.envelope },
|
||||
...options?.meta === undefined ? {} : { meta: options.meta },
|
||||
}
|
||||
const last = session.events.at(-1)
|
||||
@@ -111,7 +110,7 @@ function appendRound(session: Session, ref: GoalRef, round: number): void {
|
||||
}
|
||||
|
||||
describe('GoalService creation and replay', () => {
|
||||
it('applies the configured default and writes one balanced raw context snapshot', async () => {
|
||||
it('applies the configured default and writes one balanced verbatim context snapshot', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_700_000_000_000)
|
||||
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
|
||||
@@ -136,7 +135,6 @@ describe('GoalService creation and replay', () => {
|
||||
const context = session.events[1]
|
||||
expect(context?.type).toBe('context/message')
|
||||
if (context?.type !== 'context/message') throw new Error('expected goal context')
|
||||
expect(context.data.envelope).toBe('raw')
|
||||
expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
|
||||
const change = decodeGoalChange(context.data.meta)
|
||||
if (change === undefined) throw new Error('expected decoded goal change')
|
||||
@@ -506,7 +504,7 @@ describe('GoalService mutations', () => {
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
content: renderGoalChange(change), source, envelope: 'raw', meta: change as never,
|
||||
content: renderGoalChange(change), source, meta: change as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
|
||||
@@ -537,12 +535,10 @@ describe('GoalService mutations', () => {
|
||||
}
|
||||
appendInjection(session, renderGoalChange(change), {
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 },
|
||||
envelope: 'raw',
|
||||
meta: change as never,
|
||||
})
|
||||
appendInjection(session, [{ type: 'text', text: 'corrupt' }], {
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 },
|
||||
envelope: 'raw',
|
||||
meta: { ...change, operation: 'edit', extra: true } as never,
|
||||
})
|
||||
|
||||
@@ -574,7 +570,7 @@ describe('goal replay validation', () => {
|
||||
function appendChange(
|
||||
session: Session,
|
||||
change: GoalChangeMeta,
|
||||
overrides: { content?: ContentBlock[]; source?: MessageSource; envelope?: 'raw' } = {},
|
||||
overrides: { content?: ContentBlock[]; source?: MessageSource } = {},
|
||||
): void {
|
||||
const source = overrides.source ?? {
|
||||
kind: 'goal',
|
||||
@@ -587,13 +583,12 @@ describe('goal replay validation', () => {
|
||||
session.append('context/message', {
|
||||
content: overrides.content ?? renderGoalChange(change),
|
||||
source,
|
||||
envelope: overrides.envelope ?? 'raw',
|
||||
meta: change as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource; envelope?: 'raw' } = {}) {
|
||||
function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource } = {}) {
|
||||
const session = new Session(SessionId(`validation-${Math.random()}`))
|
||||
appendChange(session, change, overrides)
|
||||
return session.events
|
||||
@@ -783,7 +778,7 @@ describe('goal replay validation', () => {
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'missing' }], source, envelope: 'raw',
|
||||
content: [{ type: 'text', text: 'missing' }], source,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
expect(() => foldGoal(session.events)).toThrow('lacks goal change metadata')
|
||||
@@ -822,21 +817,13 @@ describe('goal replay validation', () => {
|
||||
})).toThrow('positive safe integer')
|
||||
})
|
||||
|
||||
it('rejects source, content, and envelope drift from the durable metadata', () => {
|
||||
it('rejects source and content drift from the durable metadata', () => {
|
||||
const change = snapshotChange()
|
||||
expect(() => foldGoal(oneChange(change, { source: { kind: 'plugin', plugin: 'wrong' } }))).toThrow('mismatched source')
|
||||
expect(() => foldGoal(oneChange(change, {
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: -1 },
|
||||
}))).toThrow('source is invalid')
|
||||
expect(() => foldGoal(oneChange(change, { content: [{ type: 'text', text: 'wrong' }] }))).toThrow('model-visible content')
|
||||
const events = oneChange(change)
|
||||
const context = events.find(event => event.type === 'context/message')
|
||||
if (context?.type !== 'context/message') throw new Error('expected context')
|
||||
const altered = structuredClone(events)
|
||||
const clonedContext = altered.find(event => event.type === 'context/message')
|
||||
if (clonedContext?.type !== 'context/message') throw new Error('expected cloned context')
|
||||
delete (clonedContext.data as { envelope?: string }).envelope
|
||||
expect(() => foldGoal(altered)).toThrow('model-visible content')
|
||||
})
|
||||
|
||||
it('folds a clear tombstone after a snapshot', () => {
|
||||
@@ -853,7 +840,7 @@ describe('goal replay validation', () => {
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('context/message', {
|
||||
content: renderGoalChange(clear), source, envelope: 'raw', meta: clear as never,
|
||||
content: renderGoalChange(clear), source, meta: clear as never,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
expect(foldGoal(session.events)).toEqual({
|
||||
|
||||
Reference in New Issue
Block a user