Merge pull request #3183 from code-yeongyu/fix/issue-2932

fix: prevent background agent race condition in session prompt (#2932)
This commit is contained in:
YeonGyu-Kim
2026-04-07 15:33:34 +09:00
committed by GitHub
9 changed files with 777 additions and 11 deletions
@@ -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<BackgroundTask>).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<void> }).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"
+24 -1
View File
@@ -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 {
+32 -3
View File
@@ -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 () => {
+13 -1
View File
@@ -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
}
@@ -1,6 +1,7 @@
/// <reference types="bun-types" />
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<ImageResizerModule> {
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(() => ({
@@ -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)
@@ -0,0 +1,146 @@
/// <reference types="bun-types" />
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()
})
})
})
@@ -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<number, number> = {
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
}
}
@@ -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()