Skip to content

Commit 4a9da30

Browse files
fix: background task completion detection and silent notifications
Apply upstream PR code-yeongyu#628 by @Gladdonilli - Fix background tasks that hang indefinitely due to missing sessionStatus - Add silent notification system via tool.execute.after hook injection - Add stability detection (3 consecutive stable polls after 10s minimum) - Change task retention from 200ms to 5 minutes for background_output - Fix formatTaskResult to sort messages by time descending - Fix TS2742 by adding explicit ToolDefinition type annotations Co-authored-by: Gladdonilli <[email protected]>
1 parent 2f0cd8c commit 4a9da30

File tree

9 files changed

+137
-67
lines changed

9 files changed

+137
-67
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export * from "./types"
2-
export { BackgroundManager } from "./manager"
2+
export { BackgroundManager, type PendingNotification } from "./manager"
33
export { ConcurrencyManager } from "./concurrency"

src/features/background-agent/manager.ts

Lines changed: 76 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { subagentSessions } from "../claude-code-session-state"
1313
import { getTaskToastManager } from "../task-toast-manager"
1414

1515
const TASK_TTL_MS = 30 * 60 * 1000
16+
const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in
1617

1718
type OpencodeClient = PluginInput["client"]
1819

@@ -40,9 +41,18 @@ interface Todo {
4041
id: string
4142
}
4243

44+
export interface PendingNotification {
45+
taskId: string
46+
description: string
47+
duration: string
48+
status: "completed" | "error"
49+
error?: string
50+
}
51+
4352
export class BackgroundManager {
4453
private tasks: Map<string, BackgroundTask>
4554
private notifications: Map<string, BackgroundTask[]>
55+
private pendingNotifications: Map<string, PendingNotification[]>
4656
private client: OpencodeClient
4757
private directory: string
4858
private pollingInterval?: ReturnType<typeof setInterval>
@@ -51,6 +61,7 @@ export class BackgroundManager {
5161
constructor(ctx: PluginInput, config?: BackgroundTaskConfig) {
5262
this.tasks = new Map()
5363
this.notifications = new Map()
64+
this.pendingNotifications = new Map()
5465
this.client = ctx.client
5566
this.directory = ctx.directory
5667
this.concurrencyManager = new ConcurrencyManager(config)
@@ -380,10 +391,21 @@ export class BackgroundManager {
380391
return this.notifications.get(sessionID) ?? []
381392
}
382393

383-
clearNotifications(sessionID: string): void {
394+
clearNotifications(sessionID: string): void {
384395
this.notifications.delete(sessionID)
385396
}
386397

398+
hasPendingNotifications(sessionID: string): boolean {
399+
const pending = this.pendingNotifications.get(sessionID)
400+
return pending !== undefined && pending.length > 0
401+
}
402+
403+
consumePendingNotifications(sessionID: string): PendingNotification[] {
404+
const pending = this.pendingNotifications.get(sessionID) ?? []
405+
this.pendingNotifications.delete(sessionID)
406+
return pending
407+
}
408+
387409
private clearNotificationsForTask(taskId: string): void {
388410
for (const [sessionID, tasks] of this.notifications.entries()) {
389411
const filtered = tasks.filter((t) => t.id !== taskId)
@@ -411,13 +433,14 @@ export class BackgroundManager {
411433
}
412434
}
413435

414-
cleanup(): void {
436+
cleanup(): void {
415437
this.stopPolling()
416438
this.tasks.clear()
417439
this.notifications.clear()
440+
this.pendingNotifications.clear()
418441
}
419442

420-
private notifyParentSession(task: BackgroundTask): void {
443+
private notifyParentSession(task: BackgroundTask): void {
421444
const duration = this.formatDuration(task.startedAt, task.completedAt)
422445

423446
log("[background-agent] notifyParentSession called for task:", task.id)
@@ -431,47 +454,34 @@ export class BackgroundManager {
431454
})
432455
}
433456

434-
const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.`
457+
// Store notification for silent injection via tool.execute.after hook
458+
const notification: PendingNotification = {
459+
taskId: task.id,
460+
description: task.description,
461+
duration,
462+
status: task.status === "error" ? "error" : "completed",
463+
error: task.error,
464+
}
465+
466+
const existing = this.pendingNotifications.get(task.parentSessionID) ?? []
467+
existing.push(notification)
468+
this.pendingNotifications.set(task.parentSessionID, existing)
435469

436-
log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID })
470+
log("[background-agent] Stored pending notification for parent session:", {
471+
parentSessionID: task.parentSessionID,
472+
taskId: task.id
473+
})
437474

438475
const taskId = task.id
439-
setTimeout(async () => {
476+
setTimeout(() => {
440477
if (task.concurrencyKey) {
441478
this.concurrencyManager.release(task.concurrencyKey)
479+
task.concurrencyKey = undefined // Prevent double-release
442480
}
443-
444-
try {
445-
const body: {
446-
agent?: string
447-
model?: { providerID: string; modelID: string }
448-
parts: Array<{ type: "text"; text: string }>
449-
} = {
450-
parts: [{ type: "text", text: message }],
451-
}
452-
453-
if (task.parentAgent !== undefined) {
454-
body.agent = task.parentAgent
455-
}
456-
457-
if (task.parentModel?.providerID && task.parentModel?.modelID) {
458-
body.model = { providerID: task.parentModel.providerID, modelID: task.parentModel.modelID }
459-
}
460-
461-
await this.client.session.prompt({
462-
path: { id: task.parentSessionID },
463-
body,
464-
query: { directory: this.directory },
465-
})
466-
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
467-
} catch (error) {
468-
log("[background-agent] prompt failed:", String(error))
469-
} finally {
470-
this.clearNotificationsForTask(taskId)
471-
this.tasks.delete(taskId)
472-
log("[background-agent] Removed completed task from memory:", taskId)
473-
}
474-
}, 200)
481+
this.clearNotificationsForTask(taskId)
482+
this.tasks.delete(taskId)
483+
log("[background-agent] Removed completed task from memory:", taskId)
484+
}, 5 * 60 * 1000) // 5 minutes retention for background_output retrieval
475485
}
476486

477487
private formatDuration(start: Date, end?: Date): string {
@@ -540,15 +550,11 @@ export class BackgroundManager {
540550
for (const task of this.tasks.values()) {
541551
if (task.status !== "running") continue
542552

543-
try {
553+
try {
544554
const sessionStatus = allStatuses[task.sessionID]
545555

546-
if (!sessionStatus) {
547-
log("[background-agent] Session not found in status:", task.sessionID)
548-
continue
549-
}
550-
551-
if (sessionStatus.type === "idle") {
556+
// Don't skip if session not in status - fall through to message-based detection
557+
if (sessionStatus?.type === "idle") {
552558
const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID)
553559
if (hasIncompleteTodos) {
554560
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
@@ -599,10 +605,34 @@ export class BackgroundManager {
599605
task.progress.toolCalls = toolCalls
600606
task.progress.lastTool = lastTool
601607
task.progress.lastUpdate = new Date()
602-
if (lastMessage) {
608+
if (lastMessage) {
603609
task.progress.lastMessage = lastMessage
604610
task.progress.lastMessageAt = new Date()
605611
}
612+
613+
// Stability detection: complete when message count unchanged for 3 polls
614+
const currentMsgCount = messages.length
615+
const elapsedMs = Date.now() - task.startedAt.getTime()
616+
617+
if (elapsedMs >= MIN_STABILITY_TIME_MS) {
618+
if (task.lastMsgCount === currentMsgCount) {
619+
task.stablePolls = (task.stablePolls ?? 0) + 1
620+
if (task.stablePolls >= 3) {
621+
const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID)
622+
if (!hasIncompleteTodos) {
623+
task.status = "completed"
624+
task.completedAt = new Date()
625+
this.markForNotification(task)
626+
this.notifyParentSession(task)
627+
log("[background-agent] Task completed via stability detection:", task.id)
628+
continue
629+
}
630+
}
631+
} else {
632+
task.stablePolls = 0
633+
}
634+
}
635+
task.lastMsgCount = currentMsgCount
606636
}
607637
} catch (error) {
608638
log("[background-agent] Poll error for task:", { taskId: task.id, error })

src/features/background-agent/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export interface BackgroundTask {
3232
concurrencyKey?: string
3333
/** Parent session's agent name for notification */
3434
parentAgent?: string
35+
/** Last message count for stability detection */
36+
lastMsgCount?: number
37+
/** Number of consecutive polls with stable message count */
38+
stablePolls?: number
3539
}
3640

3741
export interface LaunchInput {

src/hooks/background-notification/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,49 @@ interface EventInput {
99
event: Event
1010
}
1111

12+
interface ToolExecuteInput {
13+
sessionID?: string
14+
tool: string
15+
}
16+
17+
interface ToolExecuteOutput {
18+
title: string
19+
output: string
20+
metadata: unknown
21+
}
22+
1223
export function createBackgroundNotificationHook(manager: BackgroundManager) {
1324
const eventHandler = async ({ event }: EventInput) => {
1425
manager.handleEvent(event)
1526
}
1627

28+
const toolExecuteAfterHandler = async (
29+
input: ToolExecuteInput,
30+
output: ToolExecuteOutput
31+
) => {
32+
const sessionID = input.sessionID
33+
if (!sessionID) return
34+
35+
if (!manager.hasPendingNotifications(sessionID)) return
36+
37+
const notifications = manager.consumePendingNotifications(sessionID)
38+
if (notifications.length === 0) return
39+
40+
const messages = notifications.map((n) => {
41+
if (n.status === "error") {
42+
return `[BACKGROUND TASK FAILED] Task "${n.description}" failed after ${n.duration}. Error: ${n.error || "Unknown error"}. Use background_output with task_id="${n.taskId}" for details.`
43+
}
44+
return `[BACKGROUND TASK COMPLETED] Task "${n.description}" finished in ${n.duration}. Use background_output with task_id="${n.taskId}" to get results.`
45+
})
46+
47+
const injection = "\n\n---\n" + messages.join("\n") + "\n---"
48+
49+
output.output = output.output + injection
50+
}
51+
1752
return {
1853
event: eventHandler,
54+
"tool.execute.after": toolExecuteAfterHandler,
1955
}
2056
}
2157

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,9 +522,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
522522
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
523523
await agentUsageReminder?.["tool.execute.after"](input, output);
524524
await interactiveBashSession?.["tool.execute.after"](input, output);
525-
await editErrorRecovery?.["tool.execute.after"](input, output);
525+
await editErrorRecovery?.["tool.execute.after"](input, output);
526526
await sisyphusOrchestrator?.["tool.execute.after"]?.(input, output);
527527
await taskResumeInfo["tool.execute.after"](input, output);
528+
await backgroundNotificationHook?.["tool.execute.after"]?.(input, output);
528529
},
529530
};
530531
};

src/shared/migration.test.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,7 @@ describe("migrateConfigFile with google/ models", () => {
660660
})
661661
})
662662

663-
test("migrates google/ models in agents to proxypal/ before category migration", () => {
663+
test("migrates google/ models in agents to proxypal/", () => {
664664
const testConfigPath = "/tmp/test-config-google-model.json"
665665
const rawConfig: Record<string, unknown> = {
666666
agents: {
@@ -677,12 +677,10 @@ describe("migrateConfigFile with google/ models", () => {
677677
expect(needsWrite).toBe(true)
678678

679679
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
680-
expect(agents["frontend-ui-ux-engineer"].category).toBe("visual-engineering")
680+
expect(agents["frontend-ui-ux-engineer"].model).toBe("proxypal/gemini-3-pro-preview")
681681
expect(agents["frontend-ui-ux-engineer"].temperature).toBe(0.9)
682-
expect(agents["frontend-ui-ux-engineer"].model).toBeUndefined()
683-
expect(agents["document-writer"].category).toBe("quick")
682+
expect(agents["document-writer"].model).toBe("proxypal/gemini-3-flash-preview")
684683
expect(agents["document-writer"].temperature).toBe(0.5)
685-
expect(agents["document-writer"].model).toBeUndefined()
686684

687685
const dir = path.dirname(testConfigPath)
688686
const basename = path.basename(testConfigPath)
@@ -736,7 +734,7 @@ describe("migrateConfigFile with google/ models", () => {
736734
expect(agents["custom-agent"].model).toBe("google/unknown-model")
737735
})
738736

739-
test("does not migrate when models are already proxypal/ and have custom settings", () => {
737+
test("does not migrate proxypal models with custom settings", () => {
740738
const testConfigPath = "/tmp/test-config-proxypal.json"
741739
const rawConfig: Record<string, unknown> = {
742740
agents: {
@@ -749,16 +747,10 @@ describe("migrateConfigFile with google/ models", () => {
749747

750748
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
751749

752-
expect(needsWrite).toBe(true)
750+
expect(needsWrite).toBe(false)
753751

754752
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
755-
expect(agents["custom-agent"].category).toBe("most-capable")
753+
expect(agents["custom-agent"].model).toBe("proxypal/gemini-claude-opus-4-5-thinking")
756754
expect(agents["custom-agent"].prompt_append).toBe("custom prompt")
757-
758-
const dir = path.dirname(testConfigPath)
759-
const basename = path.basename(testConfigPath)
760-
const files = fs.readdirSync(dir)
761-
const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`))
762-
backupFiles.forEach((f) => cleanupPaths.push(path.join(dir, f)))
763755
})
764756
})

src/tools/background-task/tools.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ Session ID: ${task.sessionID}
193193
(No messages found)`
194194
}
195195

196-
const assistantMessages = messages.filter(
196+
const assistantMessages = messages.filter(
197197
(m) => m.info?.role === "assistant"
198198
)
199199

@@ -210,8 +210,15 @@ Session ID: ${task.sessionID}
210210
(No assistant response found)`
211211
}
212212

213-
const lastMessage = assistantMessages[assistantMessages.length - 1]
214-
const textParts = lastMessage?.parts?.filter(
213+
// Sort by time descending (newest first), take first result - matches sync pattern
214+
const sortedMessages = [...assistantMessages].sort((a, b) => {
215+
const timeA = String((a as { info?: { time?: string } }).info?.time ?? "")
216+
const timeB = String((b as { info?: { time?: string } }).info?.time ?? "")
217+
return timeB.localeCompare(timeA)
218+
})
219+
220+
const lastMessage = sortedMessages[0]
221+
const textParts = lastMessage.parts?.filter(
215222
(p) => p.type === "text"
216223
) ?? []
217224
const textContent = textParts

src/tools/skill/tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,4 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
194194
})
195195
}
196196

197-
export const skill = createSkillTool()
197+
export const skill: ToolDefinition = createSkillTool()

src/tools/slashcommand/tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,4 +249,4 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
249249
}
250250

251251
// Default instance for backward compatibility (lazy loading)
252-
export const slashcommand = createSlashcommandTool()
252+
export const slashcommand: ToolDefinition = createSlashcommandTool()

0 commit comments

Comments
 (0)