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