Repository navigation
[Bug] Session indexing still fails on 0.2.1-alpha.2: historical revision churn + isolated in-memory Agent index #8783
Replies: 3 comments
|
Update (2026-10-11): still reproduced on official 0.2.1-alpha.2. I have rewritten the opening report with the new reproduction and removed my two obsolete rc.2 patch comments so readers do not apply superseded diffs. The latest official-engine reproduction failed after 425,494 ms; unchanged membership still produced changes to all 350 historical revisions from unrelated current activity. There is also a separate configuration trap: the optional Agent search bundle uses an isolated in-memory index, so a populated persistent sidebar index does not prevent Agent tools from cold-building and timing out. A no-installed-code-patch recovery (official v4 migration with retained originals, one-time batch index build, persistent optional-provider configuration) restored GUI and Agent searches to around one second. That is local recovery, not a durable upstream fix. I have experienced search stopping again after earlier repairs/upgrades. Today's new-chat marker test passed, but sustained activity, host restart and subsequent upgrades remain unverified. @imccyu @Dudu-0223 — could you please take a look, or route this to the appropriate maintainer? I am mentioning you because of your concrete work on this persistence/migration code, not assigning ownership or blame: historical-read preparation and parent catalogs / child-failure isolation. The opening report contains version-pinned sources, before/after timings, configuration and the requested new-chat/restart/upgrade regression tests. Please keep this open as an indexing reliability issue; an ad-hoc rebuild is not sufficient closure. |
|
Mechanism for the "historical revision churn" — pinned to code; still present on 0.2.1-alpha.3 Confirming your observation ("unchanged membership still produced changes to all 350 historical revisions") and adding the mechanism, because the mechanism is what makes it actionable. In
The appended component is therefore a hash of the entire store, not of the session. Consequences:
Measurements on 0.2.1-alpha.3 (Windows 11 x64, Node
Cross-references: #9695 (the tool's default 30 s Your local recovery — official v4 migration with retained originals — is consistent with this mechanism, because migrated logs leave the corpus-dependent branch. One question I would find useful, if you still have that store: after the migration, does the ~1 s latency survive a full index rebuild, i.e. is anything still forcing the legacy branch? Also worth noting for whoever looks at this next: on my store the legacy members are not uniform — 21 of them cannot be opened at all (unsupported |
|
Fix for the historical revision churn — verified against current Building on @dust617's mechanism above: the trailing component of a historical Session's revision is The only files a historical parent's logical events actually depend on are its direct children — the generations
Verification on
Patch (src + tests, applies to 687f282)diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts
index 3c3c13589fb..9223f5b04d0 100644
--- a/packages/session/session-persistence-jsonl/src/index.ts
+++ b/packages/session/session-persistence-jsonl/src/index.ts
@@ -207,6 +207,11 @@ function fileRevision(identity: JsonlPhysicalIdentity): PersistenceRevision {
].join(':'))
}
+/** Whether a header names a direct child whose descriptor completes the parent's historical catalog. */
+function isDirectChild(header: SessionHeader, parentId: SessionId): boolean {
+ return header.origin === 'subagent' && header.parentSession === parentId
+}
+
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
@@ -501,7 +506,8 @@ class JsonlSessionPersistence extends SessionPersistence {
return {
...metadata,
revision: selected.sourceVersion < SESSION_FORMAT_VERSION
- ? SessionPersistenceRevision(`${fileRevision(identity)}:${await this.historicalCorpusRevision(options?.signal)}`)
+ ? SessionPersistenceRevision(`${fileRevision(identity)}:${
+ await this.childCatalogRevision(await this.listArtifacts(options?.signal), id, options?.signal)}`)
: fileRevision(identity),
sizeBytes: Number(identity.size),
}
@@ -527,8 +533,6 @@ class JsonlSessionPersistence extends SessionPersistence {
// predate the scan), so create-to-list visibility never has a hole.
const pending = [...this.tracker.pendingEntries()]
const artifacts = await this.listArtifacts(signal)
- const corpusRevision = artifacts.some(artifact => artifact.sourceVersion < SESSION_FORMAT_VERSION)
- ? await this.historicalCorpusRevision(signal) : undefined
const statted = settledValues(await settleLimited(this.listLimit, artifacts, async (artifact) => {
signal?.throwIfAborted()
try {
@@ -549,7 +553,8 @@ class JsonlSessionPersistence extends SessionPersistence {
header: artifact.header,
formatStatus: artifact.formatStatus,
revision: artifact.sourceVersion < SESSION_FORMAT_VERSION
- ? SessionPersistenceRevision(`${fileRevision(identity)}:${corpusRevision}`)
+ ? SessionPersistenceRevision(`${fileRevision(identity)}:${
+ await this.childCatalogRevision(artifacts, artifact.header.id, signal)}`)
: fileRevision(identity),
sizeBytes: Number(identity.size),
})
@@ -683,7 +688,7 @@ class JsonlSessionPersistence extends SessionPersistence {
let validateRelatedSources: () => Promise<void>
try {
const children = async () => (await this.listArtifacts(signal))
- .filter(source => source.header.origin === 'subagent' && source.header.parentSession === id)
+ .filter(source => isDirectChild(source.header, id))
const sources = await children()
const related = await prepareCatalogFacts(id, sources, this.compression, signal)
for (const failure of related.failures) {
@@ -1115,9 +1120,20 @@ class JsonlSessionPersistence extends SessionPersistence {
return results
}
- /** Historical logical events depend on the corpus, including members with unreadable headers. */
- private async historicalCorpusRevision(signal?: AbortSignal): Promise<string> {
- const paths = (await this.listGenerations(signal)).map(source => source.sourcePath).sort()
+ /**
+ * Fingerprint the selected generations of a historical parent's direct children. Catalog completion reads exactly
+ * these files, so a change elsewhere in the root leaves the parent's logical events and this fingerprint unchanged.
+ * @param artifacts - readable headers of every selected generation in the root.
+ * @param parentId - historical parent whose catalog the children complete.
+ * @param signal - optional cancellation.
+ * @returns a digest of each child's path and filesystem revision.
+ */
+ private async childCatalogRevision(
+ artifacts: readonly { readonly header: SessionHeader; readonly path: string }[],
+ parentId: SessionId,
+ signal?: AbortSignal,
+ ): Promise<string> {
+ const paths = artifacts.filter(artifact => isDirectChild(artifact.header, parentId)).map(artifact => artifact.path).sort()
const entries = settledValues(await settleLimited(this.listLimit, paths, async (path) => {
signal?.throwIfAborted()
try {
diff --git a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts
index 610be06478b..e975ae0d0b9 100644
--- a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts
+++ b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts
@@ -123,16 +123,31 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)',
expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first)
})
+ it('keeps historical revisions independent of Sessions outside the parent catalog', async () => {
+ const f = await fixture()
+ await f.write('child', [f.descriptor], true)
+ const revision = async () => (await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)!.revision
+ const first = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
+ expect(await revision()).toBe(first)
+ await f.write('unrelated', [])
+ await f.write('unrelated', [{ type: 'feedback/record', seq: 0, time: 3, data: {} }])
+ await f.write('current', [], false, 4)
+ expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first)
+ expect(await revision()).toBe(first)
+ })
+
it('invalidates historical revisions when selected children disappear or gain an opaque successor', async () => {
const f = await fixture()
const path = await f.write('child', [f.descriptor], true)
const first = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
+ const successor = await f.write('child', [], true, 5)
+ const opaque = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
+ expect(opaque).not.toBe(first)
+ expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).toBe(opaque)
+ await rm(successor)
+ expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first)
await rm(path)
- const removed = (await f.ctx.sessionPersistence.stat(f.parent))!.revision
- expect(removed).not.toBe(first)
- await f.write('child', [], true, 5)
- expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).not.toBe(removed)
- expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).not.toBe(removed)
+ expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).not.toBe(first)
})
it('isolates a corrupt child catalog and reports its path', async () => {
diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts
index 760aaa46c38..60357d82c13 100644
--- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts
+++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts
@@ -1268,9 +1268,11 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
.rejects.toThrow(/released v0 physical header lacks required member "type"/)
})
- it('tracks a disappearing corpus member and propagates its storage faults in historical revisions', async () => {
+ it('tracks a disappearing child and propagates its storage faults in historical revisions', async () => {
const parent = meta('corpus-revision-parent', '/work')
- const child = meta('corpus-revision-child', '/work')
+ const child: SessionHeader = {
+ ...meta('corpus-revision-child', '/work'), origin: 'subagent', parentSession: parent.id, delegationDepth: 1,
+ }
for (const header of [parent, child]) {
const path = historicalLogPath(root, header.cwd, header.id)
await mkdir(dirname(path), { recursive: true }) |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Status — 2026-10-11: reproduced on 0.2.1-alpha.2; locally recovered, NOT fixed upstream
This report is still applicable after upgrading from 0.2.0-rc.2 to 0.2.1-alpha.2. Please do not treat a successful one-off rebuild, or the older downstream patches formerly posted in this thread, as evidence of a durable upstream fix.
There are two separate failure paths: whole-corpus reconciliation starvation with historical sessions, and the optional Agent search bundle rebuilding a separate in-memory index within a short tool timeout. Both were observed in this installation.
Environment
@deepseek-ai/dshand@deepseek-ai/dsh-session-query-sqlite0.2.1-alpha.2.quick_checkreturnedok; initially 0 persisted sessions / 0 FTS documents.node:sqlite. An in-memory FTS5 insert/search and a read of the actual FTS table succeeded. The macOS terminal SQLite executable's missing FTS5 module is a separate diagnostic limitation, not this DSH failure.1. Confirmed reconciliation starvation on alpha.2
A separate process instantiated the official persistence backend and official SQLite engine, with an empty live registry and an in-memory index, against the actively used session store. It could read the session bodies, but full reconciliation failed after 425,494 ms (~7 minutes) with:
The initial listing had 767 sessions; subsequent listings had 769 and finally 771 as the store remained active. The first read pass took roughly 207 seconds. No index progress was committed before the final stability check.
A separate revision comparison controlled for membership: both listings contained the same 769 sessions, but 354 revisions changed: all 350 historical sessions plus 4 current sessions. For historical samples, the individual file identity/revision was unchanged; only the appended corpus hash changed. This establishes amplification beyond merely adding new session IDs.
Why it happens
Historical listing revisions combine the selected file's revision with
historicalCorpusRevision(), which hashes the paths/stat revisions of the selected generation corpus. An unrelated current-session append can invalidate every historical listing revision._observeStable()reads the changed cold logs and requires the complete before/after snapshots to agree, with only two attempts._reconcile()starts writing only after that observation succeeds. A long initial build under ongoing writes can therefore retry the entire corpus and leave an otherwise valid SQLite database empty.Version-pinned official sources:
The original rc.2 reproduction remains relevant: 477 readable sessions, 954 cold opens over two passes, failure after 147,427 ms; another observation with stable membership at 481 sessions saw 405 changing revisions. The alpha.2 reproduction above is new evidence, not an assumption based only on the old release.
2. A persistent sidebar index does NOT configure the Agent search index
The optional official
experimental-session-searchbundle mounts an isolatedsessionQueryprovider underoptional-session-search. Itsoptional-session-query-sqliterow defaults to:The installed bundle README and configuration explicitly document an isolated, lazy, in-memory index separate from sidebar search.
In this installation, the profile override affected only
session-query-sqlite, notoptional-session-query-sqlite. After the persistent sidebar index was recovered, GUI/API searches took about one second, butsession_searchstill timed out at 30,000 ms. A separate Agent session reproduced the same timeout. The optional provider was still cold-building its own in-memory index.The in-memory default is intentional, not an assertion that configuration parsing is broken. However, it is unsuitable for this corpus and makes an already recovered sidebar misleading as an indicator of Agent search health.
Local recovery performed — no installed-code patch, no new plugin
Both isolated providers now use the same durable derived database. The profile change reloaded in the active host; no host restart or installed-code modification was required.
openAt: startupopens the database; it does not itself mean a background full-corpus build runs at startup.Results immediately after recovery
ok.session_search: 5 prior sessions in 1,241 ms; targeted historical-session search in 1,011 ms.session_event_search: 9 historical user-message events in 852 ms.Recurrence concern: new chats, restart, upgrades — NOT yet ruled out
My experience has been that search works after repair and then seems to stop again as new chats accumulate or DSH is upgraded. The recurrence after the previous repair and upgrade is why I am updating this report, rather than marking it solved. This observation should not be mistaken for a controlled proof that every new chat or every upgrade causes the same failure.
A focused test after today's recovery created a genuinely new Agent chat containing a unique marker. A search restricted to that new session found the marker automatically in 1,444 ms, without another manual rebuild. Thus, that one new-chat test passed. Post-restart/post-upgrade durability and sustained high-activity behavior have NOT been verified.
The upstream whole-snapshot stability requirement still exists, including for current/live sessions. Migrating historical generations removes the amplification demonstrated here; it does not fix the algorithm for everyone. Configuring a persistent Agent index avoids this cold-start path but does not establish a general background/indexing solution.
The older v1/v2 downstream code patches and five-minute refresh throttle are retired. They could be overwritten by upgrades, admitted eventual/stale results, and did not demonstrate durable usable search. Their comments have been removed to avoid people applying obsolete rc.2 diffs to current versions. No such patch was reapplied in this recovery.
Requested upstream work
Could maintainers investigate and add regression coverage for:
Please do not solve this by freezing listings, simply removing revision checks, or silently excluding historical sessions.
Related: #6328 and #6545 concern individual migration refusals; the reproduction here reached a different failure with readable bodies. #1859 reports other large-session search costs and starvation; this report does not claim its community fork is an official alpha.2 fix.
Disclosure
Updated with the deployment owner's authorization using an AI-assisted investigation. Counts/timings are observations from this installation. Session content, IDs and private local paths are omitted. The local recovery is a workaround for this installation, not an upstream fix or an unconditional claim of permanent stability.
All reactions