From fe6df54201146295e243501ce7655a2e38169f51 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:10:33 +0200 Subject: [PATCH 1/5] fix(server): Claude task lists survive a server restart The Claude adapter kept each session's task list only in memory, while Claude persists it per session on disk. After a server restart or a new CLI process resuming the session, a TaskUpdate on an older task was dropped and a TaskCreate replaced the drawer with just the new task. The first task tool result a process sees for a session now starts from Claude's stored list (/tasks//.json, list id being CLAUDE_CODE_TASK_LIST_ID or the session id). A missing or unreadable store falls back to the old in-memory behavior. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 68 ++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 86 +++++++++++++++++-- 2 files changed, 146 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4bf007065a73..01b1c4d3b1d1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4010,7 +4010,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { } const makeTaskToolHarness = Effect.gen(function* () { - const harness = yield* makeWakeHarness; + // Claude's on-disk task store; tests seed it to stand in for a list Claude + // kept from an earlier server process. + const configDir = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "t3-claude-tasks-", + }); + const harness = yield* makeWakeHarnessWithOptions({ + environment: { CLAUDE_CONFIG_DIR: configDir }, + }); let sequence = 600; let turn = 0; const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; @@ -4046,7 +4053,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.events.flatMap((event) => event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [], ); - return { startTurn, call, finishTurn, plans }; + return { configDir, startTurn, call, finishTurn, plans }; }); const createTask = (id: string, subject: string): TaskToolCall => ({ @@ -4208,6 +4215,63 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("starts from the task list Claude kept for the session before a restart", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const harness = yield* makeTaskToolHarness; + const tasksDir = `${harness.configDir}/tasks/${WAKE_NATIVE_SESSION}`; + yield* fileSystem.makeDirectory(tasksDir, { recursive: true }); + const task = (id: string, subject: string, status: string, metadata?: object) => + fileSystem.writeFileString( + `${tasksDir}/${id}.json`, + JSON.stringify({ + id, + subject, + description: "", + status, + blocks: [], + blockedBy: [], + metadata, + }), + ); + yield* task("10", "Ship", "pending"); + yield* task("1", "Inspect", "completed"); + // Claude already wrote the TaskUpdate below before T3 sees its result. + yield* task("2", "Implement", "completed"); + yield* task("3", "Internal", "pending", { _internal: true }); + yield* fileSystem.writeFileString(`${tasksDir}/4.json`, "{"); + yield* fileSystem.writeFileString(`${tasksDir}/.highwatermark`, "10"); + yield* fileSystem.writeFileString(`${tasksDir}/.lock`, ""); + + yield* harness.startTurn(); + yield* harness.call(updateTask({ taskId: "2", status: "completed" })); + yield* harness.call(createTask("11", "Verify")); + yield* harness.finishTurn(); + + assert.deepEqual( + harness.plans().map((plan) => plan.steps), + [ + [ + { id: "task-1", text: "Inspect", status: "completed" }, + { id: "task-2", text: "Implement", status: "completed" }, + { id: "task-10", text: "Ship", status: "pending" }, + ], + [ + { id: "task-1", text: "Inspect", status: "completed" }, + { id: "task-2", text: "Implement", status: "completed" }, + { id: "task-10", text: "Ship", status: "pending" }, + { id: "task-11", text: "Verify", status: "pending" }, + ], + ], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each(["success false", "error result", "unknown id"] as const)( "does not emit a plan update for TaskUpdate with %s", (reason) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7199f73040ef..a5a943816e09 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3308,6 +3308,64 @@ function applyClaudeTaskTool( return new Map(tasks).set(id, { text, status }); } +const CLAUDE_TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskUpdate", "TaskList"]); +const CLAUDE_TASK_FILE_NAME = /^\d+\.json$/; +const decodeClaudeTaskFile = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +/** + * Reads the task list Claude persisted for a session, in id order. Claude + * keeps it at `/tasks//.json`, where the list id + * is `CLAUDE_CODE_TASK_LIST_ID` or else the session id, so a list started + * before a server restart is still the one later task tools edit. The format + * is Claude's and undocumented: anything missing or unreadable reads as an + * empty list rather than failing the turn. + */ +const readClaudeTaskList = Effect.fnUntraced( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | null | undefined; + readonly nativeThreadId: string; + }) { + const { fileSystem, path } = input; + const configDirOverride = input.environment.CLAUDE_CONFIG_DIR?.trim(); + // The CLI resolves a relative config dir against its own cwd. + const configDir = configDirOverride + ? path.resolve(input.cwd ?? ".", configDirOverride) + : path.join(yield* HostProcess.HomeDirectory, ".claude"); + const listId = input.environment.CLAUDE_CODE_TASK_LIST_ID?.trim() || input.nativeThreadId; + const directory = path.join(configDir, "tasks", listId.replace(/[^a-zA-Z0-9_-]/g, "-")); + const names = (yield* fileSystem.readDirectory(directory)).filter((name) => + CLAUDE_TASK_FILE_NAME.test(name), + ); + const tasks = yield* Effect.forEach(names, (name) => + fileSystem.readFileString(path.join(directory, name)).pipe( + Effect.map((contents) => { + const task = Option.getOrUndefined(decodeClaudeTaskFile(contents)); + const id = claudeTaskString(task, "id"); + const text = claudeTaskString(task, "subject"); + const status = claudeTaskStatus(claudeTaskField(task, "status")); + // Claude hides `_internal` tasks from TaskList; so do we. + const internal = claudeTaskField(claudeTaskField(task, "metadata"), "_internal"); + return id === undefined || text === undefined || status === undefined || internal + ? [] + : [{ id, text, status }]; + }), + Effect.orElseSucceed(() => []), + ), + ); + const list: ClaudeTaskList = new Map( + tasks + .flat() + .toSorted((left, right) => Number(left.id) - Number(right.id)) + .map(({ id, text, status }) => [id, { text, status }]), + ); + return list; + }, + Effect.orElseSucceed((): ClaudeTaskList => new Map()), +); + function claudeTaskSteps(tasks: ClaudeTaskList): ReadonlyArray { return [...tasks].map(([id, task]) => ({ id: `task-${id}`, ...task })); } @@ -7391,12 +7449,28 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // A subagent's task tools must not replace the parent's list. if (parentToolUseId === null && !isClaudeToolResultError(toolResult)) { const taskLists = yield* Ref.get(taskListsByNativeThread); - const taskList = applyClaudeTaskTool( - taskLists.get(liveQuery.nativeThreadId) ?? new Map(), - toolCall.toolName, - claudeNativeToolInputValue(toolCall.input), - claudeNativeToolOutputValue(output), - ); + const knownTaskList = taskLists.get(liveQuery.nativeThreadId); + // This process has not seen the session's list yet (a server + // restart, or a resumed session): start from Claude's own copy. + // It already includes this call, so it is news even when + // applying the call changes nothing. + const storedTaskList = + knownTaskList === undefined && CLAUDE_TASK_TOOL_NAMES.has(toolCall.toolName) + ? yield* readClaudeTaskList({ + fileSystem, + path, + environment: adapterOptions.environment, + cwd: context.input.runtimePolicy.cwd, + nativeThreadId: liveQuery.nativeThreadId, + }) + : undefined; + const taskList = + applyClaudeTaskTool( + knownTaskList ?? storedTaskList ?? new Map(), + toolCall.toolName, + claudeNativeToolInputValue(toolCall.input), + claudeNativeToolOutputValue(output), + ) ?? (storedTaskList?.size ? storedTaskList : undefined); if (taskList !== undefined) { yield* Ref.set( taskListsByNativeThread, From 8cba66236a03ff50a2195d37d8bf72e7804b9376 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:54:24 +0200 Subject: [PATCH 2/5] fix(server): a restored empty Claude task list still clears the drawer After a restart, the first task tool can find Claude's stored list already empty, for example when it deleted the last task. That empty list was treated like a failed read, so no clearing update was emitted and later results kept rereading the store. Only a missing or unreadable directory now counts as no stored list. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 25 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 9 ++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 01b1c4d3b1d1..8e66a574cbc7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4272,6 +4272,31 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("clears the list when a restart's first task tool deletes the last stored task", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const harness = yield* makeTaskToolHarness; + const tasksDir = `${harness.configDir}/tasks/${WAKE_NATIVE_SESSION}`; + // Claude already removed the task file before T3 sees the result. + yield* fileSystem.makeDirectory(tasksDir, { recursive: true }); + yield* fileSystem.writeFileString(`${tasksDir}/.highwatermark`, "1"); + + yield* harness.startTurn(); + yield* harness.call(updateTask({ taskId: "1", status: "deleted" })); + yield* harness.finishTurn(); + + assert.deepEqual( + harness.plans().map((plan) => plan.steps), + [[]], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each(["success false", "error result", "unknown id"] as const)( "does not emit a plan update for TaskUpdate with %s", (reason) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a5a943816e09..9934d140c1f4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3317,8 +3317,9 @@ const decodeClaudeTaskFile = Schema.decodeUnknownOption(Schema.fromJsonString(Sc * keeps it at `/tasks//.json`, where the list id * is `CLAUDE_CODE_TASK_LIST_ID` or else the session id, so a list started * before a server restart is still the one later task tools edit. The format - * is Claude's and undocumented: anything missing or unreadable reads as an - * empty list rather than failing the turn. + * is Claude's and undocumented: an unreadable task file is skipped, and a + * missing or unreadable directory reads as no stored list rather than failing + * the turn. */ const readClaudeTaskList = Effect.fnUntraced( function* (input: { @@ -3363,7 +3364,7 @@ const readClaudeTaskList = Effect.fnUntraced( ); return list; }, - Effect.orElseSucceed((): ClaudeTaskList => new Map()), + Effect.orElseSucceed((): ClaudeTaskList | undefined => undefined), ); function claudeTaskSteps(tasks: ClaudeTaskList): ReadonlyArray { @@ -7470,7 +7471,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolCall.toolName, claudeNativeToolInputValue(toolCall.input), claudeNativeToolOutputValue(output), - ) ?? (storedTaskList?.size ? storedTaskList : undefined); + ) ?? storedTaskList; if (taskList !== undefined) { yield* Ref.set( taskListsByNativeThread, From babf674a5f1e8318f30eedd9567b499f67c5edc8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:36:09 +0000 Subject: [PATCH 3/5] test(server): cover Claude task recovery from native storage --- .../Adapters/ClaudeAdapterV2.test.ts | 235 +++++++++--------- 1 file changed, 118 insertions(+), 117 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9f90e2ec27f3..d1071c01569c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4816,52 +4816,53 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]; } - const makeTaskToolHarness = Effect.gen(function* () { - // Claude's on-disk task store; tests seed it to stand in for a list Claude - // kept from an earlier server process. - const configDir = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ - prefix: "t3-claude-tasks-", - }); - const harness = yield* makeWakeHarnessWithOptions({ - environment: { CLAUDE_CONFIG_DIR: configDir }, - }); - let sequence = 600; - let turn = 0; - const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; - const startTurn = Effect.fnUntraced(function* () { - turn++; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-claude-task-tools-${turn}`), - providerTurnOrdinal: turn, - text: "Update the task list.", - attachments: [], - }), - ); - }); - const call = Effect.fnUntraced(function* ( - tool: TaskToolCall, - options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean }, - ) { - for (const frame of taskToolFrames(tool, nextUuid, options)) { - yield* harness.offerAndWait(frame); - } - }); - const finishTurn = Effect.fnUntraced(function* () { - yield* harness.offerAndWait( - makeResultFrame({ uuid: nextUuid(), result: "Task list updated." }), - ); - yield* Queue.take(harness.terminalReceipts); + const makeTaskToolHarnessWithOptions = (environment: NodeJS.ProcessEnv = {}) => + Effect.gen(function* () { + const configDir = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ + prefix: "t3-claude-tasks-", + }); + const harness = yield* makeWakeHarnessWithOptions({ + environment: { CLAUDE_CONFIG_DIR: configDir, ...environment }, + }); + let sequence = 600; + let turn = 0; + const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; + const startTurn = Effect.fnUntraced(function* () { + turn++; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-claude-task-tools-${turn}`), + providerTurnOrdinal: turn, + text: "Update the task list.", + attachments: [], + }), + ); + }); + const call = Effect.fnUntraced(function* ( + tool: TaskToolCall, + options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean }, + ) { + for (const frame of taskToolFrames(tool, nextUuid, options)) { + yield* harness.offerAndWait(frame); + } + }); + const finishTurn = Effect.fnUntraced(function* () { + yield* harness.offerAndWait( + makeResultFrame({ uuid: nextUuid(), result: "Task list updated." }), + ); + return yield* Queue.take(harness.terminalReceipts); + }); + const plans = () => + harness.events.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [], + ); + return { configDir, startTurn, call, finishTurn, plans }; }); - const plans = () => - harness.events.flatMap((event) => - event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [], - ); - return { configDir, startTurn, call, finishTurn, plans }; - }); + + const makeTaskToolHarness = makeTaskToolHarnessWithOptions(); const createTask = (id: string, subject: string): TaskToolCall => ({ name: "TaskCreate", @@ -5022,86 +5023,86 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("starts from the task list Claude kept for the session before a restart", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const harness = yield* makeTaskToolHarness; - const tasksDir = `${harness.configDir}/tasks/${WAKE_NATIVE_SESSION}`; - yield* fileSystem.makeDirectory(tasksDir, { recursive: true }); - const task = (id: string, subject: string, status: string, metadata?: object) => - fileSystem.writeFileString( - `${tasksDir}/${id}.json`, - JSON.stringify({ - id, - subject, - description: "", - status, - blocks: [], - blockedBy: [], - metadata, - }), - ); - yield* task("10", "Ship", "pending"); - yield* task("1", "Inspect", "completed"); - // Claude already wrote the TaskUpdate below before T3 sees its result. - yield* task("2", "Implement", "completed"); - yield* task("3", "Internal", "pending", { _internal: true }); - yield* fileSystem.writeFileString(`${tasksDir}/4.json`, "{"); - yield* fileSystem.writeFileString(`${tasksDir}/.highwatermark`, "10"); - yield* fileSystem.writeFileString(`${tasksDir}/.lock`, ""); - - yield* harness.startTurn(); - yield* harness.call(updateTask({ taskId: "2", status: "completed" })); - yield* harness.call(createTask("11", "Verify")); - yield* harness.finishTurn(); + it.effect.each([ + { + tool: updateTask({ taskId: "2", status: "completed" }), + listId: undefined, + directory: WAKE_NATIVE_SESSION, + status: "completed", + }, + { + tool: createTask("2", "Implement"), + listId: " list/ID ", + // Claude Code 2.1.303 sanitizes the raw override, including its surrounding spaces. + directory: "-list-ID-", + status: "pending", + }, + ] as const)( + "recovers persisted tasks on the first $tool.name after restart", + ({ tool, listId, directory, status }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const harness = yield* makeTaskToolHarnessWithOptions({ CLAUDE_CODE_TASK_LIST_ID: listId }); + const tasksDir = `${harness.configDir}/tasks/${directory}`; + yield* fs.makeDirectory(tasksDir, { recursive: true }); + // The native tool has already written its result before emitting the SDK frame. + for (const task of [ + { id: "1", subject: "Inspect", status: "completed" }, + { id: "2", subject: "Implement", status }, + { id: "3", subject: "Internal", status: "pending", metadata: { _internal: true } }, + ]) { + yield* fs.writeFileString( + `${tasksDir}/${task.id}.json`, + JSON.stringify({ description: "", blocks: [], blockedBy: [], ...task }), + ); + } + yield* fs.writeFileString(`${tasksDir}/4.json`, "{"); + yield* fs.makeDirectory(`${tasksDir}/5.json`); - assert.deepEqual( - harness.plans().map((plan) => plan.steps), - [ - [ - { id: "task-1", text: "Inspect", status: "completed" }, - { id: "task-2", text: "Implement", status: "completed" }, - { id: "task-10", text: "Ship", status: "pending" }, - ], + yield* harness.startTurn(); + yield* harness.call(tool); + assert.equal((yield* harness.finishTurn()).status, "completed"); + assert.deepEqual( + harness.plans().map((plan) => plan.steps), [ - { id: "task-1", text: "Inspect", status: "completed" }, - { id: "task-2", text: "Implement", status: "completed" }, - { id: "task-10", text: "Ship", status: "pending" }, - { id: "task-11", text: "Verify", status: "pending" }, + [ + { id: "task-1", text: "Inspect", status: "completed" }, + { id: "task-2", text: "Implement", status }, + ], ], - ], - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), - ), ); - it.effect("clears the list when a restart's first task tool deletes the last stored task", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const harness = yield* makeTaskToolHarness; - const tasksDir = `${harness.configDir}/tasks/${WAKE_NATIVE_SESSION}`; - // Claude already removed the task file before T3 sees the result. - yield* fileSystem.makeDirectory(tasksDir, { recursive: true }); - yield* fileSystem.writeFileString(`${tasksDir}/.highwatermark`, "1"); - - yield* harness.startTurn(); - yield* harness.call(updateTask({ taskId: "1", status: "deleted" })); - yield* harness.finishTurn(); + it.effect( + "publishes an empty task list when the first TaskUpdate after restart deletes the last task", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const harness = yield* makeTaskToolHarness; + const tasksDir = `${harness.configDir}/tasks/${WAKE_NATIVE_SESSION}`; + // Claude has removed the task file before emitting the SDK frame. + yield* fs.makeDirectory(tasksDir, { recursive: true }); + yield* fs.writeFileString(`${tasksDir}/.highwatermark`, "1"); - assert.deepEqual( - harness.plans().map((plan) => plan.steps), - [[]], - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + yield* harness.startTurn(); + yield* harness.call(updateTask({ taskId: "1", status: "deleted" })); + assert.equal((yield* harness.finishTurn()).status, "completed"); + assert.deepEqual( + harness.plans().map((plan) => plan.steps), + [[]], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), - ), ); it.effect.each(["success false", "error result", "unknown id"] as const)( From ac45ba41c085ccc314a3fed5c351753537f999be Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:43:24 +0000 Subject: [PATCH 4/5] fix(server): restore Claude tasks from native storage --- .../Adapters/ClaudeAdapterV2.test.ts | 6 +- .../Adapters/ClaudeAdapterV2.ts | 109 +++++++++--------- 2 files changed, 57 insertions(+), 58 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d1071c01569c..d8f34f86fc7e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4821,8 +4821,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { const configDir = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped({ prefix: "t3-claude-tasks-", }); + const path = yield* Path.Path; const harness = yield* makeWakeHarnessWithOptions({ - environment: { CLAUDE_CONFIG_DIR: configDir, ...environment }, + environment: { + CLAUDE_CONFIG_DIR: path.relative(CLAUDE_TEST_RUNTIME_POLICY.cwd ?? ".", configDir), + ...environment, + }, }); let sequence = 600; let turn = 0; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 9099255fc565..cd97b6d650f3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -60,6 +60,7 @@ import { import { type ChatAttachment, ClaudeSettings, + TrimmedNonEmptyString, defaultInstanceIdForDriver, type ModelSelection, type OrchestrationV2ConversationMessage, @@ -125,6 +126,7 @@ import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { claudeSignedOutMessage, makeClaudeEnvironment, + resolveClaudeHomePath, } from "../../provider/Drivers/ClaudeHome.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, @@ -3404,60 +3406,48 @@ function applyClaudeTaskTool( } const CLAUDE_TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskUpdate", "TaskList"]); -const CLAUDE_TASK_FILE_NAME = /^\d+\.json$/; -const decodeClaudeTaskFile = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +const decodeClaudeTaskFile = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + id: TrimmedNonEmptyString, + subject: TrimmedNonEmptyString, + status: Schema.Literals(["pending", "in_progress", "completed"]), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }), + ), +); -/** - * Reads the task list Claude persisted for a session, in id order. Claude - * keeps it at `/tasks//.json`, where the list id - * is `CLAUDE_CODE_TASK_LIST_ID` or else the session id, so a list started - * before a server restart is still the one later task tools edit. The format - * is Claude's and undocumented: an unreadable task file is skipped, and a - * missing or unreadable directory reads as no stored list rather than failing - * the turn. - */ const readClaudeTaskList = Effect.fnUntraced( function* (input: { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; readonly environment: NodeJS.ProcessEnv; readonly cwd: string | null | undefined; readonly nativeThreadId: string; }) { - const { fileSystem, path } = input; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const configDirOverride = input.environment.CLAUDE_CONFIG_DIR?.trim(); - // The CLI resolves a relative config dir against its own cwd. const configDir = configDirOverride ? path.resolve(input.cwd ?? ".", configDirOverride) - : path.join(yield* HostProcess.HomeDirectory, ".claude"); - const listId = input.environment.CLAUDE_CODE_TASK_LIST_ID?.trim() || input.nativeThreadId; + : yield* resolveClaudeHomePath({ homePath: "" }); + // Claude sanitizes the raw override, including whitespace, before selecting its store. + const listId = input.environment.CLAUDE_CODE_TASK_LIST_ID || input.nativeThreadId; const directory = path.join(configDir, "tasks", listId.replace(/[^a-zA-Z0-9_-]/g, "-")); - const names = (yield* fileSystem.readDirectory(directory)).filter((name) => - CLAUDE_TASK_FILE_NAME.test(name), - ); - const tasks = yield* Effect.forEach(names, (name) => - fileSystem.readFileString(path.join(directory, name)).pipe( - Effect.map((contents) => { - const task = Option.getOrUndefined(decodeClaudeTaskFile(contents)); - const id = claudeTaskString(task, "id"); - const text = claudeTaskString(task, "subject"); - const status = claudeTaskStatus(claudeTaskField(task, "status")); - // Claude hides `_internal` tasks from TaskList; so do we. - const internal = claudeTaskField(claudeTaskField(task, "metadata"), "_internal"); - return id === undefined || text === undefined || status === undefined || internal - ? [] - : [{ id, text, status }]; - }), - Effect.orElseSucceed(() => []), - ), - ); - const list: ClaudeTaskList = new Map( - tasks - .flat() - .toSorted((left, right) => Number(left.id) - Number(right.id)) - .map(({ id, text, status }) => [id, { text, status }]), - ); - return list; + const tasks = new Map>(); + const names = (yield* fileSystem.readDirectory(directory)) + .filter((name) => /^\d+\.json$/.test(name)) + .toSorted((left, right) => Number.parseInt(left, 10) - Number.parseInt(right, 10)); + for (const name of names) { + const contents = yield* fileSystem + .readFileString(path.join(directory, name)) + .pipe(Effect.orElseSucceed(() => "")); + const task = Option.getOrUndefined(decodeClaudeTaskFile(contents)); + if (task === undefined || task.metadata?._internal) continue; + tasks.set(task.id, { + text: task.subject, + status: task.status === "in_progress" ? "running" : task.status, + }); + } + return tasks; }, Effect.orElseSucceed((): ClaudeTaskList | undefined => undefined), ); @@ -3597,7 +3587,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const openedNativeThreads = yield* Ref.make(new Set()); const latestPlanByKind = yield* Ref.make(new Map()); // Claude 5 task tools edit one list per native session, across turns. - const taskListsByNativeThread = yield* Ref.make(new Map()); + const taskListsByNativeThread = yield* Ref.make( + new Map(), + ); const planIdsByNativeItem = yield* Ref.make( new Map(), ); @@ -7758,22 +7750,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( output, completedAt: yield* DateTime.now, }); - if (parentToolUseId === null && !isClaudeToolResultError(toolResult)) { + if ( + parentToolUseId === null && + !isClaudeToolResultError(toolResult) && + CLAUDE_TASK_TOOL_NAMES.has(toolCall.toolName) + ) { const taskLists = yield* Ref.get(taskListsByNativeThread); const knownTaskList = taskLists.get(liveQuery.nativeThreadId); - // This process has not seen the session's list yet (a server - // restart, or a resumed session): start from Claude's own copy. - // It already includes this call, so it is news even when - // applying the call changes nothing. + // TaskList already supplies a snapshot. Mutations need Claude's persisted + // list once; afterward the cache also records an unavailable store. const storedTaskList = - knownTaskList === undefined && CLAUDE_TASK_TOOL_NAMES.has(toolCall.toolName) + !taskLists.has(liveQuery.nativeThreadId) && toolCall.toolName !== "TaskList" ? yield* readClaudeTaskList({ - fileSystem, - path, environment: adapterOptions.environment, cwd: context.input.runtimePolicy.cwd, nativeThreadId: liveQuery.nativeThreadId, - }) + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ) : undefined; const taskList = applyClaudeTaskTool( @@ -7782,11 +7777,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( claudeNativeToolInputValue(toolCall.input), claudeNativeToolOutputValue(output), ) ?? storedTaskList; + yield* Ref.set( + taskListsByNativeThread, + new Map(taskLists).set(liveQuery.nativeThreadId, taskList ?? knownTaskList), + ); if (taskList !== undefined) { - yield* Ref.set( - taskListsByNativeThread, - new Map(taskLists).set(liveQuery.nativeThreadId, taskList), - ); yield* emitClaudePlanProjection({ context, nativeItemId: `claude-tasks:${context.providerTurnId}`, From 0c3dcf7e09cd2a7a48cea9ce4a1e9a9f7d6110fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:53:43 +0000 Subject: [PATCH 5/5] fix(server): match native Claude task list id normalization --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 3 +-- apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d8f34f86fc7e..90d92ebb0ae1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -5037,8 +5037,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { { tool: createTask("2", "Implement"), listId: " list/ID ", - // Claude Code 2.1.303 sanitizes the raw override, including its surrounding spaces. - directory: "-list-ID-", + directory: "list-ID", status: "pending", }, ] as const)( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index cd97b6d650f3..979ec2786960 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3429,8 +3429,7 @@ const readClaudeTaskList = Effect.fnUntraced( const configDir = configDirOverride ? path.resolve(input.cwd ?? ".", configDirOverride) : yield* resolveClaudeHomePath({ homePath: "" }); - // Claude sanitizes the raw override, including whitespace, before selecting its store. - const listId = input.environment.CLAUDE_CODE_TASK_LIST_ID || input.nativeThreadId; + const listId = input.environment.CLAUDE_CODE_TASK_LIST_ID?.trim() || input.nativeThreadId; const directory = path.join(configDir, "tasks", listId.replace(/[^a-zA-Z0-9_-]/g, "-")); const tasks = new Map>(); const names = (yield* fileSystem.readDirectory(directory))