fix(session): address restore review feedback
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# 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.md: 82ef45ab91408a96b6ce831a28187fe50506dfdd
|
||||||
2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 73d2868700a0543d29fbcc16e913dda24defd37a
|
2026-08-05-large-session-jsonl-restore-pipeline.zh.md: b7514df34ffeb45276990911bb2a18960b6b9303
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ The scanner stops retaining events at the first unparsable row or sequence gap b
|
|||||||
|
|
||||||
### Restore admission
|
### 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解
|
|||||||
|
|
||||||
### 恢复准入
|
### 恢复准入
|
||||||
|
|
||||||
持久化层把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in` 与 `switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并在不使用循环跟踪集合的情况下递归冻结所拥有的对象图。`surface` 校验会记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次。
|
持久化层把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in` 与 `switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并通过显式 `pending` 数组迭代冻结所拥有的对象图,不使用循环跟踪集合。`surface` 校验会记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次。
|
||||||
|
|
||||||
普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照,并使用支持循环检测的通用深度冻结。因此,这项特化仅改变持久恢复,不会放宽调用方所有值的准入要求。
|
普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照,并使用支持循环检测的通用深度冻结。因此,这项特化仅改变持久恢复,不会放宽调用方所有值的准入要求。
|
||||||
|
|
||||||
|
|||||||
@@ -1143,7 +1143,7 @@ export interface Config {
|
|||||||
export type JsonlCompression = 'zstd' | 'none'
|
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`
|
## `@deepseek-ai/dsh-session-persistence-sqlite`
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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`
|
## `ctx.sessionTitle` — `SessionTitleService`
|
||||||
|
|
||||||
|
|||||||
@@ -203,12 +203,18 @@ export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
|
|||||||
return adoptSessionEvent(structuredClone(event))
|
return adoptSessionEvent(structuredClone(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deep-freeze one acyclic object tree materialized by JSON parsing. input is stackoverflow-safe */
|
/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
|
||||||
function freezeRestoredObject<T>(value: T): T {
|
function freezeRestoredObject<T extends object>(value: T): T {
|
||||||
Object.freeze(value)
|
const pending: object[] = [value]
|
||||||
for (const key in value) {
|
while (pending.length > 0) {
|
||||||
const child = (value as Record<string, unknown>)[key]
|
// The non-empty check proves an object remains to visit.
|
||||||
if (child !== null && typeof child === 'object') freezeRestoredObject(child)
|
// 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
|
return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -941,6 +941,36 @@ describe('Session', () => {
|
|||||||
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
|
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', () => {
|
it('returns cached frozen event-array snapshots that do not grow after append', () => {
|
||||||
const session = Session.create(SessionId('events-snapshot'))
|
const session = Session.create(SessionId('events-snapshot'))
|
||||||
session.append('turn/start', { turn: 1 })
|
session.append('turn/start', { turn: 1 })
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export type { JsonlCompression } from './format.ts'
|
|||||||
|
|
||||||
const DEFAULT_PACK_CHUNKS = true
|
const DEFAULT_PACK_CHUNKS = true
|
||||||
const DEFAULT_COMPRESSION: JsonlCompression = 'zstd'
|
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
|
const ZSTD_DECODE_YIELD_INTERVAL_MS = 500
|
||||||
|
|
||||||
/** Assert that the independently decodable first frame contains only the header record. */
|
/** Assert that the independently decodable first frame contains only the header record. */
|
||||||
|
|||||||
@@ -713,7 +713,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
|||||||
|
|
||||||
expect(result.events).toEqual([oneTurnLog()[0]])
|
expect(result.events).toEqual([oneTurnLog()[0]])
|
||||||
expect(result.committedBytes).toBe(header.length + event.length + 1)
|
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', () => {
|
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]])
|
expect(scanner.finish().events).toEqual([oneTurnLog()[0]])
|
||||||
|
|
||||||
const committed = new SessionLogScanner(header)
|
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' } } }),
|
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||||
'',
|
'',
|
||||||
].join('\n'))) }).toThrow(/seq gap in committed region/)
|
].join('\n'))) }).toThrow(/seq gap in committed region/)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Read-only interpretation of session-query lineage as durable subagent
|
* Read-only interpretation of session-query lineage as durable subagent
|
||||||
* children. The module owns no catalog state and does not consult Activation,
|
* children. Only descendants with durable `origin: 'subagent'` enter per-child
|
||||||
* Agent-registry, continuation-manager, or provider state. A child's
|
* inspection. The module owns no catalog state and does not consult Activation,
|
||||||
* descriptor distinguishes one-shot work from a continuable conversation.
|
* Agent-registry, continuation-manager, or provider state. A child's descriptor
|
||||||
|
* distinguishes one-shot work from a continuable conversation.
|
||||||
*
|
*
|
||||||
* @module @deepseek-ai/dsh-subagent
|
* @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
|
* One entry of a {@link listChildren} result in trace candidate order. Only a
|
||||||
* descriptor produces a `child`, a per-child inspection failure produces a
|
* candidate whose durable header has `origin: 'subagent'` is inspected. A
|
||||||
* `diagnostic`, and a descriptor-less ordinary child is omitted. Healthy rows
|
* valid descriptor produces a `child`, a per-child inspection failure produces
|
||||||
* include a one-level, origin-classified descendant hint. Diagnostics are
|
* a `diagnostic`, and a candidate without its own descriptor is omitted.
|
||||||
* transient query results, never session events or catalog state, and never
|
* Healthy rows include a one-level, origin-classified descendant hint.
|
||||||
* expose model-hidden descriptor content.
|
* Diagnostics are transient query results, never session events or catalog
|
||||||
|
* state, and never expose model-hidden descriptor content.
|
||||||
*/
|
*/
|
||||||
export type SubagentListEntry =
|
export type SubagentListEntry =
|
||||||
| {
|
| {
|
||||||
@@ -69,8 +71,9 @@ export type SubagentListEntry =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interpret one parent's direct session descendants as session-backed subagents
|
* Interpret one parent's origin-classified direct descendants as session-backed
|
||||||
* without loading or resuming an Agent.
|
* 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
|
* @see {@link SubagentService.listChildren} for the public cancellation and
|
||||||
* failure contract.
|
* failure contract.
|
||||||
* @param ctx - context carrying the optional session-query service.
|
* @param ctx - context carrying the optional session-query service.
|
||||||
|
|||||||
@@ -410,16 +410,6 @@ describe('SubagentService.listChildren', () => {
|
|||||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
|
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 () => {
|
it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
|
||||||
const { ctx, parent } = await setup([textResponse('done')])
|
const { ctx, parent } = await setup([textResponse('done')])
|
||||||
const childId = await startChild(ctx, parent, 'shifted log')
|
const childId = await startChild(ctx, parent, 'shifted log')
|
||||||
|
|||||||
Reference in New Issue
Block a user