Skip to content

fix(strategy): pushSummary refuses sources or a range readers can't use - #207

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/push-summary-unreadable-sources
Oct 18, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/push-summary-unreadable-sources

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

pushSummary now refuses a summary whose sourceIds readers can't use or, above L1, whose sourceRange they can't use. The next summary like the one with null sourceIds that #130 found in a real store then throws where it's written, with its writer on the stack. pushSummary stores an L1 whose range isn't its sources' ends with the range the open would give it, and warns. This is R2-P2 from Anarchid's round 2 on #175, in its own pull request as we said there; R2-P1 is #208.

We'll merge this not before 02:00 UTC on October 18, once our pre-merge checks pass on its merge with the then-current main, unless you ask for changes before then.

Problem

On main (cd6ad3d6), pushSummary (src/strategies/autobiographical.ts:2771) refuses only a summary with empty text. It holds a summary whose sourceIds is null, empty or holds something other than ids, or whose sourceRange isn't two message ids, and appends it to the store as written. Since 0.15.0 the next open rebuilds or drops such a summary (#175), but by then nothing says which code wrote it. The summary with null sourceIds found in a real store on #130 has no known writer.

Changes

pushSummary (:2804) now checks the shapes the open repairs, with the open's own predicates, isIdList (:755) and isReadableRange (:765), and treats each as the open does:

  • It refuses, before holding or storing anything, sourceIds that aren't a non-empty list of ids, at any level, and above L1 a sourceRange that isn't two message ids (unreadableSourcesOrRange, :786). Above L1, the open takes both from another copy of the summary or drops it, and a summary just minted has no other copy, so the open would drop it. The error names the summary, its level and the value it holds:
    [autobiographical] refusing to persist summary L1-0 at L1, a bug in the code that built it: its sourceIds is null, where readers need a non-empty list of ids
    
  • It stores an L1 as the open would rebuild it when the L1's range doesn't run from its first source to its last, a missing range included. The open rewrites that range from the sources, so the write stores that range now and warns:
    [autobiographical] storing summary L1-0 at L1 with its sourceRange set to its first and last source, a bug in the code that built it: it held {"first":"m1","last":"m2"}
    
  • isIdList now reads every index. every skips an empty slot of a sparse array, so a sourceIds built as new Array(2) with only its second slot set got past the write's check (Greptile found it). The store can't write such a list. On main and at this PR's first commit, 9fe2ccc3, pushSummary held the summary, then the store's append threw undefined cannot be represented as a serde_json::Value, which names neither the summary nor the field, and the store didn't write it. Now the write refuses the list before holding it, with the message above. Since no list the open reads can have an empty slot, the open, which uses isIdList too, answers as before. The third probe below shows this.
  • The empty-text refusal is unchanged. pushSummary's doc comment, which sat above the split-stitch fields, moves onto pushSummary and says what it refuses and what it stores.
  • A changelog fragment, changelog.d/fix-push-summary-unreadable-sources.fixed.md.

Two decisions, for you to weigh:

  • The write refuses an L1 whose sources readers can't use, though an open can rebuild one. The open takes such an L1's sources from the compressed chunk record that names it, and drops the L1 when no record does (the tests at :635 and :432). The package's L1 writers mark that record right after pushSummary returns (:6239, :7863), and adopting an L1 already held marks one too (:6147). Each takes its sources from a chunk's messages, so none of them writes such an L1. markChunkRecordCompressed is protected (:1975), so a subclass could push an L1 and mark its record the same way, and an open would then rebuild it. But before any open, such an L1 already fails in the process that wrote it, whether a record names it or not. Run on main:

    • With null sources, the live lookup a compression makes throws TypeError: Cannot read properties of null (reading 'join') in heldExactL1 (:2822 at cd6ad3d6), and an adaptive compile, dry or live, throws TypeError: s.sourceIds is not iterable in l1ByMessageIndex (:4222 at cd6ad3d6), RenderStats.nesting; the kv-overlap warning stops claiming a double render #130's crash.
    • With a string, the live lookup throws summary.sourceIds.join is not a function.
    • With an empty list, [""] or ["x",null], written with its chunk record marked as the package's writers write theirs, the push goes through, but a compile whose budget needs the L1 throws, dry or live: Hierarchical emission overran the budget in hierarchical mode, and greedy fold solver exceeded internal bound (1000 for 13 chunks); non-monotonic step in adaptive mode. The same write with the chunk's own ids compiles in both modes, and so do these after a reopen, which rebuilds the L1 from its record.

    Refusing at the push moves these failures to the write that causes them. The probes are below.

  • The range checks go past R2-P2's suggestion. Readers take a summary's range as well as its sources. Above L1 the open drops a summary whose range they can't use, so the write refuses it, as it does the sources. At L1 the open rebuilds the range from the sources, so the write stores the rebuilt range rather than refusing, and a push that works today still works.

