diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md index d6456010e3..6c80158824 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -18,9 +18,9 @@ Local calendar work also needs to distinguish two owned facts: the immutable zon The plugin prepends an `agent/pre-step` listener and delegates first. When the downstream decision enters a request step and a reading is due, time-context derives client zones from that decision's final messages plus user-rpc messages already entered in the open turn, then appends one reading to the decision. Steering inserted after AgentLoop claims the current batch keeps ordinary next-step ownership and receives a new reading when that step enters. -An entering step appends its returned messages followed by the time reading after `step/start`, before request derivation. A first-step decision rewritten to empty opens no request, while an empty tool continuation can still enter a later step and receive a reading. Rejection, failure, or cancellation before `step/start` appends nothing. Disposal prevents an in-flight listener from contributing after it wins, without adding inbox state or an AgentLoop lifecycle path. +An entering step appends its returned messages followed by the time reading after `step/start`, before request derivation. A first-step decision rewritten to empty opens no request, while an empty tool continuation can still enter a later step without a new reading and reuse existing history. Rejection, failure, or cancellation before `step/start` appends nothing. Disposal prevents an in-flight listener from contributing after it wins, without adding inbox state or an AgentLoop lifecycle path. -Each reading has the simple source `{ kind: 'plugin', plugin: 'time-context' }`. The immutable `SessionHeader.timeZone` and each original user-rpc message's `clientTimeZone` remain the only machine-readable owners. Time-context renders those facts for the model, while Schedule derives directly from the same header and current-turn sources instead of consuming a copy. The rendered clock uses the Session zone when available. A headerless Session uses the configured fallback, or the Node process zone resolved once at plugin load when config is omitted, while still reporting the Session zone as `unavailable`. Every explicit or Session-owned IANA zone is validated through `Intl.DateTimeFormat`. +Each reading has the exact snapshot source `{ kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections: [{ name: 'time-context', text: }] }`; both the invariant companion and Schedule fail closed if that shape or equality drifts. The immutable `SessionHeader.timeZone` and each original user-rpc message's `clientTimeZone` remain the only machine-readable owners. Time-context renders those facts for the model, while Schedule derives directly from the same header and current-turn sources instead of consuming a copy. The rendered clock uses the Session zone when available. A headerless Session uses the configured fallback, or the Node process zone resolved once at plugin load when config is omitted, while still reporting the Session zone as `unavailable`. Every explicit or Session-owned IANA zone is validated through `Intl.DateTimeFormat`. The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every entered request step. A positive value scans the raw session events for the most recent `user/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache. diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index d49c0b0338..063f27d5ba 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -37,6 +37,8 @@ Every successful management preflight also asks the live owner to recompute. Thi The official Web create path requires the browser's IANA zone, validates and canonicalizes it at the Host boundary, and stores it once as immutable `SessionHeader.timeZone`. Resume preserves that value, fork copies it, and another create for the same id and cwd conflicts when its canonical zone differs. Session core keeps the field optional so pre-zone Sessions remain readable but explicitly `unavailable`; a legacy header is never backfilled from a later browser request. JSONL preserves the optional header, while SQLite schema v14 adds nullable `time_zone` and upgrades an owned v13 database atomically without guessing values for existing rows. +That exact v13-to-v14 transaction is a narrow planned exception to the pre-release default of rejecting old storage formats: valid headerless Session databases can exist before time-zone metadata is introduced. It accepts only the owned v13 layout, rejects older, newer, or spoofed schemas without mutation, and does not establish a general migration framework. + Every Web prompt samples its own `clientTimeZone`, which the Host validates before Agent entry and binds to that immutable `user-rpc` message source. This is request provenance, not a mutable property of the connection or Session, so concurrent tabs cannot overwrite one another and queue, steering, edit, retry, and persisted history retain the originating zone. Time-context delegates through `agent/pre-step`, derives the final non-empty entered batch's zones from the immutable Session header and message-bound browser sources, and appends one model-visible reading to that batch. Its source remains the simple plugin marker; it does not copy those facts into another durable authority. Steering inserted after AgentLoop claims the current batch keeps ordinary next-step ownership and receives fresh context when that step enters. Rejection, an empty decision, cancellation, or failure before `step/start` records no reading, and this feature adds no inbox or AgentLoop lifecycle state. diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index a51f547317..b8128d8d8e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -37,6 +37,8 @@ Status: implemented 官方 Web create 路径要求浏览器提供 IANA 时区,在 Host 边界校验并规范化后,将其一次性存为不可变的 `SessionHeader.timeZone`。resume 保留该值,fork 复制该值;若针对相同 id 与 cwd 的另一次 create 得到的规范化时区不同,则发生冲突。Session core 保持该字段可选,使时区支持前的 Session 仍可读取,但其时区明确为 `unavailable`;绝不会用后续浏览器请求回填 legacy header。JSONL 保留该可选 header;SQLite schema v14 增加 nullable `time_zone`,并以原子方式升级自有 v13 数据库,不为既有行猜测值。 +这笔精确的 v13 到 v14 事务,是对“预发布阶段默认拒绝旧存储格式”立场的一项窄幅、已规划例外:在引入时区 metadata 前,可能已经存在有效的无时区 Session 数据库。它只接受自有 v13 布局;更旧、更新或伪造的 schema 都会在不修改数据的前提下被拒绝,而且不会建立通用迁移框架。 + 每条 Web 提示词都会单独采样自己的 `clientTimeZone`;Host 在进入 Agent 前校验该值,并把它绑定到不可变的 `user-rpc` 消息来源。它是请求 provenance,而不是连接或 Session 的可变属性,因此并发 tab 无法相互覆盖,排队、steering(中途引导)、编辑、重试和持久化 history 都会保留来源时区。 Time-context 会委托 `agent/pre-step`,从不可变 Session header 和与消息绑定的浏览器来源为最终进入的非空批次派生时区,再向该批次追加一条模型可见读数。其来源仍是简单插件标记,不会把这些事实复制成另一份持久权威。AgentLoop 领取当前批次后才插入的 steering(中途引导)保留常规 next-step 归属,并在该步骤进入时获得新上下文。`step/start` 之前出现 reject、空决策、取消或失败时,不会记录读数;本功能也不增加 inbox 或 AgentLoop 生命周期状态。 diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index e430cf6350..c07ab9b113 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -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: }] }`; 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. diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 75d26269a2..26259f6495 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -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 } } diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 0658ecf704..9ea928c192 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -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') diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index c1b1680430..3ee9b5f950 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -95,7 +95,7 @@ export const sessionSearchValueSchema = z.object({ hasMore: z.boolean(), }) satisfies z.ZodType>> -/** session.create request payload (at most one of workspaceId / cwd). */ +/** session.create payload; timeZone stays schema-optional so Host omission returns `invalid-time-zone`. */ export const sessionCreateRequestSchema = z.object({ workspaceId: workspaceIdSchema.optional(), cwd: z.string().optional(), @@ -247,7 +247,7 @@ export const sessionSelectModelValueSchema = z.object({ /** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */ export const contentBlockSchema = z.looseObject({ type: z.string() }) -/** session.prompt request payload. */ +/** session.prompt payload; clientTimeZone stays schema-optional so Host omission returns `invalid-time-zone`. */ export const sessionPromptRequestSchema = z.object({ sessionId: sessionIdSchema, mode: z.union([z.literal('queue'), z.literal('steer')]), diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 7c59d69c82..01fb3f10c4 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -215,6 +215,7 @@ export interface SessionsApi { workspaceId?: WorkspaceId cwd?: string sessionId?: SessionId + /** Required by the Host; optional here so omission returns the stable `invalid-time-zone` RPC error. */ timeZone?: string }>): Promise> @@ -300,6 +301,7 @@ export interface SessionsApi { sessionId: SessionId mode: 'queue' | 'steer' content: ContentBlock[] + /** Required by the Host; optional here so omission returns the stable `invalid-time-zone` RPC error. */ clientTimeZone?: string }>): Promise> diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index a386e362ce..28e0d15a78 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -549,6 +549,51 @@ describe('cold Session zone identity', () => { }) expect(resume).not.toHaveBeenCalled() }) + + it.each([ + ['a missing zone', undefined, null], + ['an invalid zone', 'CST', 'CST'], + ] as const)('rejects %s before resuming a cold Session', async (_case, clientTimeZone, detailValue) => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('session-cold-prompt-zone') + const meta = header('session-cold-prompt-zone', 1000, { timeZone: 'UTC' }) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), + locate: () => undefined, + } as never) + const resume = vi.spyOn(ctx.agents, 'resume') + const api = createApiProxy(ctx, { + defaultTarget: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + const promptRequest = request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'rejected before resume' }], + clientTimeZone: clientTimeZone ?? 'UTC', + }) + if (clientTimeZone === undefined) { + delete (promptRequest.payload as { clientTimeZone?: string }).clientTimeZone + } + const response = await api.sessions.prompt(promptRequest) + + expect(response.result).toMatchObject({ + ok: false, + error: { + code: 'invalid-time-zone', + details: { field: 'clientTimeZone', value: detailValue }, + }, + }) + expect(resume).not.toHaveBeenCalled() + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) }) describe('sessions.prompt synchronous rejection', () => {