From f12dedb5c880b82ccd9971e0b46ff0627730186b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 28 Apr 2026 03:20:43 -0700 Subject: [PATCH] fix(tasks): keep media tool runs live --- CHANGELOG.md | 1 + .../tools/media-generate-background-shared.ts | 67 ++++++++++++------- .../tools/video-generate-background.test.ts | 38 +++++++++++ src/infra/agent-events.test.ts | 2 + src/infra/agent-events.ts | 6 ++ ...k-registry.maintenance.issue-60299.test.ts | 47 +++++++++++++ 6 files changed, 137 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4243813efaf..27f8647ad15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/media: register detached `video_generate` and `music_generate` tool run contexts until terminal status, so Discord-backed provider jobs stay live in `/tasks` instead of becoming `lost` when the parent chat run context disappears. Thanks @vincentkoc. - Tasks/media: infer agent ownership for session-scoped task records so `/tasks` agent-local fallback includes session-backed `video_generate` and other async media jobs even when the current chat session has no linked rows. Thanks @vincentkoc. - Agents/media: keep long-running `video_generate` and `music_generate` tasks fresh while provider jobs are still pending, so task maintenance does not mark active Discord media renders lost before completion. Thanks @vincentkoc. - CLI/status: treat scope-limited gateway probes as reachable-but-degraded in shared status scans, so `openclaw status --all` no longer reports a live gateway as unreachable after `missing scope: operator.read`. Fixes #49180; supersedes #47981. Thanks @openjay. diff --git a/src/agents/tools/media-generate-background-shared.ts b/src/agents/tools/media-generate-background-shared.ts index 5b279024a0c..34816c0293b 100644 --- a/src/agents/tools/media-generate-background-shared.ts +++ b/src/agents/tools/media-generate-background-shared.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; @@ -64,6 +65,13 @@ type WakeMediaGenerationTaskCompletionParams = { statsLine?: string; }; +function touchMediaGenerationTaskRunContext(handle: MediaGenerationTaskHandle) { + registerAgentRunContext(handle.runId, { + sessionKey: handle.requesterSessionKey, + lastActiveAt: Date.now(), + }); +} + export function createMediaGenerationTaskRun(params: { sessionKey?: string; requesterOrigin?: DeliveryContext; @@ -98,13 +106,15 @@ export function createMediaGenerationTaskRun(params: { lastEventAt: Date.now(), progressSummary: params.queuedProgressSummary, }); - return { + const handle = { taskId: task.taskId, runId, requesterSessionKey: sessionKey, requesterOrigin: params.requesterOrigin, taskLabel: params.prompt, }; + touchMediaGenerationTaskRunContext(handle); + return handle; } catch (error) { log.warn("Failed to create media generation task ledger record", { sessionKey, @@ -124,6 +134,7 @@ export function recordMediaGenerationTaskProgress(params: { if (!params.handle) { return; } + touchMediaGenerationTaskRunContext(params.handle); recordTaskRunProgressByRunId({ runId: params.handle.runId, runtime: "cli", @@ -169,17 +180,21 @@ export function completeMediaGenerationTaskRun(params: { if (!params.handle) { return; } - const endedAt = Date.now(); - const target = params.count === 1 ? params.paths[0] : `${params.count} files`; - completeTaskRunByRunId({ - runId: params.handle.runId, - runtime: "cli", - sessionKey: params.handle.requesterSessionKey, - endedAt, - lastEventAt: endedAt, - progressSummary: `Generated ${params.count} ${params.generatedLabel}${params.count === 1 ? "" : "s"}`, - terminalSummary: `Generated ${params.count} ${params.generatedLabel}${params.count === 1 ? "" : "s"} with ${params.provider}/${params.model}${target ? ` -> ${target}` : ""}.`, - }); + try { + const endedAt = Date.now(); + const target = params.count === 1 ? params.paths[0] : `${params.count} files`; + completeTaskRunByRunId({ + runId: params.handle.runId, + runtime: "cli", + sessionKey: params.handle.requesterSessionKey, + endedAt, + lastEventAt: endedAt, + progressSummary: `Generated ${params.count} ${params.generatedLabel}${params.count === 1 ? "" : "s"}`, + terminalSummary: `Generated ${params.count} ${params.generatedLabel}${params.count === 1 ? "" : "s"} with ${params.provider}/${params.model}${target ? ` -> ${target}` : ""}.`, + }); + } finally { + clearAgentRunContext(params.handle.runId); + } } export function failMediaGenerationTaskRun(params: { @@ -190,18 +205,22 @@ export function failMediaGenerationTaskRun(params: { if (!params.handle) { return; } - const endedAt = Date.now(); - const errorText = formatErrorMessage(params.error); - failTaskRunByRunId({ - runId: params.handle.runId, - runtime: "cli", - sessionKey: params.handle.requesterSessionKey, - endedAt, - lastEventAt: endedAt, - error: errorText, - progressSummary: params.progressSummary, - terminalSummary: errorText, - }); + try { + const endedAt = Date.now(); + const errorText = formatErrorMessage(params.error); + failTaskRunByRunId({ + runId: params.handle.runId, + runtime: "cli", + sessionKey: params.handle.requesterSessionKey, + endedAt, + lastEventAt: endedAt, + error: errorText, + progressSummary: params.progressSummary, + terminalSummary: errorText, + }); + } finally { + clearAgentRunContext(params.handle.runId); + } } function buildMediaGenerationReplyInstruction(params: { diff --git a/src/agents/tools/video-generate-background.test.ts b/src/agents/tools/video-generate-background.test.ts index 60912274731..04a23a1e685 100644 --- a/src/agents/tools/video-generate-background.test.ts +++ b/src/agents/tools/video-generate-background.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getAgentRunContext, resetAgentRunContextForTest } from "../../infra/agent-events.js"; import { VIDEO_GENERATION_TASK_KIND } from "../video-generation-task-status.js"; import { announceDeliveryMocks, @@ -18,6 +19,7 @@ vi.mock("../subagent-announce-delivery.js", () => announceDeliveryMocks); const { createVideoGenerationTaskRun, + failVideoGenerationTaskRun, recordVideoGenerationTaskProgress, wakeVideoGenerationTaskCompletion, } = await import("./video-generate-background.js"); @@ -25,6 +27,7 @@ const { withMediaGenerationTaskKeepalive } = await import("./media-generate-back describe("video generate background helpers", () => { beforeEach(() => { + resetAgentRunContextForTest(); resetMediaBackgroundMocks({ taskExecutorMocks, taskDeliveryRuntimeMocks, @@ -34,6 +37,7 @@ describe("video generate background helpers", () => { afterEach(() => { vi.useRealTimers(); + resetAgentRunContextForTest(); }); it("creates a running task with queued progress text", () => { @@ -82,6 +86,40 @@ describe("video generate background helpers", () => { }); }); + it("keeps the detached video tool run context registered until terminal status", () => { + taskExecutorMocks.createRunningTaskRun.mockReturnValue({ + taskId: "task-123", + }); + + const handle = createVideoGenerationTaskRun({ + sessionKey: "agent:main:discord:channel:123", + prompt: "friendly lobster surfing", + providerId: "fal", + }); + if (!handle) { + throw new Error("expected video generation task handle"); + } + + expect(handle.runId).toMatch(/^tool:video_generate:/); + expect(getAgentRunContext(handle.runId)).toMatchObject({ + sessionKey: "agent:main:discord:channel:123", + }); + + recordVideoGenerationTaskProgress({ + handle, + progressSummary: "Generating video", + }); + + expect(getAgentRunContext(handle.runId)?.lastActiveAt).toEqual(expect.any(Number)); + + failVideoGenerationTaskRun({ + handle, + error: new Error("provider failed"), + }); + + expect(getAgentRunContext(handle.runId)).toBeUndefined(); + }); + it("keeps long-running media tasks fresh while provider work is pending", async () => { vi.useFakeTimers(); let resolveRun!: (value: string) => void; diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index 7d4a9ead650..4661bf36dbf 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -106,6 +106,7 @@ describe("agent-events sequencing", () => { registerAgentRunContext("run-ctx", { verboseLevel: "full", isHeartbeat: true, + lastActiveAt: 12_345, }); expect(getAgentRunContext("run-ctx")).toMatchObject({ @@ -113,6 +114,7 @@ describe("agent-events sequencing", () => { verboseLevel: "full", isHeartbeat: true, isControlUiVisible: true, + lastActiveAt: 12_345, }); }); diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 339d3673dfa..e058d72d3fd 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -159,6 +159,12 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext) if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) { existing.isHeartbeat = context.isHeartbeat; } + if (context.registeredAt !== undefined) { + existing.registeredAt = context.registeredAt; + } + if (context.lastActiveAt !== undefined) { + existing.lastActiveAt = context.lastActiveAt; + } } export function getAgentRunContext(runId: string) { diff --git a/src/tasks/task-registry.maintenance.issue-60299.test.ts b/src/tasks/task-registry.maintenance.issue-60299.test.ts index 8a266dea1b7..20e369a37d7 100644 --- a/src/tasks/task-registry.maintenance.issue-60299.test.ts +++ b/src/tasks/task-registry.maintenance.issue-60299.test.ts @@ -341,6 +341,53 @@ describe("task-registry maintenance issue #60299", () => { expect(currentTasks.get(task.taskId)).toMatchObject({ status: "running" }); }); + it("keeps detached media cli tasks live while their tool run context is active", async () => { + const channelKey = "agent:main:discord:channel:1456744319972282449"; + const runId = "tool:video_generate:ac88dfc5-c2a9-4630-ab48-384e6450a12b"; + const task = makeStaleTask({ + runtime: "cli", + taskKind: "video_generation", + sourceId: "video_generate:fal", + runId, + ownerKey: channelKey, + requesterSessionKey: channelKey, + childSessionKey: channelKey, + progressSummary: "Generating video", + }); + + const { currentTasks } = createTaskRegistryMaintenanceHarness({ + tasks: [task], + sessionStore: { [channelKey]: { sessionId: channelKey, updatedAt: Date.now() } }, + activeRunIds: [runId], + }); + + expect(await runTaskRegistryMaintenance()).toMatchObject({ reconciled: 0 }); + expect(currentTasks.get(task.taskId)).toMatchObject({ status: "running" }); + }); + + it("keeps recently refreshed media cli tasks live without a chat run context", async () => { + const channelKey = "agent:main:discord:channel:1456744319972282449"; + const task = makeStaleTask({ + runtime: "cli", + taskKind: "video_generation", + sourceId: "video_generate:fal", + runId: "tool:video_generate:3a948fb2-79e8-470c-a6bc-46f37732cd3d", + ownerKey: channelKey, + requesterSessionKey: channelKey, + childSessionKey: channelKey, + lastEventAt: Date.now() - 60_000, + progressSummary: "Generating video", + }); + + const { currentTasks } = createTaskRegistryMaintenanceHarness({ + tasks: [task], + sessionStore: { [channelKey]: { sessionId: channelKey, updatedAt: Date.now() } }, + }); + + expect(await runTaskRegistryMaintenance()).toMatchObject({ reconciled: 0 }); + expect(currentTasks.get(task.taskId)).toMatchObject({ status: "running" }); + }); + it("skips markTaskLost and counts recovered when recovery hook recovers a stale task", async () => { const task = makeStaleTask({ runtime: "cron",