fix(time-context): close request-boundary gaps

This commit is contained in:
pku-xht
2026-08-07 21:55:18 +08:00
committed by Tianyi Cui
parent d8a85dcafd
commit 331b29d779
9 changed files with 78 additions and 5 deletions

View File

@@ -22,7 +22,7 @@ When a Session has `SessionHeader.timeZone`, that immutable IANA zone formats it
The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a non-empty message batch, time-context derives client zones from those final messages plus user-rpc messages already entered in the open turn, then appends one reading to that decision. Schedule later derives the same facts directly from the immutable Session header and those durable user-rpc sources; the reading is not a second machine authority.
An entering non-empty batch records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the simple marker `{ kind: 'plugin', plugin: 'time-context' }`; the Session header and original user-rpc sources remain the only machine-readable zone owners. A decision rewritten to empty never gains a reading: it opens no initial step, and an empty tool continuation may still enter a later step using existing history.
An entering non-empty batch records its downstream messages followed by exactly one time-context `UserMessage` after `step/start`. Its source is the exact snapshot marker `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: <same rendered text> }] }`; the invariant companion and Schedule consumer both fail closed if that shape or text equality drifts. The Session header and original user-rpc sources remain the only machine-readable zone owners. A decision rewritten to empty never gains a reading: it opens no initial step, and an empty tool continuation may still enter a later step using existing history.
Reject, cancellation, and listener failure before `step/start` add no reading. A plugin disposal that wins while the listener awaits downstream work also prevents the in-flight listener from contributing. Steering inserted after AgentLoop has claimed the current batch retains ordinary next-step ownership and receives fresh context when that later step enters; time-context adds no inbox state or AgentLoop lifecycle path.

View File

@@ -25,24 +25,33 @@ export const inject = ['invariants']
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
let openTurn: number | undefined
let openStep: number | undefined
let requestStarted = false
for (const event of history) {
switch (event.type) {
case 'turn/start': {
openTurn = event.data.turn
openStep = undefined
requestStarted = false
break
}
case 'step/start': {
openStep = event.data.step
requestStarted = false
break
}
case 'request/header': {
requestStarted = true
break
}
case 'step/end': {
openStep = undefined
requestStarted = false
break
}
case 'turn/end': {
openTurn = undefined
openStep = undefined
requestStarted = false
break
}
default:
@@ -51,6 +60,7 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa
}
if (openTurn === undefined) fail('time-context reading must be appended inside an open turn')
if (openStep === undefined) fail('time-context reading must follow step/start')
if (requestStarted) fail('time-context reading must precede request/header')
return { turn: openTurn, step: openStep }
}

View File

@@ -102,6 +102,18 @@ describe('time-context invariants', () => {
}).not.toThrow()
})
it('rejects a reading appended after request execution starts', async () => {
const ctx = await setup()
const session = preparing(1, 1)
session.append('request/header', {
header: { config: { provider: 'mock', model: 'mock' } },
reason: 'initial',
})
expect(() => {
ctx.emit('session/event', session, event(reading()))
}).toThrow(/must precede request\/header/)
})
it('derives Session and client zones from their original durable owners', async () => {
const ctx = await setup()
const id = SessionId('time-invariant-zones')