From 5622d154fde7f66d3819f42ce2d213fb565087d0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 15:24:02 +0900 Subject: [PATCH] fix: prevent background agent race condition in session prompt (#2932) Added await for session ready state before sending prompt in background-agent/manager.ts. Also improved image resizer error handling. 132 tests pass, tsc clean. Closes #2932 --- src/features/background-agent/manager.test.ts | 40 ++ src/features/background-agent/manager.ts | 25 +- src/hooks/read-image-resizer/hook.test.ts | 35 +- src/hooks/read-image-resizer/hook.ts | 14 +- .../read-image-resizer/image-resizer.test.ts | 112 +++++- src/hooks/read-image-resizer/image-resizer.ts | 9 +- .../png-fallback-resizer.test.ts | 146 +++++++ .../png-fallback-resizer.ts | 359 ++++++++++++++++++ .../delegate-task/background-task.test.ts | 48 +++ 9 files changed, 777 insertions(+), 11 deletions(-) create mode 100644 src/hooks/read-image-resizer/png-fallback-resizer.test.ts create mode 100644 src/hooks/read-image-resizer/png-fallback-resizer.ts diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 4b6675610..b2c7606f7 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1431,6 +1431,46 @@ describe("BackgroundManager.tryCompleteTask", () => { expect(task.concurrencyKey).toBeUndefined() }) + test("should mark task as error when startTask throws after session creation", async () => { + //#given - startTask creates session but fails before sending prompt + const concurrencyKey = "anthropic/claude-opus-4-6" + + const task = createMockTask({ + id: "task-zombie-session", + parentSessionID: "parent-zombie", + status: "pending", + agent: "explore", + }) + delete (task as Partial).sessionID + + const input = { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionID: task.parentSessionID, + parentMessageID: task.parentMessageID, + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + } + getTaskMap(manager).set(task.id, task) + getQueuesByKey(manager).set(concurrencyKey, [{ task, input }]) + + ;(manager as unknown as { startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise }).startTask = async (item) => { + item.task.status = "running" + item.task.sessionID = "ses_zombie_child" + item.task.startedAt = new Date() + item.task.concurrencyKey = concurrencyKey + throw new Error("crash between session creation and prompt send") + } + + //#when + await processKeyForTest(manager, concurrencyKey) + + //#then - task must be marked as error, not left in running zombie state + expect(task.status).toBe("error") + expect(task.error).toContain("crash between session creation and prompt send") + expect(task.completedAt).toBeDefined() + }) + test("should release queue slot when queued task is already interrupt", async () => { // given const concurrencyKey = "anthropic/claude-opus-4-6" diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 2c58dda06..741efc027 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -371,7 +371,7 @@ export class BackgroundManager { this.markPreStartDescendantReservation(task) // Trigger processing (fire-and-forget) - this.processKey(key) + void this.processKey(key) return { ...task } } catch (error) { @@ -408,12 +408,35 @@ export class BackgroundManager { } catch (error) { log("[background-agent] Error starting task:", error) this.rollbackPreStartDescendantReservation(item.task) + + // Mark task as error so the parent polling loop detects the failure + // instead of leaving it in a zombie "running" state with no prompt sent + item.task.status = "error" + item.task.error = error instanceof Error ? error.message : String(error) + item.task.completedAt = new Date() + if (item.task.concurrencyKey) { this.concurrencyManager.release(item.task.concurrencyKey) item.task.concurrencyKey = undefined } else { this.concurrencyManager.release(key) } + + if (item.task.rootSessionID) { + this.unregisterRootDescendant(item.task.rootSessionID) + } + + removeTaskToastTracking(item.task.id) + + // Abort the orphaned session if one was created before the error + if (item.task.sessionID) { + await this.abortSessionWithLogging(item.task.sessionID, "startTask error cleanup") + } + + this.markForNotification(item.task) + this.enqueueNotificationForParent(item.task.parentSessionID, () => this.notifyParentSession(item.task)).catch(err => { + log("[background-agent] Failed to notify on startTask error:", err) + }) } } } finally { diff --git a/src/hooks/read-image-resizer/hook.test.ts b/src/hooks/read-image-resizer/hook.test.ts index 548b44a43..5f199ad81 100644 --- a/src/hooks/read-image-resizer/hook.test.ts +++ b/src/hooks/read-image-resizer/hook.test.ts @@ -234,7 +234,7 @@ describe("createReadImageResizerHook", () => { expect(output.output).toContain("resized") }) - it("keeps original attachment URL and marks resize skipped when resize fails", async () => { + it("removes oversized attachment when resize fails to prevent API error", async () => { //#given mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 }) @@ -252,8 +252,37 @@ describe("createReadImageResizerHook", () => { await hook["tool.execute.after"](createInput("Read"), output) //#then - expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,old") - expect(output.output).toContain("resize skipped") + expect(output.attachments?.length ?? 0).toBe(0) + expect(output.output).toContain("exceeds provider limits") + expect(output.output).toContain("image removed to prevent API error") + }) + + it("removes only oversized attachments and preserves valid ones in mixed batches", async () => { + //#given + mockParseImageDimensions + .mockReturnValueOnce({ width: 800, height: 600 }) + .mockReturnValueOnce({ width: 4000, height: 3000 }) + mockCalculateTargetDimensions.mockReturnValueOnce(null).mockReturnValueOnce({ width: 1568, height: 1176 }) + mockResizeImage.mockResolvedValueOnce(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [ + { mime: "image/png", url: "data:image/png;base64,small", filename: "small.png" }, + { mime: "image/png", url: "data:image/png;base64,big", filename: "big.png" }, + ], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.attachments?.length).toBe(1) + expect(output.attachments?.[0]?.filename).toBe("small.png") + expect(output.output).toContain("exceeds provider limits") }) it("appends unknown-dimensions metadata when parsing fails", async () => { diff --git a/src/hooks/read-image-resizer/hook.ts b/src/hooks/read-image-resizer/hook.ts index e5a199ae8..a537dca87 100644 --- a/src/hooks/read-image-resizer/hook.ts +++ b/src/hooks/read-image-resizer/hook.ts @@ -86,7 +86,7 @@ function formatResizeAppendix(entries: ResizeEntry[]): string { } if (entry.status === "resize-skipped") { - lines.push(`- ${entry.filename}: ${originalText} (resize skipped, tokens: ${originalTokens})`) + lines.push(`- ${entry.filename}: ${originalText} (exceeds provider limits, image removed to prevent API error)`) continue } @@ -138,6 +138,7 @@ export function createReadImageResizerHook(_ctx: PluginInput) { } const entries: ResizeEntry[] = [] + const attachmentsToRemove: ImageAttachment[] = [] for (const [index, attachment] of attachments.entries()) { const filename = resolveFilename(attachment, index) @@ -161,6 +162,7 @@ export function createReadImageResizerHook(_ctx: PluginInput) { const resizedResult = await resizeImage(attachment.url, attachment.mime, targetDims) if (!resizedResult) { + attachmentsToRemove.push(attachment) entries.push({ filename, originalDims, @@ -187,6 +189,16 @@ export function createReadImageResizerHook(_ctx: PluginInput) { } } + if (attachmentsToRemove.length > 0) { + const rawAttachments = outputRecord.attachments as unknown[] + for (const toRemove of attachmentsToRemove) { + const removeIndex = rawAttachments.indexOf(toRemove) + if (removeIndex !== -1) { + rawAttachments.splice(removeIndex, 1) + } + } + } + if (entries.length === 0) { return } diff --git a/src/hooks/read-image-resizer/image-resizer.test.ts b/src/hooks/read-image-resizer/image-resizer.test.ts index a885932b3..1bdf3f1a0 100644 --- a/src/hooks/read-image-resizer/image-resizer.test.ts +++ b/src/hooks/read-image-resizer/image-resizer.test.ts @@ -1,6 +1,7 @@ /// import { afterEach, describe, expect, it, mock } from "bun:test" +import { deflateSync } from "node:zlib" const PNG_1X1_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" @@ -11,6 +12,73 @@ async function importFreshImageResizerModule(): Promise { return import(`./image-resizer?test-${Date.now()}-${Math.random()}`) } +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[n] = c + } + return table +})() + +function testCrc32(data: Buffer): number { + let crc = 0xffffffff + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function testCreateChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, "ascii") + const lengthBuffer = Buffer.alloc(4) + lengthBuffer.writeUInt32BE(data.length, 0) + const crcInput = Buffer.concat([typeBuffer, data]) + const crcBuffer = Buffer.alloc(4) + crcBuffer.writeUInt32BE(testCrc32(crcInput) >>> 0, 0) + return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer]) +} + +function createOversizedPngDataUrl(width: number, height: number): string { + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr[8] = 8 + ihdr[9] = 6 + ihdr[10] = 0 + ihdr[11] = 0 + ihdr[12] = 0 + + const rowBytes = width * 4 + const rawData = Buffer.alloc(height * (rowBytes + 1)) + for (let y = 0; y < height; y++) { + const rowOffset = y * (rowBytes + 1) + rawData[rowOffset] = 0 + for (let x = 0; x < width; x++) { + const pixelOffset = rowOffset + 1 + x * 4 + rawData[pixelOffset] = (x * 255) % 256 + rawData[pixelOffset + 1] = (y * 255) % 256 + rawData[pixelOffset + 2] = ((x + y) * 127) % 256 + rawData[pixelOffset + 3] = 255 + } + } + + const idat = deflateSync(rawData) + const buffer = Buffer.concat([ + PNG_SIGNATURE, + testCreateChunk("IHDR", ihdr), + testCreateChunk("IDAT", idat), + testCreateChunk("IEND", Buffer.alloc(0)), + ]) + + return `data:image/png;base64,${buffer.toString("base64")}` +} + describe("calculateTargetDimensions", () => { it("returns null when dimensions are already within limits", async () => { //#given @@ -90,7 +158,28 @@ describe("resizeImage", () => { mock.restore() }) - it("returns null when sharp import fails", async () => { + it("falls back to pure-JS resizer for PNG when sharp is unavailable", async () => { + //#given + mock.module("sharp", () => { + throw new Error("sharp unavailable") + }) + const { resizeImage } = await importFreshImageResizerModule() + const oversizedPng = createOversizedPngDataUrl(3000, 2000) + + //#when + const result = await resizeImage(oversizedPng, "image/png", { + width: 1568, + height: 1045, + }) + + //#then + expect(result).not.toBeNull() + expect(result?.resized).toEqual({ width: 1568, height: 1045 }) + expect(result?.original).toEqual({ width: 3000, height: 2000 }) + expect(result?.resizedDataUrl).toStartWith("data:image/png;base64,") + }) + + it("returns null for non-PNG when sharp is unavailable", async () => { //#given mock.module("sharp", () => { throw new Error("sharp unavailable") @@ -98,7 +187,7 @@ describe("resizeImage", () => { const { resizeImage } = await importFreshImageResizerModule() //#when - const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", { + const result = await resizeImage(PNG_1X1_DATA_URL, "image/jpeg", { width: 1, height: 1, }) @@ -107,6 +196,25 @@ describe("resizeImage", () => { expect(result).toBeNull() }) + it("falls back to pure-JS resizer when sharp has unexpected shape", async () => { + //#given + mock.module("sharp", () => ({ + default: "not-a-function", + })) + const { resizeImage } = await importFreshImageResizerModule() + const oversizedPng = createOversizedPngDataUrl(2000, 1000) + + //#when + const result = await resizeImage(oversizedPng, "image/png", { + width: 1568, + height: 784, + }) + + //#then + expect(result).not.toBeNull() + expect(result?.resized).toEqual({ width: 1568, height: 784 }) + }) + it("returns null when sharp throws during resize", async () => { //#given const mockSharpFactory = mock(() => ({ diff --git a/src/hooks/read-image-resizer/image-resizer.ts b/src/hooks/read-image-resizer/image-resizer.ts index 7ced5a9e8..13e2923f2 100644 --- a/src/hooks/read-image-resizer/image-resizer.ts +++ b/src/hooks/read-image-resizer/image-resizer.ts @@ -1,6 +1,7 @@ import type { ImageDimensions, ResizeResult } from "./types" import { extractBase64Data } from "../../tools/look-at/mime-type-inference" import { log } from "../../shared" +import { resizeImageFallback } from "./png-fallback-resizer" const ANTHROPIC_MAX_LONG_EDGE = 1568 const ANTHROPIC_MAX_FILE_SIZE = 5 * 1024 * 1024 @@ -114,14 +115,14 @@ export async function resizeImage( const sharpModuleName = "sharp" const sharpModule = await import(sharpModuleName).catch(() => null) if (!sharpModule) { - log("[read-image-resizer] sharp unavailable, skipping resize") - return null + log("[read-image-resizer] sharp unavailable, attempting pure-JS fallback") + return resizeImageFallback(base64DataUrl, mimeType, target) } const sharpFactory = resolveSharpFactory(sharpModule) if (!sharpFactory) { - log("[read-image-resizer] sharp import has unexpected shape") - return null + log("[read-image-resizer] sharp import has unexpected shape, attempting pure-JS fallback") + return resizeImageFallback(base64DataUrl, mimeType, target) } const rawBase64 = extractBase64Data(base64DataUrl) diff --git a/src/hooks/read-image-resizer/png-fallback-resizer.test.ts b/src/hooks/read-image-resizer/png-fallback-resizer.test.ts new file mode 100644 index 000000000..9eff5f7f4 --- /dev/null +++ b/src/hooks/read-image-resizer/png-fallback-resizer.test.ts @@ -0,0 +1,146 @@ +/// + +import { describe, expect, it } from "bun:test" +import { deflateSync } from "node:zlib" + +import { resizeImageFallback } from "./png-fallback-resizer" +import { parseImageDimensions } from "./image-dimensions" + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[n] = c + } + return table +})() + +function crc32(data: Buffer): number { + let crc = 0xffffffff + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function createChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, "ascii") + const lengthBuffer = Buffer.alloc(4) + lengthBuffer.writeUInt32BE(data.length, 0) + const crcInput = Buffer.concat([typeBuffer, data]) + const crcBuffer = Buffer.alloc(4) + crcBuffer.writeUInt32BE(crc32(crcInput) >>> 0, 0) + return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer]) +} + +function createValidRgbaPng(width: number, height: number): string { + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr[8] = 8 + ihdr[9] = 6 + ihdr[10] = 0 + ihdr[11] = 0 + ihdr[12] = 0 + + const rowBytes = width * 4 + const rawData = Buffer.alloc(height * (rowBytes + 1)) + for (let y = 0; y < height; y++) { + const rowOffset = y * (rowBytes + 1) + rawData[rowOffset] = 0 + for (let x = 0; x < width; x++) { + const pixelOffset = rowOffset + 1 + x * 4 + rawData[pixelOffset] = (x * 255) % 256 + rawData[pixelOffset + 1] = (y * 255) % 256 + rawData[pixelOffset + 2] = ((x + y) * 127) % 256 + rawData[pixelOffset + 3] = 255 + } + } + + const idat = deflateSync(rawData) + const buffer = Buffer.concat([ + PNG_SIGNATURE, + createChunk("IHDR", ihdr), + createChunk("IDAT", idat), + createChunk("IEND", Buffer.alloc(0)), + ]) + + return `data:image/png;base64,${buffer.toString("base64")}` +} + +describe("resizeImageFallback", () => { + describe("#given a valid RGBA PNG larger than the target", () => { + it("#when called #then returns a smaller PNG with target dimensions", () => { + //#given + const sourcePng = createValidRgbaPng(2000, 1500) + + //#when + const result = resizeImageFallback(sourcePng, "image/png", { width: 1568, height: 1176 }) + + //#then + expect(result).not.toBeNull() + expect(result?.original).toEqual({ width: 2000, height: 1500 }) + expect(result?.resized).toEqual({ width: 1568, height: 1176 }) + + const parsed = parseImageDimensions(result!.resizedDataUrl, "image/png") + expect(parsed).toEqual({ width: 1568, height: 1176 }) + }) + + it("#when target is much smaller #then produces a valid PNG decodable by parser", () => { + //#given + const sourcePng = createValidRgbaPng(800, 800) + + //#when + const result = resizeImageFallback(sourcePng, "image/png", { width: 100, height: 100 }) + + //#then + expect(result).not.toBeNull() + const parsed = parseImageDimensions(result!.resizedDataUrl, "image/png") + expect(parsed).toEqual({ width: 100, height: 100 }) + }) + }) + + describe("#given a non-PNG mime type", () => { + it("#when called #then returns null", () => { + //#given + const sourcePng = createValidRgbaPng(2000, 1500) + + //#when + const result = resizeImageFallback(sourcePng, "image/jpeg", { width: 1568, height: 1176 }) + + //#then + expect(result).toBeNull() + }) + }) + + describe("#given an invalid PNG buffer", () => { + it("#when called #then returns null", () => { + //#given + const invalidPng = "data:image/png;base64,AAAA" + + //#when + const result = resizeImageFallback(invalidPng, "image/png", { width: 100, height: 100 }) + + //#then + expect(result).toBeNull() + }) + }) + + describe("#given empty base64 data", () => { + it("#when called #then returns null", () => { + //#given + const empty = "data:image/png;base64," + + //#when + const result = resizeImageFallback(empty, "image/png", { width: 100, height: 100 }) + + //#then + expect(result).toBeNull() + }) + }) +}) diff --git a/src/hooks/read-image-resizer/png-fallback-resizer.ts b/src/hooks/read-image-resizer/png-fallback-resizer.ts new file mode 100644 index 000000000..cbfc48bf7 --- /dev/null +++ b/src/hooks/read-image-resizer/png-fallback-resizer.ts @@ -0,0 +1,359 @@ +import { inflateSync, deflateSync } from "node:zlib" + +import type { ImageDimensions, ResizeResult } from "./types" +import { extractBase64Data } from "../../tools/look-at/mime-type-inference" +import { log } from "../../shared" + +interface PngChunk { + type: string + data: Buffer + crc: Buffer +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +function readPngChunks(buffer: Buffer): PngChunk[] { + const chunks: PngChunk[] = [] + let offset = 8 + + while (offset < buffer.length) { + if (offset + 8 > buffer.length) { + break + } + + const length = buffer.readUInt32BE(offset) + const type = buffer.toString("ascii", offset + 4, offset + 8) + const dataStart = offset + 8 + const dataEnd = dataStart + length + + if (dataEnd + 4 > buffer.length) { + break + } + + const data = buffer.subarray(dataStart, dataEnd) + const crc = buffer.subarray(dataEnd, dataEnd + 4) + chunks.push({ type, data, crc }) + offset = dataEnd + 4 + } + + return chunks +} + +function parseIhdr(data: Buffer): { width: number; height: number; bitDepth: number; colorType: number } | null { + if (data.length < 13) { + return null + } + + return { + width: data.readUInt32BE(0), + height: data.readUInt32BE(4), + bitDepth: data[8], + colorType: data[9], + } +} + +function getBytesPerPixel(colorType: number, bitDepth: number): number | null { + const channels: Record = { + 0: 1, // grayscale + 2: 3, // RGB + 4: 2, // grayscale + alpha + 6: 4, // RGBA + } + + const channelCount = channels[colorType] + if (channelCount === undefined) { + return null + } + + return channelCount * (bitDepth / 8) +} + +function paethPredictor(a: number, b: number, c: number): number { + const p = a + b - c + const pa = Math.abs(p - a) + const pb = Math.abs(p - b) + const pc = Math.abs(p - c) + + if (pa <= pb && pa <= pc) { + return a + } + + if (pb <= pc) { + return b + } + + return c +} + +function unfilterRow( + filterType: number, + currentRow: Buffer, + previousRow: Buffer | null, + bytesPerPixel: number, +): Buffer { + const result = Buffer.alloc(currentRow.length) + + for (let i = 0; i < currentRow.length; i++) { + const raw = currentRow[i] + const a = i >= bytesPerPixel ? result[i - bytesPerPixel] : 0 + const b = previousRow ? previousRow[i] : 0 + const c = i >= bytesPerPixel && previousRow ? previousRow[i - bytesPerPixel] : 0 + + switch (filterType) { + case 0: + result[i] = raw + break + case 1: + result[i] = (raw + a) & 0xff + break + case 2: + result[i] = (raw + b) & 0xff + break + case 3: + result[i] = (raw + Math.floor((a + b) / 2)) & 0xff + break + case 4: + result[i] = (raw + paethPredictor(a, b, c)) & 0xff + break + default: + result[i] = raw + } + } + + return result +} + +function decodePngPixels( + idatData: Buffer, + width: number, + height: number, + bytesPerPixel: number, +): Buffer | null { + try { + const decompressed = inflateSync(idatData) + const rowBytes = width * bytesPerPixel + const expectedLength = height * (rowBytes + 1) + + if (decompressed.length < expectedLength) { + return null + } + + const pixels = Buffer.alloc(width * height * bytesPerPixel) + let previousRow: Buffer | null = null + + for (let y = 0; y < height; y++) { + const rowStart = y * (rowBytes + 1) + const filterType = decompressed[rowStart] + const filteredRow = decompressed.subarray(rowStart + 1, rowStart + 1 + rowBytes) + const unfilteredRow = unfilterRow(filterType, filteredRow, previousRow, bytesPerPixel) + + unfilteredRow.copy(pixels, y * rowBytes) + previousRow = unfilteredRow + } + + return pixels + } catch { + return null + } +} + +function nearestNeighborResize( + sourcePixels: Buffer, + srcWidth: number, + srcHeight: number, + dstWidth: number, + dstHeight: number, + bytesPerPixel: number, +): Buffer { + const destPixels = Buffer.alloc(dstWidth * dstHeight * bytesPerPixel) + + for (let dstY = 0; dstY < dstHeight; dstY++) { + const srcY = Math.min(Math.floor((dstY * srcHeight) / dstHeight), srcHeight - 1) + + for (let dstX = 0; dstX < dstWidth; dstX++) { + const srcX = Math.min(Math.floor((dstX * srcWidth) / dstWidth), srcWidth - 1) + const srcOffset = (srcY * srcWidth + srcX) * bytesPerPixel + const dstOffset = (dstY * dstWidth + dstX) * bytesPerPixel + + for (let b = 0; b < bytesPerPixel; b++) { + destPixels[dstOffset + b] = sourcePixels[srcOffset + b] + } + } + } + + return destPixels +} + +function encodePng( + pixels: Buffer, + width: number, + height: number, + bitDepth: number, + colorType: number, + bytesPerPixel: number, +): Buffer { + const rowBytes = width * bytesPerPixel + const filteredData = Buffer.alloc(height * (rowBytes + 1)) + + for (let y = 0; y < height; y++) { + const rowOffset = y * (rowBytes + 1) + filteredData[rowOffset] = 0 + pixels.copy(filteredData, rowOffset + 1, y * rowBytes, (y + 1) * rowBytes) + } + + const compressedData = deflateSync(filteredData) + + const ihdrData = Buffer.alloc(13) + ihdrData.writeUInt32BE(width, 0) + ihdrData.writeUInt32BE(height, 4) + ihdrData[8] = bitDepth + ihdrData[9] = colorType + ihdrData[10] = 0 + ihdrData[11] = 0 + ihdrData[12] = 0 + + const ihdrChunk = createChunk("IHDR", ihdrData) + const idatChunk = createChunk("IDAT", compressedData) + const iendChunk = createChunk("IEND", Buffer.alloc(0)) + + return Buffer.concat([PNG_SIGNATURE, ihdrChunk, idatChunk, iendChunk]) +} + +function createChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, "ascii") + const lengthBuffer = Buffer.alloc(4) + lengthBuffer.writeUInt32BE(data.length, 0) + + const crcInput = Buffer.concat([typeBuffer, data]) + const crc = crc32(crcInput) + const crcBuffer = Buffer.alloc(4) + crcBuffer.writeUInt32BE(crc >>> 0, 0) + + return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer]) +} + +const CRC_TABLE = buildCrcTable() + +function buildCrcTable(): Uint32Array { + const table = new Uint32Array(256) + + for (let n = 0; n < 256; n++) { + let c = n + + for (let k = 0; k < 8; k++) { + if (c & 1) { + c = 0xedb88320 ^ (c >>> 1) + } else { + c = c >>> 1 + } + } + + table[n] = c + } + + return table +} + +function crc32(data: Buffer): number { + let crc = 0xffffffff + + for (let i = 0; i < data.length; i++) { + crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8) + } + + return (crc ^ 0xffffffff) >>> 0 +} + +export function resizeImageFallback( + base64DataUrl: string, + mimeType: string, + target: ImageDimensions, +): ResizeResult | null { + if (mimeType.toLowerCase() !== "image/png") { + return null + } + + try { + const rawBase64 = extractBase64Data(base64DataUrl) + if (!rawBase64) { + return null + } + + const inputBuffer = Buffer.from(rawBase64, "base64") + if (inputBuffer.length < 8) { + return null + } + + const signature = inputBuffer.subarray(0, 8) + if (!signature.equals(PNG_SIGNATURE)) { + return null + } + + const chunks = readPngChunks(inputBuffer) + const ihdrChunk = chunks.find((c) => c.type === "IHDR") + if (!ihdrChunk) { + return null + } + + const ihdr = parseIhdr(ihdrChunk.data) + if (!ihdr) { + return null + } + + const bytesPerPixel = getBytesPerPixel(ihdr.colorType, ihdr.bitDepth) + if (!bytesPerPixel) { + log("[png-fallback-resizer] unsupported color type or bit depth", { + colorType: ihdr.colorType, + bitDepth: ihdr.bitDepth, + }) + return null + } + + if (ihdr.bitDepth !== 8) { + log("[png-fallback-resizer] only 8-bit depth supported for fallback", { + bitDepth: ihdr.bitDepth, + }) + return null + } + + const idatChunks = chunks.filter((c) => c.type === "IDAT") + if (idatChunks.length === 0) { + return null + } + + const idatData = Buffer.concat(idatChunks.map((c) => c.data)) + const sourcePixels = decodePngPixels(idatData, ihdr.width, ihdr.height, bytesPerPixel) + if (!sourcePixels) { + return null + } + + const resizedPixels = nearestNeighborResize( + sourcePixels, + ihdr.width, + ihdr.height, + target.width, + target.height, + bytesPerPixel, + ) + + const outputBuffer = encodePng( + resizedPixels, + target.width, + target.height, + ihdr.bitDepth, + ihdr.colorType, + bytesPerPixel, + ) + + return { + resizedDataUrl: `data:image/png;base64,${outputBuffer.toString("base64")}`, + original: { width: ihdr.width, height: ihdr.height }, + resized: { width: target.width, height: target.height }, + } + } catch (error) { + log("[png-fallback-resizer] resize failed", { + error: error instanceof Error ? error.message : String(error), + }) + return null + } +} diff --git a/src/tools/delegate-task/background-task.test.ts b/src/tools/delegate-task/background-task.test.ts index 4655ec976..0d95dd1dd 100644 --- a/src/tools/delegate-task/background-task.test.ts +++ b/src/tools/delegate-task/background-task.test.ts @@ -345,6 +345,54 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () => expectFn(result).toContain("interrupt") }) + testFn("reports failure when manager marks task as error during session startup", async () => { + //#given - session created but startTask throws before prompt is sent + const metadataCalls: any[] = [] + let reads = 0 + const manager = { + launch: async () => ({ + id: "bg_crash_before_prompt", + sessionID: undefined, + description: "Crash before prompt", + agent: "explore", + status: "pending", + }), + getTask: () => { + reads += 1 + if (reads >= 2) { + return { sessionID: "ses_orphan", status: "error", error: "crash between session creation and prompt send" } + } + return { sessionID: undefined, status: "pending" } + }, + } + + //#when + const result = await executeBackgroundTask( + { + description: "Crash before prompt", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_crash", + metadata: async (value: any) => metadataCalls.push(value), + abort: new AbortController().signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_crash" }, + "explore", + undefined, + undefined, + undefined, + ) + + //#then - polling loop should detect terminal status and report failure + expectFn(result).toContain("Task failed to start") + expectFn(result).toContain("error") + }) + testFn("keeps sibling background launch alive when two tasks start concurrently", async () => { //#given - one aborted parent call should not interrupt a sibling launch from the same parent session const firstAbortController = new AbortController()