fix(tasks): keep media tool runs live

This commit is contained in:
Vincent Koc
2026-04-28 03:20:43 -07:00
parent 1b13f53047
commit f12dedb5c8
6 changed files with 137 additions and 24 deletions

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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,
});
});

View File

@@ -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) {

View File

@@ -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",