diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 8da21b20236e2..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,6 +48,32 @@ describe("tailClaudeTaskOutput", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("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(); + const stop = yield* Deferred.make(); + yield* tailClaudeTaskOutput({ + root, + sessionId: SESSION_ID, + taskId: TASK_ID, + 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)), + ); + 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(