Skip to content

fix(tools): scope task CRUD state by conversation - #3587

Open
GautamSharma99 wants to merge 1 commit into
letta-ai:mainfrom
GautamSharma99:fix/task-store-conversation-scope
Open

fix(tools): scope task CRUD state by conversation#3587
GautamSharma99 wants to merge 1 commit into
letta-ai:mainfrom
GautamSharma99:fix/task-store-conversation-scope

Conversation

@GautamSharma99

Copy link
Copy Markdown

Summary

Scope the TaskCreate / TaskGet / TaskList / TaskUpdate in-memory registry by both agent and conversation instead of sharing one process-global task collection.

This prevents concurrent conversations in the listener, headless runtime, TUI, and OpenAI-compatible app server from listing, reading, or updating each other's task plans.

Fixes #3577.

Root cause

The task CRUD store previously owned one module-level Map<string, TaskRecord> and one insertion-order array. Every tool invocation in the process called that singleton directly, even though letta-code already executes tools inside a runtime context containing the active agentId and conversationId.

That meant:

  • TaskList returned tasks created by unrelated conversations;
  • a task ID from one conversation could be passed to TaskGet or TaskUpdate in another;
  • agents whose conversation ID is the shared "default" value also shared task state;
  • the TUI renderer independently read the same global collection, so fixing only tool writes would still render the wrong conversation's tasks.

Implementation

Scoped store ownership

src/tools/impl/tasks/store.ts now owns nested agent/conversation stores:

agentId -> conversationId -> { tasks, insertionOrder }

Every CRUD operation requires a TaskStoreScope. Task IDs remain process-unique, which makes a task ID created in scope A resolve as not found in scope B rather than accidentally referring to a different local task.

The store also exposes clearTaskStoreScope() so lifecycle owners can remove one conversation without affecting any other scope.

Fail-closed tool scope resolution

The new src/tools/impl/tasks/scope.ts resolves scope from the existing AsyncLocalStorage runtime context used by executeTool().

Task tools now reject execution when either agentId or conversationId is absent. They do not fall back to an unscoped bucket, because that would silently recreate the original isolation bug for any call path with incomplete context.

No model-facing task schema changes are required; scope remains execution metadata rather than user-controlled tool input.

Scoped TUI rendering

AppView and StaticTranscript pass the active agent/conversation identity to ToolCallMessage. Task snapshots are projected by the new task-crud-rendering helper, so the TUI reads exactly the same scoped store as the tool invocation.

The projection was extracted instead of growing the already oversized renderer. ToolCallMessageRich.tsx shrinks by one line, and its source-size baseline is ratcheted from 1109 to 1108.

Ephemeral conversation cleanup

Headerless OpenAI-compatible Chat Completions and Responses requests create ephemeral conversations and delete them after the turn. After a successful backend deletion, these paths now clear the matching task scope as well.

Cleanup is deliberately tied to successful conversation deletion: a failed backend deletion retains both conversation and task state for consistent retry/debug behavior.

Correctness details

  • Scope includes both IDs because conversationId === "default" is valid for multiple agents.
  • Nested maps avoid delimiter/collision issues from concatenating identifiers into one string key.
  • The global task ID counter is retained only for process-wide ID uniqueness; records and list ordering are scoped.
  • Clearing one scope prunes its empty per-agent map but never resets the global counter or another conversation.
  • Existing CRUD validation and soft-delete semantics are unchanged.
  • The change adds no dependency cycles and respects the existing layer boundaries: CLI/WebSocket surfaces may consume the lower tools layer.

Tests

Added regression coverage for:

  • concurrent task creation/listing in two runtime scopes;
  • cross-scope TaskGet and TaskUpdate rejection;
  • isolation between two agents using the "default" conversation ID;
  • fail-closed execution without runtime scope;
  • clearing one scope without affecting another;
  • scoped TUI task projection;
  • task cleanup for ephemeral Chat Completions conversations;
  • cleanup of transient Responses conversations while preserving header-keyed conversation tasks.

Focused test command:

bun test src/tools/task-crud.test.ts \
  src/cli/helpers/task-crud-rendering.test.ts \
  src/websocket/app-server-openai.test.ts \
  src/websocket/app-server-openai-responses.test.ts \
  src/cli/enter-worktree-result-renderer.test.tsx

Result: 51 passed, 0 failed.

Full repository validation:

bun run check

Result: all 12 checks passed, including circular dependencies, layer boundaries, source-size ratchet, module ownership, Biome, and TypeScript.

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.

Task CRUD store leaks state across conversations in the same process

1 participant