fix: refresh lastUpdate on all message.part.updated events, not just tool events

Reasoning/thinking models (Oracle, Claude Opus) were being killed by the
stale timeout because lastUpdate was only refreshed on tool-type events.
During extended thinking, no tool events fire, so after 3 minutes the
task was incorrectly marked as stale and aborted.

Move progress initialization and lastUpdate refresh before the tool-type
conditional so any message.part.updated event (text, thinking, tool)
keeps the task alive.
This commit is contained in:
YeonGyu-Kim 2026-02-14 13:32:17 +09:00
parent a809ac3dfc
commit 4ab93c0cf7
3 changed files with 171 additions and 11 deletions

View File

@ -69,13 +69,14 @@ export function handleBackgroundEvent(args: {
const type = getString(props, "type")
const tool = getString(props, "tool")
if (!task.progress) {
task.progress = { toolCalls: 0, lastUpdate: new Date() }
}
task.progress.lastUpdate = new Date()
if (type === "tool" || tool) {
if (!task.progress) {
task.progress = { toolCalls: 0, lastUpdate: new Date() }
}
task.progress.toolCalls += 1
task.progress.lastTool = tool
task.progress.lastUpdate = new Date()
}
}

View File

@ -3045,3 +3045,161 @@ describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
}
})
})
describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
test("should update lastUpdate on text-type message.part.updated event", () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const oldUpdate = new Date(Date.now() - 300_000)
const task: BackgroundTask = {
id: "task-text-1",
sessionID: "session-text-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Thinking task",
prompt: "Think deeply",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 2,
lastUpdate: oldUpdate,
},
}
getTaskMap(manager).set(task.id, task)
//#when - a text-type message.part.updated event arrives
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-text-1", type: "text" },
})
//#then - lastUpdate should be refreshed, toolCalls should NOT change
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime())
expect(task.progress!.toolCalls).toBe(2)
})
test("should update lastUpdate on thinking-type message.part.updated event", () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const oldUpdate = new Date(Date.now() - 300_000)
const task: BackgroundTask = {
id: "task-thinking-1",
sessionID: "session-thinking-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reasoning task",
prompt: "Reason about architecture",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: oldUpdate,
},
}
getTaskMap(manager).set(task.id, task)
//#when - a thinking-type message.part.updated event arrives
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-thinking-1", type: "thinking" },
})
//#then - lastUpdate should be refreshed, toolCalls should remain 0
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime())
expect(task.progress!.toolCalls).toBe(0)
})
test("should initialize progress on first non-tool event", () => {
//#given - a running task with NO progress field
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-init-1",
sessionID: "session-init-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "New task",
prompt: "Start thinking",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 60_000),
}
getTaskMap(manager).set(task.id, task)
//#when - a text-type event arrives before any tool call
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-init-1", type: "text" },
})
//#then - progress should be initialized with toolCalls: 0 and fresh lastUpdate
expect(task.progress).toBeDefined()
expect(task.progress!.toolCalls).toBe(0)
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(Date.now() - 5000)
})
test("should NOT mark thinking model as stale when text events refresh lastUpdate", async () => {
//#given - a running task where text events keep lastUpdate fresh
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-alive-1",
sessionID: "session-alive-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Long thinking task",
prompt: "Deep reasoning",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when - a text event arrives, then stale check runs
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-alive-1", type: "text" },
})
await manager["checkAndInterruptStaleTasks"]()
//#then - task should still be running (text event refreshed lastUpdate)
expect(task.status).toBe("running")
})
})

View File

@ -662,16 +662,17 @@ export class BackgroundManager {
this.idleDeferralTimers.delete(task.id)
}
if (partInfo?.type === "tool" || partInfo?.tool) {
if (!task.progress) {
task.progress = {
toolCalls: 0,
lastUpdate: new Date(),
}
if (!task.progress) {
task.progress = {
toolCalls: 0,
lastUpdate: new Date(),
}
}
task.progress.lastUpdate = new Date()
if (partInfo?.type === "tool" || partInfo?.tool) {
task.progress.toolCalls += 1
task.progress.lastTool = partInfo.tool
task.progress.lastUpdate = new Date()
}
}