From 3a6b3e8d5cc953fea3c148b286fab6f4ecd9bc47 Mon Sep 17 00:00:00 2001 From: Abhishek B R Date: Sun, 1 Nov 2026 17:44:22 +0530 Subject: [PATCH 1/2] fix(server): truncated Claude task output no longer starts with a replacement character --- .../Adapters/ClaudeTaskOutputTail.test.ts | 24 +++++++++++++++++++ .../Adapters/ClaudeTaskOutputTail.ts | 5 +++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 8da21b20236e2..625a5b3834856 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -48,6 +48,30 @@ describe("tailClaudeTaskOutput", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("drops a split character when the file is truncated and rewritten", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { root, tasks, file } = yield* makeTaskFile; + yield* fileSystem.makeDirectory(tasks, { recursive: true }); + // End the first read inside the two-byte é. + yield* fileSystem.writeFile(file, new TextEncoder().encode("café").subarray(0, 4)); + const chunks = yield* Queue.unbounded(); + yield* tailClaudeTaskOutput({ + root, + sessionId: SESSION_ID, + taskId: TASK_ID, + stop: yield* Deferred.make(), + onChunk: (chunk) => Queue.offer(chunks, chunk), + }).pipe(Effect.forkScoped); + assert.equal(yield* Queue.take(chunks), "caf"); + + yield* fileSystem.writeFileString(file, "ok\n"); + assert.equal(yield* Queue.take(chunks), "ok\n"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("skips ahead when the command writes faster than it is read", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts index 0bd369eb4df3a..77505d24a5bbf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts @@ -88,7 +88,10 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct Effect.map((info) => Number(info.size)), Effect.orElseSucceed(() => offset), ); - if (size < offset) offset = 0; + if (size < offset) { + offset = 0; + decoder = new TextDecoder("utf-8", { fatal: false }); + } if (size > offset) { const start = size - offset > MAX_READ_BYTES ? size - MAX_READ_BYTES : offset; const bytes = yield* Effect.scoped( From dc7758065062608529d62f6d289a2f656a1facad Mon Sep 17 00:00:00 2001 From: Abhishek B R Date: Sun, 1 Nov 2026 20:54:47 +0530 Subject: [PATCH 2/2] test(server): tailer truncation test no longer waits on the live clock Completing the stop deferred after the rewrite triggers the final read directly, so the test does not depend on the 250ms poll. --- .../Adapters/ClaudeTaskOutputTail.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 625a5b3834856..b70b7322d5e63 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -20,7 +20,7 @@ const makeTaskFile = Effect.gen(function* () { return { root, tasks, file: path.join(tasks, `${TASK_ID}.output`) }; }); -// TestClock cannot interleave with real filesystem I/O; use the live clock. +// TestClock cannot interleave with real filesystem I/O; tests that wait on a poll use the live clock. describe("tailClaudeTaskOutput", () => { it.live("follows a file that appears after the task starts, across split characters", () => Effect.scoped( @@ -48,7 +48,7 @@ describe("tailClaudeTaskOutput", () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.live("drops a split character when the file is truncated and rewritten", () => + it.effect("drops a split character when the file is truncated and rewritten", () => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -57,16 +57,18 @@ describe("tailClaudeTaskOutput", () => { // End the first read inside the two-byte é. yield* fileSystem.writeFile(file, new TextEncoder().encode("café").subarray(0, 4)); const chunks = yield* Queue.unbounded(); + const stop = yield* Deferred.make(); yield* tailClaudeTaskOutput({ root, sessionId: SESSION_ID, taskId: TASK_ID, - stop: yield* Deferred.make(), + stop, onChunk: (chunk) => Queue.offer(chunks, chunk), }).pipe(Effect.forkScoped); assert.equal(yield* Queue.take(chunks), "caf"); yield* fileSystem.writeFileString(file, "ok\n"); + yield* Deferred.succeed(stop, undefined); assert.equal(yield* Queue.take(chunks), "ok\n"); }), ).pipe(Effect.provide(NodeServices.layer)),