fix(session): address restore review feedback

This commit is contained in:
imccyu
2026-08-06 03:59:01 +08:00
parent 2551b757fb
commit e89b1e612f
11 changed files with 69 additions and 36 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md
2026-08-05-large-session-jsonl-restore-pipeline.md: a99fca8cfa1b35f40b82778b2f2462b318348722
2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 73d2868700a0543d29fbcc16e913dda24defd37a
2026-08-05-large-session-jsonl-restore-pipeline.md: 82ef45ab91408a96b6ce831a28187fe50506dfdd
2026-08-05-large-session-jsonl-restore-pipeline.zh.md: b7514df34ffeb45276990911bb2a18960b6b9303

View File

@@ -30,7 +30,7 @@ The scanner stops retaining events at the first unparsable row or sequence gap b
### Restore admission
Persistence transfers freshly materialized JSON values to `Session.fromRestore`. These values are detached, acyclic trees, and packed chunk rows expand into newly allocated events, so the restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and recursively freezes the owned graph without a cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice.
Persistence transfers freshly materialized JSON values to `Session.fromRestore`. These values are detached, acyclic trees, and packed chunk rows expand into newly allocated events, so the restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice.
Borrowed seeds used by ordinary creation and fork paths still take a JSON snapshot and use the generic cycle-safe deep freeze. The specialization therefore changes only durable restoration; it does not weaken acceptance for caller-owned values.

View File

@@ -30,7 +30,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解
### 恢复准入
持久化层把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in``switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并在不使用循环跟踪集合的情况下递归冻结所拥有的对象图`surface` 校验会记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次。
持久化层把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in``switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并通过显式 `pending` 数组迭代冻结所拥有的对象图,不使用循环跟踪集合`surface` 校验会记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次。
普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照,并使用支持循环检测的通用深度冻结。因此,这项特化仅改变持久恢复,不会放宽调用方所有值的准入要求。

View File

@@ -1143,7 +1143,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:53`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:58`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
## `@deepseek-ai/dsh-session-persistence-sqlite`

View File