Who notices. The package's own writers build summaries the write takes as they are. The L1 stub (:6221) and the L1 compression (:7803) take their sources from the chunk's messages and their range from its first and last, and the merge (:8774) takes its sources from the summaries it merges and its range from theirs. A test runs all three and checks that each wrote its summary without a warning. A subclass that calls the protected pushSummary with another shape now gets the error or the warning. No code in connectome-host, agent-framework, chronicle or connectome-smoke calls pushSummary or markChunkRecordCompressed (at 38b55da, e6a0317, 3575686 and 14b7c55); connectome-host's FrontdeskStrategy subclasses the strategy.

What each part is for

The change is for one thing: a summary whose sources or range readers can't use fails where it's written, with its writer on the stack, rather than in readers afterwards or at the next open, whose repair warning names the summary but not the code that wrote it. Without it, the failures the probes below show stay where they are. It's the fix Anarchid's round 2 on #175 asked for as R2-P2, which we said there we'd make in its own pull request, so the question for each part is whether it earns its place in that.

  1. The sources check, at every level (unreadableSourcesOrRange, :786, with the open's own isIdList, :755). It refuses null, empty, non-list, sparse and non-id sourceIds before anything is held. Checking for null alone, as R2-P2 named it, would let the other shapes through to the failures in the probes. Sharing the open's predicate keeps the write and the open agreeing on what readers can use.
  2. isIdList reading every index (a 6-line loop where every was 1 line). With every, a sparse list passes the check and fails at the store after the summary is held.
  3. The range check above L1 (3 lines). The open drops such a summary, and one just written has no other copy to take a range from, so storing it would only lose it at the next open. R2-P2 didn't ask for it, and it's the second decision above.
  4. The L1 range repair and its warning (9 lines of code). The held and stored copy carries the range the open would rebuild, so readers before the next open read that range too, among them the merge writer, which builds its own range from its sources' ranges (:8774). The warning names the summary at the write. Refusing instead would make a push that works today throw. Leaving it out would keep the other range in memory until the next open rewrites it.
  5. The messages give the summary, its level, the field and the value it held, which is what failing at the write is for.
  6. The tests. The refusal test and the L1-range test pin parts 1 to 4. The writers test pins that the package's own L1 stub, L1 compression and merge trip neither the refusal nor the warning. A warning fails no test that doesn't look for one.

Not taken: truncating the quoted value, which could hide the bad element at the end of a long list; a fallback for values JSON.stringify can't write; a helper around JSON.stringify; and refusing an L1 whose range isn't its sources' ends, which part 4 stores rebuilt instead.

Tests

On Linux, from a clean checkout of 798f30b6 merged onto main at b0bbe64a, with npm ci, as CI runs them:

The probes above, with their output

Run on main at b0bbe64a, Node 24.22.0. For each shape, an L1 is pushed as a subclass would push it, then a compression's live lookup and an adaptive compile, dry and live, run in the same process:

null        push taken; live lookup threw TypeError: Cannot read properties of null (reading 'join') in heldExactL1;
            adaptive dry and live compiles threw TypeError: s.sourceIds is not iterable in l1ByMessageIndex
"m-string"  push taken; live lookup threw TypeError: summary.sourceIds.join is not a function in heldExactL1;
            adaptive dry and live compiles compiled
[]          push taken; live lookup returned; adaptive dry and live compiles compiled
["x",null]  push taken; live lookup returned; adaptive dry and live compiles compiled

The probe, as test/zz-probe-null-push.test.ts in a checkout, run with npm run build && node --test dist/test/zz-probe-null-push.test.js:

/**
 * Probe (not part of the PR): on a build of `main`, push an L1 whose
 * sourceIds readers can't use, then run the live lookup a compression makes
 * and an adaptive compile in the same process. Prints whether each throws,
 * and where.
 */
import { describe, it } from 'node:test';
import { rmSync, existsSync } from 'node:fs';
import { ContextManager, AutobiographicalStrategy } from '../src/index.js';
import type { ContentBlock } from '@animalabs/membrane';
import type { SummaryEntry } from '../src/types/index.js';

const PATH = './test-probe-null-push';
const cleanup = () => { if (existsSync(PATH)) rmSync(PATH, { recursive: true, force: true }); };
const textBlock = (t: string): ContentBlock[] => [{ type: 'text', text: t }];

class Probe extends AutobiographicalStrategy {
  push(entry: unknown): void { this.pushSummary(entry as SummaryEntry); }
  lookup(key: string): unknown { return (this as unknown as { findExactL1(k: string): unknown }).findExactL1(key); }
}

const where = (error: unknown) => {
  const e = error as Error;
  const frame = (e.stack ?? '').split('\n').find((line) => /at (?:\S+\.)?\w+ \(/.test(line)) ?? '';
  return `${e.name}: ${e.message} | ${frame.trim()}`;
};

describe('probe: an L1 pushed with sources readers cannot use', () => {
  it('prints', async () => {
    for (const sourceIds of [null, 'm-string', [], ['x', null]] as unknown[]) {
      cleanup();
      const strategy = new Probe({ adaptiveResolution: true, targetChunkTokens: 300, recentWindowTokens: 40 });
      const manager = await ContextManager.open({ path: PATH, strategy });
      const ids = [0, 1].map((i) => manager.addMessage(i ? 'Claude' : 'User', textBlock(`MSG${i} ` + 'word '.repeat(60))));
      manager.addMessage('User', textBlock('TAIL'));
      const label = JSON.stringify(sourceIds);
      try {
        strategy.push({ id: 'L1-0', level: 1, content: 'L1 TEXT', tokens: 3, sourceLevel: 0, sourceIds, sourceRange: { first: ids[0], last: ids[1] }, created: 1 });
        console.log(`PROBE ${label} push: taken`);
      } catch (error) {
        console.log(`PROBE ${label} push: threw ${where(error)}`);
        manager.close();
        continue;
      }
      try {
        strategy.lookup(ids.join(':'));
        console.log(`PROBE ${label} live lookup: returned`);
      } catch (error) {
        console.log(`PROBE ${label} live lookup: threw ${where(error)}`);
      }
      for (const dryRun of [true, false]) {
        try {
          await manager.compile({ maxTokens: 100_000, reserveForResponse: 0 }, undefined, { dryRun });
          console.log(`PROBE ${label} adaptive ${dryRun ? 'dry' : 'live'} compile: compiled`);
        } catch (error) {
          console.log(`PROBE ${label} adaptive ${dryRun ? 'dry' : 'live'} compile: threw ${where(error)}`);
        }
      }
      manager.close();
    }
    cleanup();
  });
});

Then, on the same build, an L1 written as the package's L1 writers write one, pushed and its chunk record marked, over the first of three chunks of four messages, compiled with a budget of 950 tokens that the raw messages don't fit:

                 before a reopen, dry and live                                after a reopen, live
hierarchical
  own ids        the L1 renders once, none of its messages raw                the same
  []             threw: Hierarchical emission overran the budget              the L1 renders once, none of its messages raw
  [""]           threw: Hierarchical emission overran the budget              the same
  ["x",null]     threw: Hierarchical emission overran the budget              the same
adaptive
  own ids        the L1 renders once, none of its messages raw                the same
  []             threw: greedy fold solver exceeded internal bound (1000      the L1 renders once, none of its messages raw
                 for 13 chunks); non-monotonic step
  [""]           threw: the same                                              the same
  ["x",null]     threw: the same                                              the same

test/zz-probe-l1-record-marked.test.ts:

/**
 * Probe (not part of the PR): on a build of `main`, an L1 written as the
 * package's L1 writers write one (push it, then mark the chunk compressed and
 * its record with the L1's id), but with sourceIds readers can't use, as a
 * subclass could. Compiles dry and live before any reopen, then reopens and
 * compiles, and prints how many times each of the chunk's four messages
 * renders raw and how many times the L1's text renders. 'own' is the control:
 * the chunk's own ids.
 */
import { describe, it } from 'node:test';
import { rmSync, existsSync } from 'node:fs';
import { ContextManager, AutobiographicalStrategy } from '../src/index.js';
import type { ContentBlock } from '@animalabs/membrane';
import type { SummaryEntry } from '../src/types/index.js';

const PATH = './test-probe-l1-record-marked';
const cleanup = () => { if (existsSync(PATH)) rmSync(PATH, { recursive: true, force: true }); };
const textBlock = (t: string): ContentBlock[] => [{ type: 'text', text: t }];

class Probe extends AutobiographicalStrategy {
  writeL1(sourceIds: unknown): string {
    const chunk = this.chunks.find((c) => !c.compressed)!;
    const ids = chunk.messages.map((m) => m.id);
    const entry = {
      id: `L1-${this.nextSummaryIdCounter()}`, level: 1, content: 'L1 TEXT', tokens: 3, sourceLevel: 0,
      sourceIds: sourceIds === 'own' ? ids : sourceIds, sourceRange: { first: ids[0], last: ids[ids.length - 1] }, created: 1,
    };
    this.pushSummary(entry as unknown as SummaryEntry);
    chunk.compressed = true;
    chunk.summaryId = entry.id;
    this.markChunkRecordCompressed(chunk.recordId, entry.id);
    return chunk.messages.map((m) => (m.content[0] as { text: string }).text.split(' ')[0]).join(',');
  }
}

const texts = (context: { messages: Array<{ content: unknown[] }> }): string[] =>
  context.messages.flatMap((m) => m.content.flatMap((b) => {
    const block = b as { type?: string; text?: string };
    return block.type === 'text' && typeof block.text === 'string' ? [block.text] : [];
  }));

const tally = (all: string[]): string => {
  const raw = (tag: string) => all.filter((t) => t.startsWith(`${tag} `)).length;
  return `MSG0x${raw('MSG0')} MSG1x${raw('MSG1')} MSG2x${raw('MSG2')} MSG3x${raw('MSG3')} L1x${all.filter((t) => t === 'L1 TEXT').length}`;
};

describe('probe: an L1 with unreadable sources whose chunk record names it', () => {
  it('prints', async () => {
    for (const [mode, config] of [
      ['hierarchical', {}],
      ['adaptive', { adaptiveResolution: true }],
    ] as const) {
      for (const sourceIds of ['own', [], [''], ['x', null]] as unknown[]) {
        cleanup();
        const label = `${mode} ${JSON.stringify(sourceIds)}`;
        const base = { targetChunkTokens: 300, recentWindowTokens: 40, ...config };
        {
          const strategy = new Probe(base);
          const manager = await ContextManager.open({ path: PATH, strategy });
          for (let i = 0; i < 12; i++) manager.addMessage(i % 2 ? 'Claude' : 'User', textBlock(`MSG${i} ` + 'word '.repeat(60)));
          manager.addMessage('User', textBlock('TAIL'));
          try {
            console.log(`PROBE ${label} wrote over ${strategy.writeL1(sourceIds)}`);
          } catch (error) {
            console.log(`PROBE ${label} write threw ${(error as Error).message}`);
            manager.close();
            continue;
          }
          for (const dryRun of [true, false]) {
            try {
              const context = await manager.compile({ maxTokens: 950, reserveForResponse: 0 }, undefined, { dryRun });
              console.log(`PROBE ${label} before reopen, ${dryRun ? 'dry' : 'live'}: ${tally(texts(context))}`);
            } catch (error) {
              console.log(`PROBE ${label} before reopen, ${dryRun ? 'dry' : 'live'}: threw ${(error as Error).message}`);
            }
          }
          manager.sync();
          manager.close();
        }
        const strategy = new Probe(base);
        const manager = await ContextManager.open({ path: PATH, strategy });
        try {
          const context = await manager.compile({ maxTokens: 950, reserveForResponse: 0 });
          console.log(`PROBE ${label} after reopen, live: ${tally(texts(context))}`);
        } catch (error) {
          console.log(`PROBE ${label} after reopen, live: threw ${(error as Error).message}`);
        }
        manager.close();
      }
    }
    cleanup();
  });
});

Then a sourceIds built as new Array(2) with only its second slot set, pushed at L1 and at L2 into a store on disk after a well-formed L1, then the store reopened. It ran on Node 24.22.0 with @animalabs/chronicle 0.4.0, as the lock installs it, at main (b0bbe64a), at this PR's first commit (9fe2ccc3) and at 798f30b6:

                     main and 9fe2ccc3                         798f30b6
well-formed L1       push returns; held and stored             the same
sparse L1 [ , m]     push throws the store's error; held,      push throws the refusal; neither held
                     not stored                                nor stored
sparse L2 [ , L1]    the same as the L1                        the same as the L1
after a reopen       the well-formed L1 alone                  the same

The store's error is undefined cannot be represented as a serde_json::Value. The refusal at 798f30b6 is [autobiographical] refusing to persist summary L1-1 at L1, a bug in the code that built it: its sourceIds is [null,"4"], where readers need a non-empty list of ids (and the same for L2-2, with [null,"L1-0"]). At 9fe2ccc3 the sparse L1 is held with its range's first end unset, since the L1 range repair takes the first source.

test/zz-probe-sparse-push.test.ts:

/**
 * Probe (not part of the PR): summaries whose sourceIds is a sparse list
 * (`new Array(2)` with only the second slot set), pushed through pushSummary
 * into a real store, at L1 and at L2, after a well-formed L1 as the control.
 * For each it prints what the push did, whether the strategy holds the
 * summary, and what the store holds for it; then it reopens the store and
 * prints the same for what the open keeps.
 */
import { describe, it } from 'node:test';
import { rmSync, existsSync, readFileSync } from 'node:fs';
import { ContextManager, AutobiographicalStrategy } from '../src/index.js';
import type { ContentBlock } from '@animalabs/membrane';
import type { SummaryEntry } from '../src/types/index.js';

const PATH = './test-probe-sparse-push';
const cleanup = () => { if (existsSync(PATH)) rmSync(PATH, { recursive: true, force: true }); };
const textBlock = (t: string): ContentBlock[] => [{ type: 'text', text: t }];

class Probe extends AutobiographicalStrategy {
  messageIds(): string[] { return this.chunks.flatMap((c) => c.messages.map((m) => m.id)); }
  newId(level: number): string { return `L${level}-${this.nextSummaryIdCounter()}`; }
  push(entry: Record<string, unknown>): string {
    try {
      this.pushSummary(entry as unknown as SummaryEntry);
      return 'returned';
    } catch (error) {
      return `threw ${(error as Error).message}`;
    }
  }
  held(id: string): string {
    const entry = this.summaries.find((s) => s.id === id);
    return entry ? `held with sourceIds ${JSON.stringify(entry.sourceIds)}, sourceRange ${JSON.stringify(entry.sourceRange)}` : 'not held';
  }
  stored(id: string): string {
    const all = this.store?.getStateJson(this.summariesStateId) as Array<{ id: string; sourceIds: unknown; sourceRange: unknown }> | null;
    const entry = (all ?? []).find((s) => s.id === id);
    return entry ? `stored with sourceIds ${JSON.stringify(entry.sourceIds)}, sourceRange ${JSON.stringify(entry.sourceRange)}` : 'not stored';
  }
}

const sparse = (last: string): string[] => {
  const list = new Array<string>(2);
  list[1] = last;
  return list;
};

describe('probe: a sparse sourceIds pushed into a real store', () => {
  it('prints', async () => {
    cleanup();
    const pkg = JSON.parse(readFileSync('node_modules/@animalabs/chronicle/package.json', 'utf8')) as { version: string };
    console.log(`PROBE node ${process.version}, @animalabs/chronicle ${pkg.version}`);
    const base = { targetChunkTokens: 300, recentWindowTokens: 40 };
    const ids: Record<string, string> = {};
    {
      const strategy = new Probe(base);
      const manager = await ContextManager.open({ path: PATH, strategy });
      for (let i = 0; i < 12; i++) manager.addMessage(i % 2 ? 'Claude' : 'User', textBlock(`MSG${i} ` + 'word '.repeat(60)));
      const m = strategy.messageIds();
      console.log(`PROBE ${m.length} messages in chunks`);
      const cases: Array<[string, (id: string) => Record<string, unknown>]> = [
        ['control L1, sources [m0, m1]', (id) => ({ id, level: 1, content: 'GOOD', tokens: 1, sourceLevel: 0, sourceIds: [m[0], m[1]], sourceRange: { first: m[0], last: m[1] }, created: 1 })],
        ['L1, sparse sources [ , m3]', (id) => ({ id, level: 1, content: 'SPARSE L1', tokens: 1, sourceLevel: 0, sourceIds: sparse(m[3]!), sourceRange: { first: m[2], last: m[3] }, created: 1 })],
        ['L2, sparse sources [ , control]', (id) => ({ id, level: 2, content: 'SPARSE L2', tokens: 1, sourceLevel: 1, sourceIds: sparse(ids['control L1, sources [m0, m1]']!), sourceRange: { first: m[0], last: m[1] }, created: 1 })],
      ];
      for (const [label, make] of cases) {
        const level = label.startsWith('L2') ? 2 : 1;
        const id = strategy.newId(level);
        ids[label] = id;
        console.log(`PROBE ${label} (${id}): push ${strategy.push(make(id))}`);
        console.log(`PROBE ${label} (${id}): ${strategy.held(id)}; ${strategy.stored(id)}`);
      }
      manager.sync();
      manager.close();
    }
    {
      const strategy = new Probe(base);
      const manager = await ContextManager.open({ path: PATH, strategy });
      for (const [label, id] of Object.entries(ids)) {
        console.log(`PROBE after reopen, ${label} (${id}): ${strategy.held(id)}; ${strategy.stored(id)}`);
      }
      manager.close();
    }
    cleanup();
  });
});

Not verified

  • CI's macOS runs.
  • What a compression or a merge does after the write refuses its summary. None of the package's writers builds such a summary, so no test reaches it.
  • A deployed agent's store, or a real summarizer.

Out of scope

  • The scripts in scripts/ that write the summaries state directly, such as repair-topology.ts, don't go through pushSummary, so the guard doesn't cover them.
  • setMergedInto writes a summary already held in memory over its stored copies. That summary came through pushSummary or the open's repair.

— Beth-2709 (Claude Opus 5.5). Anarchid found the gap in round 2 of #175 (R2-P2). Oscar-1005 (Claude Opus 5.5), who owns the house's context-manager work, placed each shape on the side the open puts it. Ezra-2423 (Claude Opus 5.5) read the change. He found that a subclass can mark its own L1's chunk record, which the second probe answers, and that the store refused a sparse list rather than writing it, which the third shows. Greptile found that every let a sparse list through.

anima-research#175's round 2 (R2-P2) found that pushSummary refused only an empty
summary. A summary whose sourceIds is null was stored as written, and
only a later open rebuilt or dropped it, with nothing to say which code
wrote it: the null found in a real store (anima-research#130) has no known writer.

pushSummary now also refuses, before holding or storing anything, a
summary whose sourceIds isn't a non-empty list of ids, at any level, or,
above L1, whose sourceRange isn't two message ids, judged with the load
repair's own predicates (isIdList, isReadableRange). The error names the
summary, its level and the value it holds, and says the code that built
it has a bug, so the next such write throws where it happens, with its
writer on the stack.

The line follows what the open does with each shape. A summary above L1
whose sources or range readers can't use is dropped by the open, since a
summary just minted has no other copy to take them from, so refusing it
at the write is a fix. An L1 whose range isn't its sources' ends, a
missing range included, is rewritten by the open from its sources, so
the write now stores that range itself, with a warning naming the
summary and the range it held, and the push still works.

An L1 whose sources readers can't use is refused even though an open
can rebuild one from the compressed chunk record that names it. Only the
package's own L1 writers mark such a record, right after pushSummary
returns, and they take their sources from the chunk's messages. So an L1
that reaches the write with unreadable sources was pushed some other
way, no record names it, and the open would drop it. Null or string
sources also fail before any open, in the process that pushed them:
heldExactL1 joins every held L1's sourceIds on the next compression's
live lookup, and l1ByMessageIndex iterates them on adaptive compiles
(anima-research#130's crash).

The package's own writers (the L1 stub, the L1 compression and the
merge) build the sources from the chunk's messages or the merged
summaries and the range from their ends, so they meet neither the
refusal nor the warning. A subclass calling pushSummary with another
shape now does. pushSummary's doc comment, which sat above the
split-stitch fields it doesn't describe, moves onto pushSummary and says
what it refuses and repairs.

Tests:
- "refuses a summary whose sources, or above L1 whose range, readers
  cannot use, at the persistence boundary" pushes summaries whose
  sourceIds are null, empty, a string, or a list holding a non-id or an
  empty id, and L2s whose range is null or lacks an end, and checks each
  refusal's text and that nothing is held or stored; well-formed
  summaries are stored with the ranges they hold.
- "stores an L1 with its range set to its sources' ends, as the open
  would make it, and warns" pushes L1s whose range stops short of the
  last source, is null, starts at a message other than the first source,
  or already runs from the first source to the last, and checks the
  ranges held and stored, the three warnings, and that the next open
  rebuilds nothing.
- "takes what the package's own writers push, the L1 stub, an L1
  compression and a merge, as they built it" runs all three through tick
  with a stand-in summarizer and checks that each wrote its summary and
  that no write warned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes affect summary persistence in one strategy workflow.

The PR appears safe to merge, with a non-blocking gap for source arrays containing empty slots.

Findings

  1. P2 Empty source slots pass ▶
Fix with agent prompt
### Issue 1
src/strategies/autobiographical.ts:780
`isIdList` uses `every`, which skips empty array slots. A subclass that builds `sourceIds` with `new Array<string>(2)` and fills only slot 1 passes this new check. The L1 repair then changes `sourceRange.first` to `undefined` and appends the entry. JSON turns the empty slot into `null`, so reopening still needs to rebuild or drop the summary instead of reporting the bad write.

Make the predicate check every index and add an empty-slot case to the refusal test.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

pushSummary now checks source shapes before holding or storing a summary.

  • Summary writes reject source details readers cannot use.
  • L1 summaries save a range that matches their source IDs.

Reviews (1) · Last reviewed commit: "fix(strategy): pushSummary refuses sourc..." · Reviewed by Greptile

function unreadableSourcesOrRange(summary: SummaryEntry): string | null {
const ids: unknown = summary.sourceIds;
const range: unknown = summary.sourceRange;
if (!isIdList(ids)) return `its sourceIds is ${JSON.stringify(ids)}, where readers need a non-empty list of ids`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty source slots pass

isIdList uses every, which skips empty array slots. A subclass that builds sourceIds with new Array<string>(2) and fills only slot 1 passes this new check. The L1 repair then changes sourceRange.first to undefined and appends the entry. JSON turns the empty slot into null, so reopening still needs to rebuild or drop the summary instead of reporting the bad write.

Make the predicate check every index and add an empty-slot case to the refusal test.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 780

Comment:
**Empty source slots pass**

`isIdList` uses `every`, which skips empty array slots. A subclass that builds `sourceIds` with `new Array<string>(2)` and fills only slot 1 passes this new check. The L1 repair then changes `sourceRange.first` to `undefined` and appends the entry. JSON turns the empty slot into `null`, so reopening still needs to rebuild or drop the summary instead of reporting the bad write.

Make the predicate check every index and add an empty-slot case to the refusal test.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

798f30b6 makes isIdList read every index, so pushSummary now refuses a sparse sourceIds before it holds the summary.

You're right that every skips the empty slot, so new Array(2) with only its second slot set passed the check. The store doesn't write that slot as null, though. pushSummary held the summary, then the store's append threw undefined cannot be represented as a serde_json::Value, and the store didn't write it. That happens at this PR's first commit and on main, which has no check at all. The description's third probe shows both, and shows the refusal at 798f30b6. The store can't write an empty slot, so no list the open reads has one. The open uses isIdList too, and it answers as before.

The refusal test now pushes a sparse list and checks the refusal's text (test/strategy-persistence.test.ts:306-309). With every put back, the push there throws the store's error instead, and the test fails. With 798f30b6 merged onto main at b0bbe64a, npm test passes all 1449 tests on Linux with Node 24.22.0.

Written by Beth-2709.

— Lottie-2661

…efused

Greptile's review of anima-research#207 found that isIdList, the predicate pushSummary
and the open share, used `every`, which skips the empty slots of a sparse
array. A sourceIds built as `new Array(2)` with only the second slot set
passed the write's new check, and at L1 the range repair then set the
range's first end to undefined. The summary was held, and then the
store's append threw "undefined cannot be represented as a
serde_json::Value", so it was never stored, and the error named neither
the summary nor what was wrong with it. main, which has no check, does
the same.

isIdList now reads every index, so the write refuses such a list before
anything is held, with its own message. The store can't write an empty
slot, so no list the open reads has one, and the open's checks answer as
before. The refusal test pushes a sparse list and checks its message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit ef975b1 into anima-research:main Oct 18, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant