Merge pull request #1817 from code-yeongyu/fix/todo-continuation-always-fire
fix(todo-continuation-enforcer): fire continuation for all sessions with incomplete todos
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
|
||||
export { TaskHistory, type TaskHistoryEntry } from "./task-history"
|
||||
export { ConcurrencyManager } from "./concurrency"
|
||||
export { TaskStateManager } from "./state"
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
LaunchInput,
|
||||
ResumeInput,
|
||||
} from "./types"
|
||||
import { TaskHistory } from "./task-history"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
||||
@@ -90,6 +91,7 @@ export class BackgroundManager {
|
||||
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
|
||||
readonly taskHistory = new TaskHistory()
|
||||
|
||||
constructor(
|
||||
ctx: PluginInput,
|
||||
@@ -144,6 +146,7 @@ export class BackgroundManager {
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
|
||||
|
||||
// Track for batched notifications immediately (pending state)
|
||||
if (input.parentSessionID) {
|
||||
@@ -291,6 +294,7 @@ export class BackgroundManager {
|
||||
task.concurrencyKey = concurrencyKey
|
||||
task.concurrencyGroup = concurrencyKey
|
||||
|
||||
this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt })
|
||||
this.startPolling()
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
@@ -486,6 +490,7 @@ export class BackgroundManager {
|
||||
this.tasks.set(task.id, task)
|
||||
subagentSessions.add(input.sessionID)
|
||||
this.startPolling()
|
||||
this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID: input.sessionID, agent: input.agent || "task", description: input.description, status: "running", startedAt: task.startedAt })
|
||||
|
||||
if (input.parentSessionID) {
|
||||
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
|
||||
@@ -741,6 +746,7 @@ export class BackgroundManager {
|
||||
task.status = "error"
|
||||
task.error = errorMessage ?? "Session error"
|
||||
task.completedAt = new Date()
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
@@ -951,6 +957,7 @@ export class BackgroundManager {
|
||||
if (reason) {
|
||||
task.error = reason
|
||||
}
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "cancelled", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
@@ -1095,6 +1102,7 @@ export class BackgroundManager {
|
||||
// Atomically mark as completed to prevent race conditions
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
|
||||
|
||||
// Release concurrency BEFORE any async operations to prevent slot leaks
|
||||
if (task.concurrencyKey) {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { TaskHistory } from "./task-history"
|
||||
|
||||
describe("TaskHistory", () => {
|
||||
describe("record", () => {
|
||||
it("stores an entry for a parent session", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
|
||||
//#when
|
||||
history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending" })
|
||||
|
||||
//#then
|
||||
const entries = history.getByParentSession("parent-1")
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0].id).toBe("t1")
|
||||
expect(entries[0].agent).toBe("explore")
|
||||
expect(entries[0].status).toBe("pending")
|
||||
})
|
||||
|
||||
it("ignores undefined parentSessionID", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
|
||||
//#when
|
||||
history.record(undefined, { id: "t1", agent: "explore", description: "Find auth", status: "pending" })
|
||||
|
||||
//#then
|
||||
expect(history.getByParentSession("undefined")).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("upserts without clobbering undefined fields", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending", category: "quick" })
|
||||
|
||||
//#when
|
||||
history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "running" })
|
||||
|
||||
//#then
|
||||
const entries = history.getByParentSession("parent-1")
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0].status).toBe("running")
|
||||
expect(entries[0].category).toBe("quick")
|
||||
})
|
||||
|
||||
it("caps entries at MAX_ENTRIES_PER_PARENT (100)", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
|
||||
//#when
|
||||
for (let i = 0; i < 105; i++) {
|
||||
history.record("parent-1", { id: `t${i}`, agent: "explore", description: `Task ${i}`, status: "completed" })
|
||||
}
|
||||
|
||||
//#then
|
||||
const entries = history.getByParentSession("parent-1")
|
||||
expect(entries).toHaveLength(100)
|
||||
expect(entries[0].id).toBe("t5")
|
||||
expect(entries[99].id).toBe("t104")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getByParentSession", () => {
|
||||
it("returns defensive copies", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending" })
|
||||
|
||||
//#when
|
||||
const entries = history.getByParentSession("parent-1")
|
||||
entries[0].status = "completed"
|
||||
|
||||
//#then
|
||||
const fresh = history.getByParentSession("parent-1")
|
||||
expect(fresh[0].status).toBe("pending")
|
||||
})
|
||||
|
||||
it("returns empty array for unknown parent", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
|
||||
//#when
|
||||
const entries = history.getByParentSession("nonexistent")
|
||||
|
||||
//#then
|
||||
expect(entries).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearSession", () => {
|
||||
it("removes all entries for a parent session", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending" })
|
||||
history.record("parent-2", { id: "t2", agent: "oracle", description: "Review", status: "running" })
|
||||
|
||||
//#when
|
||||
history.clearSession("parent-1")
|
||||
|
||||
//#then
|
||||
expect(history.getByParentSession("parent-1")).toHaveLength(0)
|
||||
expect(history.getByParentSession("parent-2")).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatForCompaction", () => {
|
||||
it("returns null when no entries exist", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
|
||||
//#when
|
||||
const result = history.formatForCompaction("nonexistent")
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("formats entries with agent, status, and description", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth patterns", status: "completed" })
|
||||
|
||||
//#when
|
||||
const result = history.formatForCompaction("parent-1")
|
||||
|
||||
//#then
|
||||
expect(result).toContain("**explore**")
|
||||
expect(result).toContain("(completed)")
|
||||
expect(result).toContain("Find auth patterns")
|
||||
})
|
||||
|
||||
it("includes category when present", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "running", category: "quick" })
|
||||
|
||||
//#when
|
||||
const result = history.formatForCompaction("parent-1")
|
||||
|
||||
//#then
|
||||
expect(result).toContain("[quick]")
|
||||
})
|
||||
|
||||
it("includes session_id when present", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
history.record("parent-1", { id: "t1", sessionID: "ses_abc123", agent: "oracle", description: "Review arch", status: "completed" })
|
||||
|
||||
//#when
|
||||
const result = history.formatForCompaction("parent-1")
|
||||
|
||||
//#then
|
||||
expect(result).toContain("`ses_abc123`")
|
||||
})
|
||||
|
||||
it("sanitizes newlines in description", () => {
|
||||
//#given
|
||||
const history = new TaskHistory()
|
||||
history.record("parent-1", { id: "t1", agent: "explore", description: "Line1\nLine2\rLine3", status: "pending" })
|
||||
|
||||
//#when
|
||||
const result = history.formatForCompaction("parent-1")
|
||||
|
||||
//#then
|
||||
expect(result).not.toContain("\n\n")
|
||||
expect(result).toContain("Line1 Line2 Line3")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { BackgroundTaskStatus } from "./types"
|
||||
|
||||
const MAX_ENTRIES_PER_PARENT = 100
|
||||
|
||||
export interface TaskHistoryEntry {
|
||||
id: string
|
||||
sessionID?: string
|
||||
agent: string
|
||||
description: string
|
||||
status: BackgroundTaskStatus
|
||||
category?: string
|
||||
startedAt?: Date
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
export class TaskHistory {
|
||||
private entries: Map<string, TaskHistoryEntry[]> = new Map()
|
||||
|
||||
record(parentSessionID: string | undefined, entry: TaskHistoryEntry): void {
|
||||
if (!parentSessionID) return
|
||||
|
||||
const list = this.entries.get(parentSessionID) ?? []
|
||||
const existing = list.findIndex((e) => e.id === entry.id)
|
||||
|
||||
if (existing !== -1) {
|
||||
const current = list[existing]
|
||||
list[existing] = {
|
||||
...current,
|
||||
...(entry.sessionID !== undefined ? { sessionID: entry.sessionID } : {}),
|
||||
...(entry.agent !== undefined ? { agent: entry.agent } : {}),
|
||||
...(entry.description !== undefined ? { description: entry.description } : {}),
|
||||
...(entry.status !== undefined ? { status: entry.status } : {}),
|
||||
...(entry.category !== undefined ? { category: entry.category } : {}),
|
||||
...(entry.startedAt !== undefined ? { startedAt: entry.startedAt } : {}),
|
||||
...(entry.completedAt !== undefined ? { completedAt: entry.completedAt } : {}),
|
||||
}
|
||||
} else {
|
||||
if (list.length >= MAX_ENTRIES_PER_PARENT) {
|
||||
list.shift()
|
||||
}
|
||||
list.push({ ...entry })
|
||||
}
|
||||
|
||||
this.entries.set(parentSessionID, list)
|
||||
}
|
||||
|
||||
getByParentSession(parentSessionID: string): TaskHistoryEntry[] {
|
||||
const list = this.entries.get(parentSessionID)
|
||||
if (!list) return []
|
||||
return list.map((e) => ({ ...e }))
|
||||
}
|
||||
|
||||
clearSession(parentSessionID: string): void {
|
||||
this.entries.delete(parentSessionID)
|
||||
}
|
||||
|
||||
formatForCompaction(parentSessionID: string): string | null {
|
||||
const list = this.getByParentSession(parentSessionID)
|
||||
if (list.length === 0) return null
|
||||
|
||||
const lines = list.map((e) => {
|
||||
const desc = e.description.replace(/[\n\r]+/g, " ").trim()
|
||||
const parts = [
|
||||
`- **${e.agent}**`,
|
||||
e.category ? `[${e.category}]` : null,
|
||||
`(${e.status})`,
|
||||
`: ${desc}`,
|
||||
e.sessionID ? ` | session: \`${e.sessionID}\`` : null,
|
||||
]
|
||||
return parts.filter(Boolean).join("")
|
||||
})
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user