@@ -1707,7 +1707,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
Source: [`packages/core/session/src/index.ts:831`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:837`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`

View File

@@ -203,12 +203,18 @@ export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
return adoptSessionEvent(structuredClone(event))
}
/** Deep-freeze one acyclic object tree materialized by JSON parsing. input is stackoverflow-safe */
function freezeRestoredObject<T>(value: T): T {
Object.freeze(value)
for (const key in value) {
const child = (value as Record<string, unknown>)[key]
if (child !== null && typeof child === 'object') freezeRestoredObject(child)
/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
function freezeRestoredObject<T extends object>(value: T): T {
const pending: object[] = [value]
while (pending.length > 0) {
// The non-empty check proves an object remains to visit.
// oxlint-disable-next-line typescript/no-non-null-assertion
const current = pending.pop()!
Object.freeze(current)
for (const key in current) {
const child = (current as Record<string, unknown>)[key]
if (child !== null && typeof child === 'object') pending.push(child)
}
}
return value
}

View File

@@ -941,6 +941,36 @@ describe('Session', () => {
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
})
it('iteratively freezes deeply nested restored event data', () => {
const depth = 20_000
const data: Record<string, unknown> = {}
let tail = data
for (let index = 0; index < depth; index += 1) {
const child: Record<string, unknown> = {}
tail['child'] = child
tail = child
}
const event = {
type: 'test/deep-restore', seq: 0, time: 1, data,
} as unknown as SessionEvent
expect(() => Session.fromRestore(SessionId('deep-restore'), [event], {
version: SESSION_FORMAT_VERSION,
id: SessionId('deep-restore'),
createdAt: 1,
})).not.toThrow()
let current: unknown = event
let frozenNodes = 0
for (let index = 0; index <= depth + 1; index += 1) {
if (!Object.isFrozen(current)) break
frozenNodes += 1
current = (current as Record<string, unknown>)['data']
?? (current as Record<string, unknown>)['child']
}
expect(frozenNodes).toBe(depth + 2)
})
it('returns cached frozen event-array snapshots that do not grow after append', () => {
const session = Session.create(SessionId('events-snapshot'))
session.append('turn/start', { turn: 1 })

View File

@@ -34,7 +34,11 @@ export type { JsonlCompression } from './format.ts'
const DEFAULT_PACK_CHUNKS = true
const DEFAULT_COMPRESSION: JsonlCompression = 'zstd'
/** internal yield interval. */
/**
* Internal scheduling constant, not deployment configuration: balance
* frame-boundary event-loop yields against `setImmediate` overhead. One frame
* remains an indivisible synchronous decode.
*/
const ZSTD_DECODE_YIELD_INTERVAL_MS = 500
/** Assert that the independently decodable first frame contains only the header record. */

View File

@@ -713,7 +713,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
expect(result.events).toEqual([oneTurnLog()[0]])
expect(result.committedBytes).toBe(header.length + event.length + 1)
expect(() =>{ scanner.write(Buffer.from('\n')) }).toThrow(/finished/)
expect(() => { scanner.write(Buffer.from('\n')) }).toThrow(/finished/)
})
it('keeps scanning after a tolerable corrupt suffix until a committed turn end appears', () => {
@@ -728,7 +728,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
expect(scanner.finish().events).toEqual([oneTurnLog()[0]])
const committed = new SessionLogScanner(header)
expect(() =>{ committed.write(Buffer.from([
expect(() => { committed.write(Buffer.from([
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
'',
].join('\n'))) }).toThrow(/seq gap in committed region/)

View File

@@ -1,8 +1,9 @@
/**
* Read-only interpretation of session-query lineage as durable subagent
* children. The module owns no catalog state and does not consult Activation,
* Agent-registry, continuation-manager, or provider state. A child's
* descriptor distinguishes one-shot work from a continuable conversation.
* children. Only descendants with durable `origin: 'subagent'` enter per-child
* inspection. The module owns no catalog state and does not consult Activation,
* Agent-registry, continuation-manager, or provider state. A child's descriptor
* distinguishes one-shot work from a continuable conversation.
*
* @module @deepseek-ai/dsh-subagent
*/
@@ -20,12 +21,13 @@ type SessionQueryRuntime = Pick<
>
/**
* One entry of a {@link listChildren} result in trace candidate order. A valid
* descriptor produces a `child`, a per-child inspection failure produces a
* `diagnostic`, and a descriptor-less ordinary child is omitted. Healthy rows
* include a one-level, origin-classified descendant hint. Diagnostics are
* transient query results, never session events or catalog state, and never
* expose model-hidden descriptor content.
* One entry of a {@link listChildren} result in trace candidate order. Only a
* candidate whose durable header has `origin: 'subagent'` is inspected. A
* valid descriptor produces a `child`, a per-child inspection failure produces
* a `diagnostic`, and a candidate without its own descriptor is omitted.
* Healthy rows include a one-level, origin-classified descendant hint.
* Diagnostics are transient query results, never session events or catalog
* state, and never expose model-hidden descriptor content.
*/
export type SubagentListEntry =
| {
@@ -69,8 +71,9 @@ export type SubagentListEntry =
}
/**
* Interpret one parent's direct session descendants as session-backed subagents
* without loading or resuming an Agent.
* Interpret one parent's origin-classified direct descendants as session-backed
* subagents without loading or resuming an Agent. Ordinary forks are skipped
* before per-child event inspection.
* @see {@link SubagentService.listChildren} for the public cancellation and
* failure contract.
* @param ctx - context carrying the optional session-query service.

View File

@@ -410,16 +410,6 @@ describe('SubagentService.listChildren', () => {
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('maps an invalid child surface to corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'invalid surface')
const query = ctx.get('sessionQuery')!
query.listEvents = () =>
Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
})
it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'shifted log')