Skip to content

HistoryService: direct mutations bypass the mutation FIFO and can drop a concurrent add #3443

Open

Description

@acoliver

Found during an independent review of the 0.12.0 merge branch. Pre-existing, not a regression — verified present at premerge/0.12.0-start.

What the FIFO does cover

HistoryService serializes the paths that matter most for compression. replaceAll goes through enqueueAsynchronousHistoryMutation (HistoryServiceCore.ts:816-839), an add() during compression enters the phase queue (HistoryServiceCore.ts:506-537), and endCompression routes release events and flushed closures through runSynchronousHistoryMutation (HistoryService.ts:526-546). If an async replacement is in flight, those closures queue behind it and completeAsynchronousHistoryMutation drains them in order.

That path is sound. This issue is about the methods that sit outside it.

The gap

Four methods mutate this.history directly, bypassing both queues:

method location note
summarizeOldHistory HistoryService.ts:477-494 awaits, then assigns
validateAndFix HistoryService.ts:407-429 direct splice()
removeLastIfMatches / pop HistoryService.ts:302-324 direct mutation
dispose HistoryService.ts:217-239 direct reset

summarizeOldHistory has the clearest data-loss shape:

const result = await summarizeOldHistoryHelper(this.history, keepRecentCount, summarizeFn);
if (result) {
  for (const item of result) this.chronology.stamp(item);
  this.history = result;          // any add() during the await is discarded
  await this.recalculateTotalTokens();
}

result is computed from a snapshot of this.history taken before the await. Any add() landing while summarizeFn is in flight — a network call — is silently overwritten.

Exposure

  • summarizeOldHistory has no production caller in the current tree. Only its own definition and the historyContextWindow.ts:52 helper it imports. Test-only surface today, but it is public API on a service other packages hold.
  • validateAndFix is called in production, from packages/agents/src/core/client.ts:725. It splices synchronously, so it cannot interleave with itself, but it can land in the middle of a queued async replacement and operate on history that is about to be replaced wholesale.

Suggested fix

Route these through enqueueAsynchronousHistoryMutation / runSynchronousHistoryMutation like their neighbours. For summarizeOldHistory specifically, either re-read this.history after the await and merge, or take the mutation lock for the whole operation so no add() can interleave.

Worth confirming whether the direct-mutation methods are intended to be lock-free, in which case the fix is documenting that rather than changing them.

Provenance

Reported by an independent review agent auditing the 65-PR 0.12.0 merge for integration defects. Both findings were checked against premerge/0.12.0-start and are present there, so neither is caused by that merge. Raising separately so the merge does not carry unrelated changes.

Activity

  1. added
    packages/coreIssues related to packages/core component
    Context ManagementIssues related to managing the context sent to the model.
    on Aug 31, 2026
  2. github-actions commented on Aug 31, 2026

    @github-actions
    Contributor
    • Summary

      • Fix a history-mutation ordering bug in HistoryService where direct mutations can bypass the existing mutation FIFO and drop concurrent add() work.
      • Target methods: summarizeOldHistory, validateAndFix, removeLastIfMatches / pop, and dispose.
      • Keep the fix package-local to packages/core and preserve existing public behavior/API shape.
      • Add/extend tests in the existing HistoryService.*.test.ts files; no new test file unless justified.
    • Sizing audit

      • Acceptance criteria: 4
      • Package span: 1 (packages/core)
      • Phase/epic signal: none
      • Expected net LoC: small (< 500)
      • Classification: small
    • Test plan

      • Existing files to extend:
        • packages/core/src/services/history/HistoryService.management.test.ts
          • Add a concurrent summarizeOldHistory + add() test that asserts no added content is lost when summarization awaits.
          • Add a validateAndFix concurrency test showing queued ordering with an in-flight async mutation.
          • Add removeLastIfMatches / pop tests that verify mutation ordering and token recalculation behavior.
          • Add a dispose test verifying cleanup without leaking mutation-queue state and that subsequent operations are safely no-op/error-resilient.
        • packages/core/src/services/history/HistoryService.basic.test.ts
          • Add one regression test for summarizeOldHistory preserving interleaved add() items.
      • New test file: none needed; existing history test files already cover adjacent behavior and can absorb these cases.
    • Implementation steps

      1. Route summarizeOldHistory through the async mutation queue, or hold the mutation lock for the whole operation and re-merge any items added during the await.
      2. Route validateAndFix through the synchronous mutation path so it cannot interleave with an in-flight async replacement.
      3. Route removeLastIfMatches and pop through the synchronous mutation path; preserve token recalculation semantics.
      4. Make dispose drain/reset mutation state safely and stop queued work from resurrecting history after reset.
      5. Update/add tests for each path and run repo verification:
        • npm run lint:ci
        • npm run lint:eslint-guard
        • npm run typecheck
        • npm run test
    • Prompt for AI agents

      • Implement only within packages/core/src/services/history/HistoryService.ts and the two existing test files above.
      • Do not change public method signatures unless required for safety.
      • Preserve chronology/token/tokensUpdated invariants already enforced by runSynchronousHistoryMutation / enqueueAsynchronousHistoryMutation.
      • Avoid new suppression directives, eslint downgrades, or test infrastructure changes.
      • After edits, run the four verification commands and fix any failures before finishing.
  3. added this to the 0.12.0 milestone on Sep 7, 2026
  4. acoliver commented on Oct 13, 2026

    @acoliver
    CollaboratorAuthor

    #3852 (journal-backed history, #854) rewrites HistoryService as a facade over an on-disk journal, so the direct-mutation methods named here no longer mutate an in-memory array. Re-verify against the #3852 code after it merges; close with evidence or adopt under #3868.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Context ManagementIssues related to managing the context sent to the model.packages/coreIssues related to packages/core component

    Type

    No type

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions