Merge remote-tracking branch 'origin/dev' into fix/retry-recovery-and-observability

This commit is contained in:
Choi Kijin / 최 기진 / チョイ キジン
2026-04-29 12:25:02 +09:00
13 changed files with 412 additions and 23 deletions
+16
View File
@@ -3023,6 +3023,22 @@
"created_at": "2026-04-28T03:32:31Z",
"repoId": 1108837393,
"pullRequestNo": 3695
},
{
"name": "unclok",
"id": 5087124,
"comment_id": 4335472715,
"created_at": "2026-04-28T13:00:37Z",
"repoId": 1108837393,
"pullRequestNo": 3706
},
{
"name": "deopa0402",
"id": 107998765,
"comment_id": 4336992103,
"created_at": "2026-04-28T16:03:18Z",
"repoId": 1108837393,
"pullRequestNo": 3713
}
]
}
+69 -10
View File
@@ -177,6 +177,7 @@ export class BackgroundManager {
private tasks: Map<string, BackgroundTask>
private tasksByParentSession: Map<string, Set<string>>
private notifications: Map<string, BackgroundTask[]>
private pendingNotifications: Map<string, string[]>
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
@@ -218,6 +219,7 @@ export class BackgroundManager {
}
) {
this.tasks = new Map()
this.tasksByParentSession = new Map()
this.notifications = new Map()
this.pendingNotifications = new Map()
this.pendingByParent = new Map()
@@ -322,6 +324,50 @@ export class BackgroundManager {
this.unregisterRootDescendant(task.rootSessionID)
}
private addTask(task: BackgroundTask): void {
this.tasks.set(task.id, task)
if (!task.parentSessionID) {
return
}
const taskIDs = this.tasksByParentSession.get(task.parentSessionID) ?? new Set<string>()
taskIDs.add(task.id)
this.tasksByParentSession.set(task.parentSessionID, taskIDs)
}
private removeTask(task: BackgroundTask): void {
this.tasks.delete(task.id)
this.removeTaskFromParentIndex(task.id, task.parentSessionID)
}
private updateTaskParent(task: BackgroundTask, parentSessionID: string): void {
if (task.parentSessionID === parentSessionID) {
return
}
this.removeTaskFromParentIndex(task.id, task.parentSessionID)
task.parentSessionID = parentSessionID
const taskIDs = this.tasksByParentSession.get(parentSessionID) ?? new Set<string>()
taskIDs.add(task.id)
this.tasksByParentSession.set(parentSessionID, taskIDs)
}
private removeTaskFromParentIndex(taskID: string, parentSessionID: string | undefined): void {
if (!parentSessionID) {
return
}
const taskIDs = this.tasksByParentSession.get(parentSessionID)
if (!taskIDs) {
return
}
taskIDs.delete(taskID)
if (taskIDs.size === 0) {
this.tasksByParentSession.delete(parentSessionID)
}
}
async launch(input: LaunchInput): Promise<BackgroundTask> {
log("[background-agent] launch() called with:", {
agent: input.agent,
@@ -368,7 +414,7 @@ export class BackgroundManager {
}
const firstAttempt = startAttempt(task, input.model)
this.tasks.set(task.id, task)
this.addTask(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)
@@ -745,13 +791,25 @@ The fallback retry session is now created and can be inspected directly.
}
getTasksByParentSession(sessionID: string): BackgroundTask[] {
const result: BackgroundTask[] = []
for (const task of this.tasks.values()) {
if (task.parentSessionID === sessionID) {
result.push(task)
const taskIDs = this.tasksByParentSession.get(sessionID)
if (!taskIDs) {
const result: BackgroundTask[] = []
for (const task of this.tasks.values()) {
if (task.parentSessionID === sessionID) {
result.push(task)
}
}
return result
}
const tasks: BackgroundTask[] = []
for (const taskID of taskIDs) {
const task = this.tasks.get(taskID)
if (task) {
tasks.push(task)
}
}
return result
return tasks
}
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
@@ -830,7 +888,7 @@ The fallback retry session is now created and can be inspected directly.
const parentChanged = input.parentSessionID !== existingTask.parentSessionID
if (parentChanged) {
this.cleanupPendingByParent(existingTask) // Clean from OLD parent
existingTask.parentSessionID = input.parentSessionID
this.updateTaskParent(existingTask, input.parentSessionID)
}
if (input.parentAgent !== undefined) {
existingTask.parentAgent = input.parentAgent
@@ -885,7 +943,7 @@ The fallback retry session is now created and can be inspected directly.
concurrencyGroup,
}
this.tasks.set(task.id, task)
this.addTask(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 })
@@ -935,7 +993,7 @@ The fallback retry session is now created and can be inspected directly.
existingTask.status = "running"
existingTask.completedAt = undefined
existingTask.error = undefined
existingTask.parentSessionID = input.parentSessionID
this.updateTaskParent(existingTask, input.parentSessionID)
existingTask.parentMessageID = input.parentMessageID
existingTask.parentModel = input.parentModel
existingTask.parentAgent = input.parentAgent
@@ -1680,7 +1738,7 @@ The task was re-queued on a fallback model after a retryable failure.
}
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
this.removeTask(task)
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
@@ -2360,6 +2418,7 @@ The task was re-queued on a fallback model after a retryable failure.
this.concurrencyManager.clear()
this.tasks.clear()
this.tasksByParentSession.clear()
this.notifications.clear()
this.pendingNotifications.clear()
this.pendingByParent.clear()
+22
View File
@@ -141,6 +141,22 @@ function extractFilePath(metadata: unknown): string | undefined {
return undefined
}
function extractLineCount(metadata: unknown): number | undefined {
if (!metadata || typeof metadata !== "object") {
return undefined
}
const objectMeta = metadata as Record<string, unknown>
const candidates = [objectMeta.lineCount, objectMeta.linesWritten, objectMeta.lines]
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
return candidate
}
}
return undefined
}
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
if (output.output.startsWith(WRITE_SUCCESS_MARKER)) {
return
@@ -151,6 +167,12 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
return
}
const metadataLineCount = extractLineCount(output.metadata)
if (metadataLineCount !== undefined) {
output.output = `${WRITE_SUCCESS_MARKER} ${metadataLineCount} lines written.`
return
}
const filePath = extractFilePath(output.metadata)
if (!filePath) {
return
+22 -2
View File
@@ -11,9 +11,9 @@ function mockCtx(): PluginInput {
return {
client: {} as PluginInput["client"],
directory: "/test",
project: "/test" as unknown as PluginInput["project"],
project: "/test" as PluginInput["project"],
worktree: "/test",
serverUrl: "http://localhost" as unknown as PluginInput["serverUrl"],
serverUrl: "http://localhost" as PluginInput["serverUrl"],
$: {} as PluginInput["$"],
}
}
@@ -238,6 +238,26 @@ describe("hashline-read-enhancer", () => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
it("uses write metadata line count without reading the file", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "write", sessionID: "s", callID: "c" }
const output = {
title: "write",
output: "Wrote file successfully.",
metadata: {
filepath: "/tmp/hashline-metadata-fast-path-missing-file.ts",
lineCount: 7,
},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBe("File written successfully. 7 lines written.")
})
it("does not overwrite write tool error output with success message", async () => {
//#given — write tool failed, but stale file exists from previous write
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
@@ -43,6 +43,7 @@ interface ClientLike {
export interface AssistantCompactionMessageInfo {
sessionID: string
id?: string
parts?: unknown
}
async function withTimeout<TValue>(
@@ -185,6 +186,7 @@ export function createPostCompactionDegradationMonitor(args: {
sessionID: info.sessionID,
messageID: info.id,
directory,
parts: info.parts,
})
if (!isNoTextTail) {
@@ -46,8 +46,13 @@ export async function resolveNoTextTailFromSession(args: {
sessionID: string
messageID?: string
directory: string
parts?: unknown
}): Promise<boolean> {
const { client, sessionID, messageID, directory } = args
const { client, sessionID, messageID, directory, parts } = args
if (Array.isArray(parts)) {
return isStepOnlyNoTextParts(parts)
}
try {
const response = await client.session.messages({
@@ -192,4 +192,28 @@ describe("preemptive-compaction post-compaction degradation monitor", () => {
// then
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
})
it("uses message update parts without refetching session messages", async () => {
// given
const sessionHistory: AssistantHistoryMessage[] = []
const ctx = createMockCtx(sessionHistory)
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_tail_update_parts"
const stepOnlyParts = [{ type: "step-start" }, { type: "step-finish" }]
await hook.event({
event: {
type: "session.compacted",
properties: { sessionID },
},
})
// when
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_1", parts: stepOnlyParts }))
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_2", parts: stepOnlyParts }))
// then
expect(ctx.client.session.messages).not.toHaveBeenCalled()
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
})
})
+2
View File
@@ -76,6 +76,7 @@ export function createPreemptiveCompactionHook(
modelID?: string
finish?: boolean
tokens?: TokenInfo
parts?: unknown
} | undefined
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
@@ -92,6 +93,7 @@ export function createPreemptiveCompactionHook(
await postCompactionMonitor.onAssistantMessageUpdated({
sessionID: info.sessionID,
id: info.id,
parts: info.parts,
})
}
}
@@ -0,0 +1,99 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { handleSessionIdle } from "./idle-event"
import type { SessionStateStore } from "./session-state"
import type { ContinuationProgressUpdate, SessionState } from "./types"
function createStateStore(): {
store: SessionStateStore
resetCalls: string[]
} {
const state: SessionState = {
stagnationCount: 0,
consecutiveFailures: 0,
}
const resetCalls: string[] = []
const progressUpdate: ContinuationProgressUpdate = {
previousStagnationCount: 0,
stagnationCount: 0,
hasProgressed: false,
progressSource: "none",
}
return {
resetCalls,
store: {
getState: () => state,
getExistingState: () => state,
startPruneInterval: () => {},
recordActivity: () => {},
trackContinuationProgress: () => progressUpdate,
resetContinuationProgress: (sessionID: string) => {
resetCalls.push(sessionID)
},
cancelCountdown: () => {},
cleanup: () => {},
cancelAllCountdowns: () => {},
shutdown: () => {},
},
}
}
describe("handleSessionIdle", () => {
it("resets continuation progress once when todos are empty", async () => {
// given
const sessionID = "ses_empty_todos"
const { store, resetCalls } = createStateStore()
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
}
// when
await handleSessionIdle({
ctx: ctx as never,
sessionID,
sessionStateStore: store,
})
// then
expect(resetCalls).toEqual([sessionID])
})
it("resets continuation progress once when every todo is complete", async () => {
// given
const sessionID = "ses_completed_todos"
const { store, resetCalls } = createStateStore()
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
todo: async () => ({
data: [
{ id: "todo-1", content: "Ship", status: "completed", priority: "high" },
{ id: "todo-2", content: "Verify", status: "completed", priority: "medium" },
],
}),
},
},
directory: "/tmp/test",
}
// when
await handleSessionIdle({
ctx: ctx as never,
sessionID,
sessionStateStore: store,
})
// then
expect(resetCalls).toEqual([sessionID])
})
})
@@ -108,7 +108,6 @@ export async function handleSessionIdle(args: {
}
if (!todos || todos.length === 0) {
sessionStateStore.resetContinuationProgress(sessionID)
sessionStateStore.resetContinuationProgress(sessionID)
log(`[${HOOK_NAME}] No todos`, { sessionID })
return
@@ -116,7 +115,6 @@ export async function handleSessionIdle(args: {
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
sessionStateStore.resetContinuationProgress(sessionID)
sessionStateStore.resetContinuationProgress(sessionID)
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
return
+6 -2
View File
@@ -1,4 +1,5 @@
import type { OhMyOpenCodeConfig } from "../config";
import type { PluginInput } from "@opencode-ai/plugin";
import type { PluginContext } from "./types";
import {
@@ -26,6 +27,7 @@ import {
import { resetMessageCursor } from "../shared";
import { getAgentConfigKey } from "../shared/agent-display-names";
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
import { invalidateContextWindowUsageCache } from "../shared/dynamic-truncator";
import { log } from "../shared/logger";
import { shouldRetryError } from "../shared/model-error-classifier";
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
@@ -161,8 +163,7 @@ export function createEventHandler(args: {
body: { parts: Array<{ type: "text"; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
summarize: (...args: any[]) => Promise<unknown>;
summarize: (...args: unknown[]) => Promise<unknown>;
};
};
};
@@ -476,6 +477,9 @@ export function createEventHandler(args: {
const sessionID = info?.sessionID as string | undefined;
const agent = info?.agent as string | undefined;
const role = info?.role as string | undefined;
if (sessionID && info?.finish === true) {
invalidateContextWindowUsageCache(pluginContext as PluginInput, sessionID);
}
if (sessionID && role === "user") {
const isCompactionMessage = agent ? isCompactionAgent(agent) : false;
if (agent && !isCompactionMessage) {
+67 -1
View File
@@ -2,7 +2,7 @@
import { describe, expect, it, afterEach } from "bun:test"
import { getContextWindowUsage } from "./dynamic-truncator"
import { getContextWindowUsage, invalidateContextWindowUsageCache } from "./dynamic-truncator"
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
@@ -53,6 +53,39 @@ function createContextUsageMockContext(
}
}
function createCountingContextUsageMockContext(inputTokens: number) {
let messagesCalls = 0
return {
ctx: {
client: {
session: {
messages: async () => {
messagesCalls += 1
return {
data: [
{
info: {
role: "assistant",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
tokens: {
input: inputTokens,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
},
],
}
},
},
},
},
getMessagesCalls: () => messagesCalls,
}
}
describe("getContextWindowUsage", () => {
afterEach(() => {
resetContextLimitEnv()
@@ -125,6 +158,39 @@ describe("getContextWindowUsage", () => {
expect(usage?.remainingTokens).toBe(82144)
})
it("reuses context usage for repeated calls in the same session", async () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const { ctx, getMessagesCalls } = createCountingContextUsageMockContext(100000)
const modelCacheState = { anthropicContext1MEnabled: false }
// when
const firstUsage = await getContextWindowUsage(ctx as never, "ses_cached_usage", modelCacheState)
const secondUsage = await getContextWindowUsage(ctx as never, "ses_cached_usage", modelCacheState)
// then
expect(firstUsage?.remainingTokens).toBe(100000)
expect(secondUsage?.remainingTokens).toBe(100000)
expect(getMessagesCalls()).toBe(1)
})
it("refetches context usage after cache invalidation", async () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const { ctx, getMessagesCalls } = createCountingContextUsageMockContext(100000)
const modelCacheState = { anthropicContext1MEnabled: false }
// when
await getContextWindowUsage(ctx as never, "ses_invalidated_usage", modelCacheState)
invalidateContextWindowUsageCache(ctx as never, "ses_invalidated_usage")
await getContextWindowUsage(ctx as never, "ses_invalidated_usage", modelCacheState)
// then
expect(getMessagesCalls()).toBe(2)
})
it("returns null for non-anthropic providers without a cached limit", async () => {
// given
const ctx = createContextUsageMockContext(180000, {
+77 -5
View File
@@ -24,6 +24,66 @@ interface MessageWrapper {
info: { role: string } & Partial<AssistantMessageInfo>;
}
type ContextWindowUsage = {
usedTokens: number;
remainingTokens: number;
usagePercentage: number;
}
type ContextWindowUsageClient = Pick<PluginInput["client"], "session">
const usageCacheByClient = new WeakMap<object, Map<string, Map<string, Promise<ContextWindowUsage | null>>>>()
function createModelCacheKey(modelCacheState?: ContextLimitModelCacheState): string {
if (!modelCacheState) {
return "default"
}
const cachedLimits = modelCacheState.modelContextLimitsCache
? [...modelCacheState.modelContextLimitsCache.entries()]
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([modelKey, limit]) => `${modelKey}:${limit}`)
.join(",")
: ""
return `${modelCacheState.anthropicContext1MEnabled ? "1m" : "200k"}|${cachedLimits}`
}
function getUsageCache(
client: ContextWindowUsageClient,
modelCacheState?: ContextLimitModelCacheState,
): Map<string, Promise<ContextWindowUsage | null>> {
let cacheByModelState = usageCacheByClient.get(client)
if (!cacheByModelState) {
cacheByModelState = new Map()
usageCacheByClient.set(client, cacheByModelState)
}
const modelCacheKey = createModelCacheKey(modelCacheState)
let cache = cacheByModelState.get(modelCacheKey)
if (!cache) {
cache = new Map()
cacheByModelState.set(modelCacheKey, cache)
}
return cache
}
export function invalidateContextWindowUsageCache(ctx: PluginInput, sessionID?: string): void {
const cacheByModelState = usageCacheByClient.get(ctx.client)
if (!cacheByModelState) {
return
}
for (const cache of cacheByModelState.values()) {
if (sessionID) {
cache.delete(sessionID)
} else {
cache.clear()
}
}
}
export interface TruncationResult {
result: string;
truncated: boolean;
@@ -112,11 +172,23 @@ export async function getContextWindowUsage(
ctx: PluginInput,
sessionID: string,
modelCacheState?: ContextLimitModelCacheState,
): Promise<{
usedTokens: number;
remainingTokens: number;
usagePercentage: number;
} | null> {
): Promise<ContextWindowUsage | null> {
const cache = getUsageCache(ctx.client, modelCacheState)
const cached = cache.get(sessionID)
if (cached) {
return cached
}
const usagePromise = fetchContextWindowUsage(ctx, sessionID, modelCacheState)
cache.set(sessionID, usagePromise)
return usagePromise
}
async function fetchContextWindowUsage(
ctx: PluginInput,
sessionID: string,
modelCacheState?: ContextLimitModelCacheState,
): Promise<ContextWindowUsage | null> {
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },