From b285330fb61600ef3658dfc7f2491abbc79aea7d Mon Sep 17 00:00:00 2001 From: DC Date: Thu, 15 Jan 2026 23:02:30 -0500 Subject: [PATCH] feat(background-agent): configurable task timing (initial wait, poll interval, timeout) Replace hardcoded 30-minute task timeout with configurable three-phase timing: - initial_wait_ms: Wait before first poll (default: 5 min) - poll_interval_ms: Polling frequency after initial wait (default: 60 sec) - timeout_ms: Hard timeout, task killed after this (default: 15 min) Configurable at three levels: 1. Global defaults via background_task config 2. Per-category overrides in categories config 3. Per-task when launching via sisyphus_task The 30-minute hardcoded timeout was excessive - most tasks complete in under 8 minutes, and if they don't, the task scoping is wrong. This allows quick tasks to fail fast while giving complex tasks appropriate time. Also improves timeout behavior: - Timeout now triggers notification to parent (not silent prune) - Per-task timeout instead of global constant - Respects initial wait before polling (reduces API spam) --- assets/oh-my-opencode.schema.json | 57 +++++++++++++++++ src/config/schema.ts | 30 +++++++++ src/features/background-agent/manager.ts | 79 +++++++++++++++++++----- src/features/background-agent/types.ts | 9 +++ 4 files changed, 160 insertions(+), 15 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index b215a7c81d..3c8c4bedac 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -2060,6 +2060,18 @@ }, "prompt_append": { "type": "string" + }, + "initial_wait_ms": { + "type": "number", + "minimum": 1000 + }, + "poll_interval_ms": { + "type": "number", + "minimum": 500 + }, + "timeout_ms": { + "type": "number", + "minimum": 30000 } }, "required": [ @@ -2405,6 +2417,18 @@ "type": "number", "minimum": 1 } + }, + "default_initial_wait_ms": { + "type": "number", + "minimum": 1000 + }, + "default_poll_interval_ms": { + "type": "number", + "minimum": 500 + }, + "default_timeout_ms": { + "type": "number", + "minimum": 30000 } } }, @@ -2428,6 +2452,39 @@ "type": "boolean" } } + }, + "keyword_modes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "pattern": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "prompt_file": { + "type": "string" + }, + "show_toast": { + "default": true, + "type": "boolean" + }, + "toast_title": { + "type": "string" + }, + "toast_message": { + "type": "string" + } + }, + "required": [ + "pattern" + ] + } } } } \ No newline at end of file diff --git a/src/config/schema.ts b/src/config/schema.ts index 950a359f6b..b4df2a9aa7 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -166,6 +166,12 @@ export const CategoryConfigSchema = z.object({ textVerbosity: z.enum(["low", "medium", "high"]).optional(), tools: z.record(z.string(), z.boolean()).optional(), prompt_append: z.string().optional(), + /** Wait before first poll in ms (default: 300000 = 5 min) */ + initial_wait_ms: z.number().min(1000).optional(), + /** Polling interval in ms after initial wait (default: 60000 = 60 sec) */ + poll_interval_ms: z.number().min(500).optional(), + /** Hard timeout in ms - task killed after this (default: 900000 = 15 min) */ + timeout_ms: z.number().min(30000).optional(), }) export const BuiltinCategoryNameSchema = z.enum([ @@ -287,6 +293,12 @@ export const BackgroundTaskConfigSchema = z.object({ defaultConcurrency: z.number().min(1).optional(), providerConcurrency: z.record(z.string(), z.number().min(1)).optional(), modelConcurrency: z.record(z.string(), z.number().min(1)).optional(), + /** Wait before first poll in ms (default: 300000 = 5 min) */ + default_initial_wait_ms: z.number().min(1000).optional(), + /** Polling interval in ms (default: 60000 = 60 sec) */ + default_poll_interval_ms: z.number().min(500).optional(), + /** Hard timeout in ms (default: 900000 = 15 min) */ + default_timeout_ms: z.number().min(30000).optional(), }) export const NotificationConfigSchema = z.object({ @@ -294,6 +306,21 @@ export const NotificationConfigSchema = z.object({ force_enable: z.boolean().optional(), }) +export const KeywordModeConfigSchema = z.object({ + /** Regex pattern to match (case-insensitive by default) */ + pattern: z.string(), + /** Inline prompt to inject when keyword is detected */ + prompt: z.string().optional(), + /** Path to external .md file containing the prompt (supports ~ expansion) */ + prompt_file: z.string().optional(), + /** Whether to show toast notification when mode activates (default: true) */ + show_toast: z.boolean().default(true), + /** Custom toast title */ + toast_title: z.string().optional(), + /** Custom toast message */ + toast_message: z.string().optional(), +}) + export const GitMasterConfigSchema = z.object({ /** Add "Ultraworked with Sisyphus" footer to commit messages (default: true) */ commit_footer: z.boolean().default(true), @@ -320,6 +347,8 @@ export const OhMyOpenCodeConfigSchema = z.object({ background_task: BackgroundTaskConfigSchema.optional(), notification: NotificationConfigSchema.optional(), git_master: GitMasterConfigSchema.optional(), + /** Custom keyword modes - define your own triggers and prompts */ + keyword_modes: z.record(z.string(), KeywordModeConfigSchema).optional(), }) export type OhMyOpenCodeConfig = z.infer @@ -342,5 +371,6 @@ export type CategoryConfig = z.infer export type CategoriesConfig = z.infer export type BuiltinCategoryName = z.infer export type GitMasterConfig = z.infer +export type KeywordModeConfig = z.infer export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 16c38d0377..a27e11239a 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -15,8 +15,10 @@ import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-i import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" -const TASK_TTL_MS = 30 * 60 * 1000 -const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in +const DEFAULT_INITIAL_WAIT_MS = 5 * 60 * 1000 // 5 minutes +const DEFAULT_POLL_INTERVAL_MS = 60 * 1000 // 60 seconds +const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000 // 15 minutes +const MIN_STABILITY_TIME_MS = 10 * 1000 type OpencodeClient = PluginInput["client"] @@ -52,6 +54,9 @@ export class BackgroundManager { private directory: string private pollingInterval?: ReturnType private concurrencyManager: ConcurrencyManager + private defaultInitialWaitMs: number + private defaultPollIntervalMs: number + private defaultTimeoutMs: number constructor(ctx: PluginInput, config?: BackgroundTaskConfig) { this.tasks = new Map() @@ -60,6 +65,9 @@ export class BackgroundManager { this.client = ctx.client this.directory = ctx.directory this.concurrencyManager = new ConcurrencyManager(config) + this.defaultInitialWaitMs = config?.default_initial_wait_ms ?? DEFAULT_INITIAL_WAIT_MS + this.defaultPollIntervalMs = config?.default_poll_interval_ms ?? DEFAULT_POLL_INTERVAL_MS + this.defaultTimeoutMs = config?.default_timeout_ms ?? DEFAULT_TIMEOUT_MS } async launch(input: LaunchInput): Promise { @@ -126,6 +134,9 @@ export class BackgroundManager { parentAgent: input.parentAgent, model: input.model, concurrencyKey, + initial_wait_ms: input.initial_wait_ms ?? this.defaultInitialWaitMs, + poll_interval_ms: input.poll_interval_ms ?? this.defaultPollIntervalMs, + timeout_ms: input.timeout_ms ?? this.defaultTimeoutMs, } this.tasks.set(task.id, task) @@ -173,16 +184,33 @@ export class BackgroundManager { parts: [{ type: "text", text: input.prompt }], }, }).catch((error) => { - log("[background-agent] promptAsync error:", error) + const errorMessage = error instanceof Error ? error.message : String(error) + log("[background-agent] prompt error:", { + sessionID, + agent: input.agent, + errorName: error?.name, + errorMessage, + }) + + // Only treat as fatal if it's clearly an agent/session creation issue. + // Network timeouts, connection drops, and SDK issues should NOT mark task as failed + // since the session may still be running successfully (common during high API load). + const isFatalError = + errorMessage.includes("agent.name") || + errorMessage.includes("undefined") || + errorMessage.includes("not found") || + errorMessage.includes("not registered") || + errorMessage.includes("does not exist") + + if (!isFatalError) { + log("[background-agent] Non-fatal prompt error, letting polling detect actual status:", sessionID) + return + } + const existingTask = this.findBySession(sessionID) if (existingTask) { existingTask.status = "error" - const errorMessage = error instanceof Error ? error.message : String(error) - if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) { - existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.` - } else { - existingTask.error = errorMessage - } + existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.` existingTask.completedAt = new Date() if (existingTask.concurrencyKey) { this.concurrencyManager.release(existingTask.concurrencyKey) @@ -540,12 +568,23 @@ export class BackgroundManager { private startPolling(): void { if (this.pollingInterval) return + const minPollInterval = this.getMinPollInterval() this.pollingInterval = setInterval(() => { this.pollRunningTasks() - }, 2000) + }, minPollInterval) this.pollingInterval.unref() } + private getMinPollInterval(): number { + let minInterval = this.defaultPollIntervalMs + for (const task of this.tasks.values()) { + if (task.status === "running" && task.poll_interval_ms) { + minInterval = Math.min(minInterval, task.poll_interval_ms) + } + } + return minInterval + } + private stopPolling(): void { if (this.pollingInterval) { clearInterval(this.pollingInterval) @@ -713,15 +752,20 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea for (const [taskId, task] of this.tasks.entries()) { const age = now - task.startedAt.getTime() - if (age > TASK_TTL_MS) { - log("[background-agent] Pruning stale task:", { taskId, age: Math.round(age / 1000) + "s" }) + const taskTimeout = task.timeout_ms ?? this.defaultTimeoutMs + if (age > taskTimeout) { + const timeoutMinutes = Math.round(taskTimeout / 60000) + log("[background-agent] Pruning stale task:", { taskId, age: Math.round(age / 1000) + "s", timeout: timeoutMinutes + "m" }) task.status = "error" - task.error = "Task timed out after 30 minutes" + task.error = `Task timed out after ${timeoutMinutes} minutes` task.completedAt = new Date() if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) } - this.clearNotificationsForTask(taskId) + this.markForNotification(task) + this.notifyParentSession(task).catch(err => { + log("[background-agent] Failed to notify on timeout:", err) + }) this.tasks.delete(taskId) subagentSessions.delete(task.sessionID) } @@ -734,7 +778,8 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea } const validNotifications = notifications.filter((task) => { const age = now - task.startedAt.getTime() - return age <= TASK_TTL_MS + const taskTimeout = task.timeout_ms ?? this.defaultTimeoutMs + return age <= taskTimeout }) if (validNotifications.length === 0) { this.notifications.delete(sessionID) @@ -753,6 +798,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea for (const task of this.tasks.values()) { if (task.status !== "running") continue + const age = Date.now() - task.startedAt.getTime() + const initialWait = task.initial_wait_ms ?? this.defaultInitialWaitMs + if (age < initialWait) continue + try { const sessionStatus = allStatuses[task.sessionID] diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index 8c384211b4..913657ed8d 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -36,6 +36,12 @@ export interface BackgroundTask { lastMsgCount?: number /** Number of consecutive polls with stable message count */ stablePolls?: number + /** Initial wait before polling in ms */ + initial_wait_ms?: number + /** Polling interval in ms */ + poll_interval_ms?: number + /** Hard timeout in ms */ + timeout_ms?: number } export interface LaunchInput { @@ -49,6 +55,9 @@ export interface LaunchInput { model?: { providerID: string; modelID: string; variant?: string } skills?: string[] skillContent?: string + initial_wait_ms?: number + poll_interval_ms?: number + timeout_ms?: number } export interface ResumeInput {