refactor(schedule): make absolute times explicit

This commit is contained in:
Tianyi Cui
2026-08-09 16:30:11 +08:00
parent 3d6498e91b
commit b7ec8429a9
109 changed files with 1248 additions and 3219 deletions

View File

@@ -12,9 +12,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, optional `timeZone`, `seedLength`, `origin`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches an awaited parallel checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; observe-only listeners return void, while a persistence listener returns literal `true` only after completing durability work. A fully successful checkpoint with at least one such acknowledgement returns `true` and emits contained `session/flushed(session, throughSeq)` with the exclusive event boundary captured at entry; no durability acknowledgement returns `false`, and unpublished, detached, or stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary.
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome.
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -43,7 +42,7 @@ Plain class (not a Cordis Service). Create live sessions through `ctx.sessions.c
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
- `session.seq`, `session.id` — current sequence and readonly typed identity.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`timeZone`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
### Lossless JSON utilities
@@ -84,7 +83,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Metadata types (`types.ts`)
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, timeZone?, parentSession?, seedLength?, delegationDepth? }`. The optional `timeZone` is an opaque caller-validated string: session core checks only its stored shape and preserves it verbatim through reconstruction and fork. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
### Extension points

View File

@@ -12,9 +12,8 @@
### 公共 API
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、可选的 `timeZone`、`seedLength`、`origin` 和 `delegationDepth`。
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行检查点。每个监听器都会启动,调用会等待全部结算后才报告失败;仅观察的监听器返回 void,持久化监听器只有在完成持久化工作后才返回字面量 `true`。全部成功且至少有一个此类确认时,调用返回 `true`,并发布受包含的 `session/flushed(session, throughSeq)`,其中 `throughSeq` 是入口处捕获的事件排他边界;没有持久化确认时返回 `false`,未发布、已脱离或陈旧对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -43,7 +42,7 @@
- `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。
- `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。
- `session.seq`、`session.id`:当前序号和只读类型化身份。
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`timeZone`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
### 无损 JSON 工具
@@ -84,7 +83,7 @@
### 元数据类型(`types.ts`)
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, timeZone?, parentSession?, seedLength?, delegationDepth? }`。可选的 `timeZone` 是由调用方校验的不透明字符串:会话核心仅检查其存储形状,并在重建和 fork 过程中原样保留。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
### 扩展点

View File

@@ -135,9 +135,6 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
}
}
if (record.timeZone !== undefined && typeof record.timeZone !== 'string') {
throw new Error('session header timeZone must be a string')
}
if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
throw new Error('session header parentSession must be a string')
}
@@ -451,8 +448,8 @@ export class Session {
}
/**
* Detached, deep-frozen creation metadata (format version, cwd, time zone,
* lineage, seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
* `Session` is created without a store-owned header, a minimal header is
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* `session.header` is always present. Kept out of the event log — it is a
@@ -828,8 +825,8 @@ export class SessionStore extends Service {
* Create a session owned by the calling fiber: disposing that fiber stops
* event notification and removes the session from the store. `options.seed`
* populates the session with a copy of those events (replay/fork);
* `options.meta` attaches creation metadata (validated absolute `cwd`, opaque
* time-zone string, seed and parent lineage, and delegation depth) as the immutable
* `options.meta` attaches creation metadata (validated absolute `cwd`, seed
* and parent lineage, and delegation depth) as the immutable
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
@@ -897,7 +894,6 @@ export class SessionStore extends Service {
id: sessionId,
createdAt: meta?.createdAt ?? Date.now(),
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
...meta?.timeZone === undefined ? {} : { timeZone: meta.timeZone },
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
...meta?.origin === undefined ? {} : { origin: meta.origin },
@@ -1106,7 +1102,6 @@ export class SessionStore extends Service {
seed,
meta: {
...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
...liveSource.header.timeZone !== undefined ? { timeZone: liveSource.header.timeZone } : {},
parentSession: liveSource.id,
seedLength: seed.length,
},

View File

@@ -51,11 +51,6 @@ export interface SessionHeader {
readonly createdAt: number
/** Absolute working directory the session was created in (if any). */
readonly cwd?: string
/**
* Optional caller-validated time-zone identifier captured at creation.
* Session core preserves the exact string without interpreting or canonicalizing it.
*/
readonly timeZone?: string
/** The session this one was forked from (seed lineage), if any. */
readonly parentSession?: SessionId
/**
@@ -90,8 +85,6 @@ export interface CreateSessionOptions {
*/
readonly meta?: {
readonly cwd?: string
/** Caller-validated time-zone identifier to preserve verbatim in the header. */
readonly timeZone?: string
readonly parentSession?: SessionId
readonly createdAt?: number
readonly seedLength?: number

View File

@@ -63,9 +63,7 @@ function inherited(session: Session): readonly SessionEvent[] {
describe('SessionStore.fork', () => {
it('forks an empty live session as an empty child with lineage metadata', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('empty-parent'), {
meta: { cwd: '/workspace', timeZone: 'Asia/Shanghai' },
})
const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
const child = sessions.fork(source, undefined, SessionId('empty-child'))
@@ -73,21 +71,11 @@ describe('SessionStore.fork', () => {
expect(child.header).toMatchObject({
id: SessionId('empty-child'),
cwd: '/workspace',
timeZone: 'Asia/Shanghai',
parentSession: SessionId('empty-parent'),
seedLength: 0,
})
})
it('keeps a headerless fork headerless', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('headerless-parent'), { meta: { cwd: '/workspace' } })
const child = sessions.fork(source, undefined, SessionId('headerless-child'))
expect(child.header.timeZone).toBeUndefined()
})
it('forks the latest completed boundary by default into detached frozen seed events', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })

View File

@@ -997,7 +997,6 @@ describe('Session', () => {
id: SessionId('header-owned'),
createdAt: 123,
cwd: '/accepted',
timeZone: 'Caller/Canonical',
parentSession: SessionId('parent'),
seedLength: 2,
}
@@ -1010,7 +1009,6 @@ describe('Session', () => {
id: 'header-owned',
createdAt: 123,
cwd: '/accepted',
timeZone: 'Caller/Canonical',
parentSession: 'parent',
seedLength: 2,
})
@@ -1065,7 +1063,6 @@ describe('Session', () => {
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ },
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
{ header: { ...base, timeZone: 1 }, error: /header timeZone must be a string/ },
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
{ header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
@@ -1276,17 +1273,16 @@ describe('SessionStore', () => {
expect(session.header.parentSession).toBeUndefined()
})
it('attaches cwd, timeZone, and parentSession from meta to the header', async () => {
it('attaches cwd and parentSession from meta to the header', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('child'), {
meta: { cwd: '/work/project', timeZone: 'Asia/Shanghai', parentSession: SessionId('parent') },
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
})
expect(session.header).toMatchObject({
version: SESSION_FORMAT_VERSION,
id: 'child',
cwd: '/work/project',
timeZone: 'Asia/Shanghai',
parentSession: 'parent',
})
})
@@ -1311,7 +1307,6 @@ describe('SessionStore', () => {
const cases: Array<{ meta: unknown; error: RegExp }> = [
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
{ meta: { timeZone: 1 }, error: /header timeZone must be a string/ },
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ },