refactor(discord): remove retired subagent progress runtime (#117802)

This commit is contained in:
Peter Steinberger
2026-08-01 21:36:28 -07:00
committed by GitHub
parent c46f1e4496
commit b1f97bb7b3
8 changed files with 270 additions and 1915 deletions
-19
View File
@@ -815,25 +815,6 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
</Accordion>
<Accordion title="Subagent progress on the source message">
Set `channels.discord.subagentProgress: true` to show background child activity on the Discord message that started the parent run.
```json5
{
channels: {
discord: {
subagentProgress: true,
},
},
}
```
While child runs are active, OpenClaw keeps Discord typing active for up to one hour and replaces one count reaction (`1️⃣` through `🔟`) as the concurrent count changes; `🔟` also represents 10 or more. The count reaction is removed after the final child ends. A failed, timed-out, or killed child leaves a `🔴` reaction.
This is opt-in and uses fixed internal timing and emoji defaults. The bot needs **Add Reactions** permission for reaction feedback. Account-level `channels.discord.accounts.<id>.subagentProgress` overrides the top-level value.
</Accordion>
<Accordion title="Persistent ACP channel bindings">
For stable "always-on" ACP workspaces, configure top-level typed ACP bindings targeting Discord conversations.
@@ -61,7 +61,6 @@ const hookMocks = vi.hoisted(() => {
listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []),
unbindThreadBindingsBySessionKey: vi.fn(() => []),
progressModuleFactory: vi.fn(),
handleDiscordSubagentProgress: vi.fn(),
recoverDiscordSubagentProgress: vi.fn(),
};
});
@@ -78,10 +77,7 @@ vi.mock("./monitor/thread-bindings.js", () => ({
}));
vi.mock("./subagent-progress.js", () => {
hookMocks.progressModuleFactory();
return {
handleDiscordSubagentProgress: hookMocks.handleDiscordSubagentProgress,
recoverDiscordSubagentProgress: hookMocks.recoverDiscordSubagentProgress,
};
return { recoverDiscordSubagentProgress: hookMocks.recoverDiscordSubagentProgress };
});
function registerHandlersForTest(
@@ -221,11 +217,10 @@ describe("discord subagent hook handlers", () => {
hookMocks.listThreadBindingsBySessionKey.mockClear();
hookMocks.unbindThreadBindingsBySessionKey.mockClear();
hookMocks.progressModuleFactory.mockClear();
hookMocks.handleDiscordSubagentProgress.mockClear();
hookMocks.recoverDiscordSubagentProgress.mockClear();
});
it("keeps progress runtime lazy for unrelated subagent hooks", async () => {
it("keeps progress cleanup lazy for unrelated subagent hooks", async () => {
const handlers = registerHandlersForTest();
const handler = getRequiredHookHandler(handlers, "subagent_delivery_target");
@@ -244,7 +239,7 @@ describe("discord subagent hook handlers", () => {
expect(hookMocks.progressModuleFactory).not.toHaveBeenCalled();
});
it("loads progress runtime for gateway recovery", async () => {
it("loads retired progress cleanup on gateway startup", async () => {
const handlers = registerHandlersForTest();
const handler = getRequiredHookHandler(handlers, "gateway_start");
@@ -1,63 +0,0 @@
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
normalizeOptionalLowercaseString,
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/string-coerce-runtime";
export const RUNNING_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"];
export const FAILURE_EMOJI = "🔴";
export type DiscordProgressRequester = {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
channelId?: string | number;
messageId?: string | number;
};
function channelIdFromTarget(target?: string): string | undefined {
const trimmed = target?.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.startsWith("channel:")) {
return trimmed.slice("channel:".length).trim() || undefined;
}
return /^\d+$/u.test(trimmed) ? trimmed : undefined;
}
export function resolveDiscordProgressTarget(requester?: DiscordProgressRequester) {
if (normalizeOptionalLowercaseString(requester?.channel) !== "discord") {
return undefined;
}
const channelId =
normalizeOptionalStringifiedId(requester?.channelId) ?? channelIdFromTarget(requester?.to);
const messageId = normalizeOptionalStringifiedId(requester?.messageId);
if (!channelId || !messageId) {
return undefined;
}
return { channelId, messageId };
}
export function reservedReactionEmojis(config: OpenClawConfig, ackReaction?: string): Set<string> {
const reserved = new Set<string>(Object.values(DEFAULT_EMOJIS));
for (const emoji of [config.messages?.ackReaction, ackReaction]) {
if (emoji?.trim()) {
reserved.add(emoji.trim());
}
}
for (const agent of config.agents?.list ?? []) {
const emoji = agent.identity?.emoji?.trim();
if (emoji) {
reserved.add(emoji);
}
}
return reserved;
}
export function reactionsAreAvailable(config: OpenClawConfig, ackReaction?: string): boolean {
const reserved = reservedReactionEmojis(config, ackReaction);
return !RUNNING_EMOJIS.some((emoji) => reserved.has(emoji)) && !reserved.has(FAILURE_EMOJI);
}
@@ -1,264 +0,0 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
export const PROGRESS_STORE_TTL_MS = 7 * 24 * 60 * 60_000;
export const MAX_TRACKED_RUNS = 4_096;
const TERMINAL_TOMBSTONE_TTL_MS = 60 * 60_000;
export type SubagentProgressOutcome = "ok" | "error" | "timeout" | "killed" | "unknown";
export type ProgressApi = {
config: OpenClawConfig;
logger: { debug?: (message: string) => void };
runtime?: {
state: {
openKeyedStore<T>(options: {
namespace: string;
maxEntries: number;
overflowPolicy: "reject-new";
defaultTtlMs: number;
}): PluginStateKeyedStore<T>;
};
};
};
type PersistedProgressRunBase = {
/** Plugin SQLite ownership lets an ended run clean reactions after gateway restart. */
key: string;
accountId: string;
channelId: string;
messageId: string;
runningEmoji?: string;
};
type PersistedActiveProgressRun = PersistedProgressRunBase & { status: "active" };
type PersistedCleanupProgressRun = PersistedProgressRunBase & {
status: "cleanup";
outcome: SubagentProgressOutcome;
};
export type PersistedProgressRun = PersistedActiveProgressRun | PersistedCleanupProgressRun;
export function persistedTerminalOutcome(
persisted?: PersistedProgressRun,
): SubagentProgressOutcome | undefined {
return persisted?.status === "cleanup" ? persisted.outcome : undefined;
}
export type ProgressTracker = {
accountId: string;
channelId: string;
messageId: string;
activeRunIds: Set<string>;
persistedRunIds: Set<string>;
runningEmoji?: string;
runningEmojiConfirmed: boolean;
reactionsEnabled: boolean;
typingTimer?: ReturnType<typeof setInterval>;
typingExpiresAt: number;
};
type ProgressRunLookupResult =
| { status: "found"; value: PersistedProgressRun }
| { status: "missing" }
| { status: "error" };
export type PersistProgressResult = "persisted" | "terminal" | "conflict" | "error";
type ProgressStateForKeyResult =
| {
ok: true;
activeRunIds: string[];
cleanupRuns: Array<{ runId: string; value: PersistedCleanupProgressRun }>;
ownedEmojis: string[];
}
| { ok: false };
let progressStores = new WeakMap<object, PluginStateKeyedStore<PersistedProgressRun> | null>();
const trackerQueues = new Map<string, Promise<void>>();
const terminalRuns = new Map<string, { expiresAt: number; outcome: SubagentProgressOutcome }>();
export function markRunTerminal(runId: string, outcome: SubagentProgressOutcome) {
const now = Date.now();
for (const [trackedRunId, terminal] of terminalRuns) {
if (terminal.expiresAt <= now) {
terminalRuns.delete(trackedRunId);
}
}
terminalRuns.set(runId, { expiresAt: now + TERMINAL_TOMBSTONE_TTL_MS, outcome });
if (terminalRuns.size > MAX_TRACKED_RUNS) {
const oldest = terminalRuns.keys().next().value;
if (oldest) {
terminalRuns.delete(oldest);
}
}
}
export function terminalOutcome(runId: string): SubagentProgressOutcome | undefined {
const terminal = terminalRuns.get(runId);
if (!terminal) {
return undefined;
}
if (terminal.expiresAt <= Date.now()) {
terminalRuns.delete(runId);
return undefined;
}
return terminal.outcome;
}
export function logFailure(api: ProgressApi, action: string, error: unknown) {
const message = error instanceof Error ? error.message : String(error);
api.logger.debug?.(`discord subagent progress ${action} failed: ${message}`);
}
export function getProgressStore(
api: ProgressApi,
): PluginStateKeyedStore<PersistedProgressRun> | undefined {
const cached = progressStores.get(api);
if (cached !== undefined) {
return cached ?? undefined;
}
if (!api.runtime?.state) {
progressStores.set(api, null);
return undefined;
}
try {
const store = api.runtime.state.openKeyedStore<PersistedProgressRun>({
namespace: "subagent-progress",
maxEntries: MAX_TRACKED_RUNS,
overflowPolicy: "reject-new",
defaultTtlMs: PROGRESS_STORE_TTL_MS,
});
progressStores.set(api, store);
return store;
} catch (error) {
logFailure(api, "state store open", error);
return undefined;
}
}
export function persistedProgressRunFromTracker(tracker: ProgressTracker): PersistedProgressRun {
return {
key: `${tracker.accountId}:${tracker.channelId}:${tracker.messageId}`,
accountId: tracker.accountId,
channelId: tracker.channelId,
messageId: tracker.messageId,
status: "active",
...(tracker.runningEmoji ? { runningEmoji: tracker.runningEmoji } : {}),
};
}
export async function persistProgressRun(
api: ProgressApi,
runId: string,
tracker: ProgressTracker,
): Promise<PersistProgressResult> {
const store = getProgressStore(api);
if (!store) {
return "error";
}
const value = persistedProgressRunFromTracker(tracker);
try {
if (await store.registerIfAbsent(runId, value)) {
return "persisted";
}
const existing = await store.lookup(runId);
if (!existing) {
return "error";
}
if (existing.status === "cleanup") {
return "terminal";
}
return existing.key === value.key ? "persisted" : "conflict";
} catch (error) {
logFailure(api, "state store write", error);
return "error";
}
}
export async function markProgressRunForCleanup(
api: ProgressApi,
runId: string,
persisted: PersistedProgressRun,
outcome: SubagentProgressOutcome,
) {
try {
await getProgressStore(api)?.register(runId, { ...persisted, status: "cleanup", outcome });
return true;
} catch (error) {
logFailure(api, "state store cleanup mark", error);
return false;
}
}
export async function lookupProgressRun(
api: ProgressApi,
runId: string,
): Promise<ProgressRunLookupResult> {
const store = getProgressStore(api);
if (!store) {
return { status: "error" };
}
try {
const value = await store.lookup(runId);
return value ? { status: "found", value } : { status: "missing" };
} catch (error) {
logFailure(api, "state store read", error);
return { status: "error" };
}
}
export async function consumeProgressRun(api: ProgressApi, runId: string) {
try {
return await getProgressStore(api)?.consume(runId);
} catch (error) {
logFailure(api, "state store consume", error);
return undefined;
}
}
export async function listProgressStateForKey(
api: ProgressApi,
key: string,
): Promise<ProgressStateForKeyResult> {
const store = getProgressStore(api);
if (!store) {
return { ok: false };
}
try {
const entries = await store.entries();
const matching = entries.filter((entry) => entry.value.key === key);
const cleanupRuns = matching.flatMap((entry) =>
entry.value.status === "cleanup" ? [{ runId: entry.key, value: entry.value }] : [],
);
return {
ok: true,
activeRunIds: matching
.filter((entry) => entry.value.status === "active")
.map((entry) => entry.key),
cleanupRuns,
ownedEmojis: Array.from(new Set(matching.flatMap((entry) => entry.value.runningEmoji ?? []))),
};
} catch (error) {
logFailure(api, "state store list", error);
return { ok: false };
}
}
export async function runQueued(key: string, task: () => Promise<void>) {
const previous = trackerQueues.get(key) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(task);
trackerQueues.set(key, current);
try {
await current;
} finally {
if (trackerQueues.get(key) === current) {
trackerQueues.delete(key);
}
}
}
export function resetDiscordSubagentProgressStateForTest() {
trackerQueues.clear();
terminalRuns.clear();
progressStores = new WeakMap();
}
File diff suppressed because it is too large Load Diff
+142 -689
View File
@@ -1,735 +1,188 @@
// Discord plugin module maps portable subagent progress onto source-message feedback.
import { resolveDiscordAccount } from "./accounts.js";
import { reactMessageDiscord, removeReactionDiscord } from "./send.reactions.js";
import { sendTypingDiscord } from "./send.typing.js";
import {
FAILURE_EMOJI,
RUNNING_EMOJIS,
reactionsAreAvailable,
reservedReactionEmojis,
resolveDiscordProgressTarget,
type DiscordProgressRequester,
} from "./subagent-progress-config.js";
import {
MAX_TRACKED_RUNS,
PROGRESS_STORE_TTL_MS,
consumeProgressRun,
getProgressStore,
listProgressStateForKey,
logFailure,
lookupProgressRun,
markRunTerminal,
markProgressRunForCleanup,
persistedProgressRunFromTracker,
persistedTerminalOutcome,
persistProgressRun,
resetDiscordSubagentProgressStateForTest,
runQueued,
terminalOutcome,
type PersistedProgressRun,
type PersistProgressResult,
type ProgressApi,
type ProgressTracker,
type SubagentProgressOutcome,
} from "./subagent-progress-state.js";
// Discord plugin module cleans up reactions left by the retired subagent progress feature.
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
PluginStateEntry,
PluginStateKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { resolveDiscordAccount, resolveDiscordAccountConfig } from "./accounts.js";
import { removeReactionDiscord } from "./send.reactions.js";
const TYPING_INTERVAL_MS = 8_500;
const TYPING_TTL_MS = 60 * 60_000;
const TERMINAL_LOOKUP_RETRY_MS = 1_000;
const TERMINAL_RETRY_MAX_DELAY_MS = 60 * 60_000;
const TERMINAL_RETRY_MAX_ATTEMPTS = 12;
const STARTUP_RETRY_MAX_ATTEMPTS = 12;
// Keep beta3's namespace lifecycle so its reaction ownership remains discoverable after upgrade.
const PROGRESS_STORE_TTL_MS = 7 * 24 * 60 * 60_000;
const MAX_TRACKED_RUNS = 4_096;
const RETRY_BASE_DELAY_MS = 1_000;
const RETRY_MAX_DELAY_MS = 60 * 60_000;
const RETRY_MAX_ATTEMPTS = 12;
const HISTORICAL_RUNNING_EMOJIS = new Set([
"1️⃣",
"2️⃣",
"3️⃣",
"4️⃣",
"5️⃣",
"6️⃣",
"7️⃣",
"8️⃣",
"9️⃣",
"🔟",
]);
type SubagentProgressEvent =
| {
phase: "started";
runId: string;
requester?: DiscordProgressRequester;
}
| {
phase: "ended";
runId: string;
outcome: SubagentProgressOutcome;
requester?: Extract<SubagentProgressEvent, { phase: "started" }>["requester"];
type PersistedProgressRun = {
accountId: string;
channelId: string;
messageId: string;
runningEmoji?: string;
};
type ProgressCleanupApi = {
config: OpenClawConfig;
logger: { debug?: (message: string) => void };
runtime: {
state: {
openKeyedStore<T>(options: {
namespace: string;
maxEntries: number;
overflowPolicy: "reject-new";
defaultTtlMs: number;
}): PluginStateKeyedStore<T>;
};
type PersistedReconciliationResult =
| { ok: false }
| {
ok: true;
activeRunIds: string[];
reactionsEnabled: boolean;
typingEnabled: boolean;
runningEmoji?: string;
};
type StartupRecovery = { attempts: number; timer?: ReturnType<typeof setTimeout> };
const trackers = new Map<string, ProgressTracker>();
const trackerKeyByRunId = new Map<string, string>();
const terminalRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
const terminalRetryExpiresAt = new Map<string, number>();
const terminalRetryAttempts = new Map<string, number>();
const startupRecoveryRetries = new Map<ProgressApi, StartupRecovery>();
function isSubagentProgressEnabled(account: ReturnType<typeof resolveDiscordAccount>): boolean {
return Boolean(process.env.VITEST) && account.enabled && account.config.subagentProgress === true;
}
function clearTerminalRetry(runId: string) {
const timer = terminalRetryTimers.get(runId);
if (timer) {
clearTimeout(timer);
}
terminalRetryTimers.delete(runId);
terminalRetryExpiresAt.delete(runId);
terminalRetryAttempts.delete(runId);
}
function cancelTerminalRetryTimer(runId: string) {
const timer = terminalRetryTimers.get(runId);
if (timer) {
clearTimeout(timer);
}
terminalRetryTimers.delete(runId);
}
async function setReaction(api: ProgressApi, tracker: ProgressTracker, emoji: string) {
try {
const result = await reactMessageDiscord(tracker.channelId, tracker.messageId, emoji, {
cfg: api.config,
accountId: tracker.accountId,
});
return result.ok;
} catch (error) {
logFailure(api, "reaction add", error);
return false;
}
}
async function clearReaction(api: ProgressApi, tracker: ProgressTracker, emoji?: string) {
if (!emoji) {
return true;
}
try {
const result = await removeReactionDiscord(tracker.channelId, tracker.messageId, emoji, {
cfg: api.config,
accountId: tracker.accountId,
});
return result.ok;
} catch (error) {
logFailure(api, "reaction remove", error);
return false;
}
}
async function clearRunningReactions(
api: ProgressApi,
tracker: ProgressTracker,
emojis: readonly string[],
) {
const results = await Promise.all(emojis.map((emoji) => clearReaction(api, tracker, emoji)));
return results.every(Boolean);
}
async function persistTrackerRunningEmoji(api: ProgressApi, tracker: ProgressTracker) {
const store = getProgressStore(api);
if (!store) {
return false;
}
try {
await Promise.all(
Array.from(tracker.persistedRunIds, (runId) =>
store.register(runId, persistedProgressRunFromTracker(tracker)),
),
);
return true;
} catch (error) {
logFailure(api, "reaction ownership write", error);
return false;
}
}
async function updateRunningReaction(api: ProgressApi, tracker: ProgressTracker) {
if (!tracker.reactionsEnabled) {
return true;
}
const nextEmoji =
tracker.activeRunIds.size > 0
? RUNNING_EMOJIS[Math.min(tracker.activeRunIds.size, RUNNING_EMOJIS.length) - 1]
: undefined;
if (nextEmoji === tracker.runningEmoji) {
if (!nextEmoji || tracker.runningEmojiConfirmed) {
return true;
}
if (!(await persistTrackerRunningEmoji(api, tracker))) {
return false;
}
tracker.runningEmojiConfirmed = await setReaction(api, tracker, nextEmoji);
return tracker.runningEmojiConfirmed;
}
if (!(await clearReaction(api, tracker, tracker.runningEmoji))) {
return false;
}
tracker.runningEmoji = undefined;
tracker.runningEmojiConfirmed = false;
if (nextEmoji) {
// A lost add response may still have applied; keep attempted ownership for cleanup.
tracker.runningEmoji = nextEmoji;
if (!(await persistTrackerRunningEmoji(api, tracker))) {
tracker.runningEmoji = undefined;
return false;
}
tracker.runningEmojiConfirmed = await setReaction(api, tracker, nextEmoji);
return tracker.runningEmojiConfirmed;
}
await persistTrackerRunningEmoji(api, tracker);
return true;
}
async function disableTrackerReactionsOnCollision(
api: ProgressApi,
tracker: ProgressTracker,
ackReaction?: string,
) {
if (!tracker.reactionsEnabled || reactionsAreAvailable(api.config, ackReaction)) {
return true;
}
const reserved = reservedReactionEmojis(api.config, ackReaction);
if (tracker.runningEmoji && !reserved.has(tracker.runningEmoji)) {
if (!(await clearReaction(api, tracker, tracker.runningEmoji))) {
tracker.reactionsEnabled = false;
return false;
}
}
tracker.runningEmoji = undefined;
tracker.runningEmojiConfirmed = false;
tracker.reactionsEnabled = false;
await persistTrackerRunningEmoji(api, tracker);
return true;
}
async function sendTyping(api: ProgressApi, tracker: ProgressTracker) {
try {
await sendTypingDiscord(tracker.channelId, {
cfg: api.config,
accountId: tracker.accountId,
});
} catch (error) {
logFailure(api, "typing", error);
}
}
function startTyping(api: ProgressApi, tracker: ProgressTracker) {
tracker.typingExpiresAt = Date.now() + TYPING_TTL_MS;
void sendTyping(api, tracker);
if (tracker.typingTimer) {
return;
}
tracker.typingTimer = setInterval(() => {
if (tracker.activeRunIds.size === 0 || Date.now() >= tracker.typingExpiresAt) {
stopTyping(tracker);
return;
}
void sendTyping(api, tracker);
}, TYPING_INTERVAL_MS);
tracker.typingTimer.unref?.();
}
function stopTyping(tracker: ProgressTracker) {
if (tracker.typingTimer) {
clearInterval(tracker.typingTimer);
tracker.typingTimer = undefined;
}
}
async function handleStarted(
api: ProgressApi,
event: Extract<SubagentProgressEvent, { phase: "started" }>,
) {
const runId = event.runId.trim();
const target = resolveDiscordProgressTarget(event.requester);
if (!runId || !target || terminalOutcome(runId)) {
return;
}
const account = resolveDiscordAccount({ cfg: api.config, accountId: event.requester?.accountId });
const key = `${account.accountId}:${target.channelId}:${target.messageId}`;
if (!isSubagentProgressEnabled(account)) {
await runQueued(key, async () => {
const tracker = trackers.get(key);
if (!tracker) {
return;
}
stopTyping(tracker);
tracker.reactionsEnabled = false;
if (!account.enabled || !tracker.runningEmoji) {
return;
}
const reserved = reservedReactionEmojis(api.config, account.config.ackReaction);
if (
!reserved.has(tracker.runningEmoji) &&
(await clearReaction(api, tracker, tracker.runningEmoji))
) {
tracker.runningEmoji = undefined;
tracker.runningEmojiConfirmed = false;
await persistTrackerRunningEmoji(api, tracker);
}
});
return;
}
await runQueued(key, async () => {
let tracker = trackers.get(key);
let restoredCurrentRunWasTerminal = false;
if (!tracker) {
const reactionsEnabled = reactionsAreAvailable(api.config, account.config.ackReaction);
const restored = await listProgressStateForKey(api, key);
if (!restored.ok) {
return;
}
restoredCurrentRunWasTerminal = restored.cleanupRuns.some(
(cleanup) => cleanup.runId === runId,
);
tracker = {
accountId: account.accountId,
channelId: target.channelId,
messageId: target.messageId,
activeRunIds: new Set(restored.activeRunIds),
persistedRunIds: new Set(restored.activeRunIds),
runningEmojiConfirmed: false,
reactionsEnabled,
typingExpiresAt: 0,
};
// Recover post-registration crashes from bot-owned glyphs instead of guessing.
if (restored.activeRunIds.length > 0 || restored.cleanupRuns.length > 0) {
const reserved = reservedReactionEmojis(api.config, account.config.ackReaction);
const cleanupEmojis = restored.ownedEmojis.filter((emoji) => !reserved.has(emoji));
const countsCleared = await clearRunningReactions(api, tracker, cleanupEmojis);
const failedCleanupRuns = restored.cleanupRuns.filter(
(cleanup) => cleanup.value.outcome !== "ok",
);
const failurePresented =
failedCleanupRuns.length === 0 ||
!reactionsEnabled ||
(countsCleared && (await setReaction(api, tracker, FAILURE_EMOJI)));
if (countsCleared && failurePresented) {
for (const cleanup of restored.cleanupRuns) {
markRunTerminal(cleanup.runId, cleanup.value.outcome);
}
await Promise.all(
restored.cleanupRuns.map((cleanup) => consumeProgressRun(api, cleanup.runId)),
);
} else {
tracker.reactionsEnabled = false;
for (const cleanup of restored.cleanupRuns) {
scheduleTerminalLookupRetry(
api,
{
phase: "ended",
runId: cleanup.runId,
outcome: cleanup.value.outcome,
},
cleanup.value,
);
}
}
}
trackers.set(key, tracker);
}
if (!(await disableTrackerReactionsOnCollision(api, tracker, account.config.ackReaction))) {
return;
}
if (restoredCurrentRunWasTerminal) {
if (tracker.activeRunIds.size > 0) {
await updateRunningReaction(api, tracker);
startTyping(api, tracker);
} else {
trackers.delete(key);
}
return;
}
if (tracker.activeRunIds.has(runId)) {
trackerKeyByRunId.set(runId, key);
await updateRunningReaction(api, tracker);
startTyping(api, tracker);
return;
}
let persistResult: PersistProgressResult = "error";
if (tracker.reactionsEnabled) {
persistResult = await persistProgressRun(api, runId, tracker);
if (persistResult === "terminal") {
markRunTerminal(runId, "unknown");
return;
}
if (persistResult === "conflict") {
api.logger.debug?.(`discord subagent progress ignored conflicting run id: ${runId}`);
return;
}
if (persistResult === "error") {
await clearReaction(api, tracker, tracker.runningEmoji);
tracker.runningEmoji = undefined;
tracker.runningEmojiConfirmed = false;
tracker.reactionsEnabled = false;
}
}
tracker.activeRunIds.add(runId);
trackerKeyByRunId.set(runId, key);
if (persistResult === "persisted") {
tracker.persistedRunIds.add(runId);
}
// Tombstone a fast child that ends before presentation so a late start cannot stick.
const endedOutcome = terminalOutcome(runId);
if (endedOutcome) {
const owned = persistedProgressRunFromTracker(tracker);
await markProgressRunForCleanup(api, runId, owned, endedOutcome);
const failurePresented =
endedOutcome === "ok" ||
!tracker.reactionsEnabled ||
(await setReaction(api, tracker, FAILURE_EMOJI));
if (failurePresented) {
await consumeProgressRun(api, runId);
} else {
scheduleTerminalLookupRetry(
api,
{ phase: "ended", runId, outcome: endedOutcome, requester: event.requester },
{ ...owned, status: "cleanup", outcome: endedOutcome },
);
}
tracker.activeRunIds.delete(runId);
tracker.persistedRunIds.delete(runId);
trackerKeyByRunId.delete(runId);
await updateRunningReaction(api, tracker);
if (tracker.activeRunIds.size === 0) {
stopTyping(tracker);
trackers.delete(key);
}
return;
}
await updateRunningReaction(api, tracker);
startTyping(api, tracker);
});
}
async function reconcilePersistedTracker(
api: ProgressApi,
persisted: PersistedProgressRun,
outcome: Extract<SubagentProgressEvent, { phase: "ended" }>["outcome"],
endingRunId: string,
): Promise<PersistedReconciliationResult> {
const store = getProgressStore(api);
let activeRunIds: string[] = [];
if (store) {
try {
const entries = await store.entries();
activeRunIds = entries
.filter(
(entry) =>
entry.key !== endingRunId &&
entry.value.key === persisted.key &&
entry.value.status === "active",
)
.map((entry) => entry.key);
} catch (error) {
logFailure(api, "state store list", error);
return { ok: false };
}
}
const tracker: ProgressTracker = {
accountId: persisted.accountId,
channelId: persisted.channelId,
messageId: persisted.messageId,
activeRunIds: new Set(activeRunIds),
persistedRunIds: new Set(activeRunIds),
runningEmojiConfirmed: false,
reactionsEnabled: true,
typingExpiresAt: 0,
};
const account = resolveDiscordAccount({ cfg: api.config, accountId: persisted.accountId });
const typingEnabled = isSubagentProgressEnabled(account);
const reserved = reservedReactionEmojis(api.config, account.config.ackReaction);
const cleanupEmojis =
persisted.runningEmoji && !reserved.has(persisted.runningEmoji) ? [persisted.runningEmoji] : [];
const reactionsEnabled =
typingEnabled && reactionsAreAvailable(api.config, account.config.ackReaction);
// Preserve newly reserved keycaps, but remove every unreserved glyph that
// this feature could have left behind under the previous configuration.
const reactionsCleared =
account.enabled && (await clearRunningReactions(api, tracker, cleanupEmojis));
const nextEmoji = RUNNING_EMOJIS[Math.min(activeRunIds.length, RUNNING_EMOJIS.length) - 1];
let countPresented = true;
if (reactionsEnabled && reactionsCleared && nextEmoji) {
tracker.runningEmoji = nextEmoji;
countPresented =
(await persistTrackerRunningEmoji(api, tracker)) &&
(await setReaction(api, tracker, nextEmoji));
tracker.runningEmojiConfirmed = countPresented;
}
const outcomePresented =
outcome === "ok" ||
!reactionsEnabled ||
(reactionsCleared && countPresented && (await setReaction(api, tracker, FAILURE_EMOJI)));
if (!reactionsCleared || !countPresented || !outcomePresented) {
return { ok: false };
}
return {
ok: true,
activeRunIds,
reactionsEnabled,
typingEnabled,
...(reactionsEnabled && nextEmoji ? { runningEmoji: nextEmoji } : {}),
};
};
type RecoveryRetry = { attempts: number; timer?: ReturnType<typeof setTimeout> };
const recoveryRetries = new Map<ProgressCleanupApi, RecoveryRetry>();
function logFailure(api: ProgressCleanupApi, action: string, error: unknown) {
const message = error instanceof Error ? error.message : String(error);
api.logger.debug?.(`discord retired subagent progress ${action} failed: ${message}`);
}
function scheduleTerminalLookupRetry(
api: ProgressApi,
event: Extract<SubagentProgressEvent, { phase: "ended" }>,
owned?: PersistedProgressRun,
) {
const runId = event.runId.trim();
if (!runId || terminalRetryTimers.has(runId)) {
return;
}
if (!owned) {
const target = resolveDiscordProgressTarget(event.requester);
const account = resolveDiscordAccount({
cfg: api.config,
accountId: event.requester?.accountId,
});
if (!target || !isSubagentProgressEnabled(account)) {
return;
function reservedReactionEmojis(api: ProgressCleanupApi, accountAckReaction?: string): Set<string> {
const reserved = new Set<string>(Object.values(DEFAULT_EMOJIS));
for (const emoji of [api.config.messages?.ackReaction, accountAckReaction]) {
if (emoji?.trim()) {
reserved.add(emoji.trim());
}
}
if (terminalRetryTimers.size >= MAX_TRACKED_RUNS) {
return;
for (const agent of api.config.agents?.list ?? []) {
const emoji = agent.identity?.emoji?.trim();
if (emoji) {
reserved.add(emoji);
}
}
const expiresAt = terminalRetryExpiresAt.get(runId) ?? Date.now() + PROGRESS_STORE_TTL_MS;
const attempts = terminalRetryAttempts.get(runId) ?? 0;
if (expiresAt <= Date.now() || attempts >= TERMINAL_RETRY_MAX_ATTEMPTS) {
clearTerminalRetry(runId);
return;
}
terminalRetryExpiresAt.set(runId, expiresAt);
terminalRetryAttempts.set(runId, attempts + 1);
const retryDelayMs = Math.min(
TERMINAL_LOOKUP_RETRY_MS * 2 ** Math.min(attempts, 12),
TERMINAL_RETRY_MAX_DELAY_MS,
);
const timer = setTimeout(() => {
terminalRetryTimers.delete(runId);
void handleEnded(api, event, owned);
}, retryDelayMs);
timer.unref?.();
terminalRetryTimers.set(runId, timer);
return reserved;
}
async function handleEnded(
api: ProgressApi,
event: Extract<SubagentProgressEvent, { phase: "ended" }>,
persistedHint?: PersistedProgressRun,
) {
const runId = event.runId.trim();
if (!runId) {
return;
}
markRunTerminal(runId, event.outcome);
const lookup = await lookupProgressRun(api, runId);
const persisted = lookup.status === "found" ? lookup.value : persistedHint;
const key = trackerKeyByRunId.get(runId) ?? persisted?.key;
if (!key) {
if (lookup.status === "error") {
scheduleTerminalLookupRetry(api, event);
} else {
clearTerminalRetry(runId);
}
return;
}
cancelTerminalRetryTimer(runId);
await runQueued(key, async () => {
const tracker = trackers.get(key);
const currentLookup = await lookupProgressRun(api, runId);
const currentPersisted =
currentLookup.status === "found"
? currentLookup.value
: currentLookup.status === "error"
? (persistedHint ?? persisted)
: (persistedHint ?? (tracker ? undefined : persisted));
const outcome =
persistedTerminalOutcome(currentPersisted) ??
persistedTerminalOutcome(persisted) ??
event.outcome;
const retryEvent = outcome === event.outcome ? event : { ...event, outcome };
trackerKeyByRunId.delete(runId);
const owned =
tracker?.persistedRunIds.has(runId) && currentPersisted?.status !== "cleanup"
? persistedProgressRunFromTracker(tracker)
: currentPersisted;
const cleanupMarked = owned
? owned.status === "cleanup" || (await markProgressRunForCleanup(api, runId, owned, outcome))
: true;
if (tracker) {
const currentAccount = resolveDiscordAccount({
cfg: api.config,
accountId: tracker.accountId,
});
if (!isSubagentProgressEnabled(currentAccount)) {
tracker.reactionsEnabled = false;
stopTyping(tracker);
} else {
await disableTrackerReactionsOnCollision(api, tracker, currentAccount.config.ackReaction);
}
}
if (!tracker) {
const reconciliation = owned
? await reconcilePersistedTracker(api, owned, outcome, runId)
: { ok: false as const };
if (reconciliation.ok && owned) {
const consumed = await consumeProgressRun(api, runId);
if (!consumed) {
scheduleTerminalLookupRetry(api, retryEvent, owned);
} else {
clearTerminalRetry(runId);
}
if (reconciliation.typingEnabled && reconciliation.activeRunIds.length > 0) {
const restoredTracker: ProgressTracker = {
accountId: owned.accountId,
channelId: owned.channelId,
messageId: owned.messageId,
activeRunIds: new Set(reconciliation.activeRunIds),
persistedRunIds: new Set(reconciliation.activeRunIds),
runningEmojiConfirmed: Boolean(reconciliation.runningEmoji),
reactionsEnabled: reconciliation.reactionsEnabled,
...(reconciliation.runningEmoji ? { runningEmoji: reconciliation.runningEmoji } : {}),
typingExpiresAt: 0,
};
trackers.set(key, restoredTracker);
for (const activeRunId of reconciliation.activeRunIds) {
trackerKeyByRunId.set(activeRunId, key);
}
startTyping(api, restoredTracker);
}
} else if (owned) {
scheduleTerminalLookupRetry(api, retryEvent, owned);
}
return;
}
tracker.activeRunIds.delete(runId);
tracker.persistedRunIds.delete(runId);
const countReconciled = tracker.reactionsEnabled
? await updateRunningReaction(api, tracker)
: owned
? (await reconcilePersistedTracker(api, owned, outcome, runId)).ok
: true;
const outcomePresented =
outcome === "ok" ||
!tracker.reactionsEnabled ||
(countReconciled && (await setReaction(api, tracker, FAILURE_EMOJI)));
const reconciled = countReconciled && outcomePresented;
if (reconciled && owned) {
const consumed = await consumeProgressRun(api, runId);
if (!consumed) {
scheduleTerminalLookupRetry(api, retryEvent, owned);
} else {
clearTerminalRetry(runId);
}
if (!consumed && !cleanupMarked) {
await markProgressRunForCleanup(api, runId, owned, outcome);
}
} else if (owned) {
scheduleTerminalLookupRetry(api, retryEvent, owned);
}
if (tracker.activeRunIds.size === 0) {
stopTyping(tracker);
trackers.delete(key);
}
});
}
async function handleDiscordSubagentProgressImpl(api: ProgressApi, event: SubagentProgressEvent) {
if (event.phase === "started") {
await handleStarted(api, event);
return;
}
await handleEnded(api, event);
}
function clearStartupRecoveryRetry(api: ProgressApi) {
const retry = startupRecoveryRetries.get(api);
function clearRecoveryRetry(api: ProgressCleanupApi) {
const retry = recoveryRetries.get(api);
if (retry?.timer) {
clearTimeout(retry.timer);
}
startupRecoveryRetries.delete(api);
recoveryRetries.delete(api);
}
function scheduleStartupRecoveryRetry(api: ProgressApi) {
const retry = startupRecoveryRetries.get(api) ?? { attempts: 0 };
if (retry.timer || retry.attempts >= STARTUP_RETRY_MAX_ATTEMPTS) {
function scheduleRecoveryRetry(api: ProgressCleanupApi) {
const retry = recoveryRetries.get(api) ?? { attempts: 0 };
if (retry.timer || retry.attempts >= RETRY_MAX_ATTEMPTS) {
return;
}
const delayMs = Math.min(
TERMINAL_LOOKUP_RETRY_MS * 2 ** retry.attempts,
TERMINAL_RETRY_MAX_DELAY_MS,
);
// A later gateway restart starts a fresh bounded recovery window for retained rows.
const delayMs = Math.min(RETRY_BASE_DELAY_MS * 2 ** retry.attempts, RETRY_MAX_DELAY_MS);
retry.attempts += 1;
retry.timer = setTimeout(() => {
retry.timer = undefined;
void recoverDiscordSubagentProgress(api);
}, delayMs);
retry.timer.unref?.();
startupRecoveryRetries.set(api, retry);
recoveryRetries.set(api, retry);
}
export async function recoverDiscordSubagentProgress(api: ProgressApi) {
const store = getProgressStore(api);
if (!store) {
if (api.runtime?.state) {
scheduleStartupRecoveryRetry(api);
async function cleanPersistedReaction(
api: ProgressCleanupApi,
store: PluginStateKeyedStore<PersistedProgressRun>,
entry: PluginStateEntry<PersistedProgressRun>,
): Promise<boolean> {
try {
// Removed named accounts must not inherit today's root token and mutate another bot's reaction.
if (
entry.value.accountId !== DEFAULT_ACCOUNT_ID &&
!resolveDiscordAccountConfig(api.config, entry.value.accountId)
) {
return false;
}
const account = resolveDiscordAccount({ cfg: api.config, accountId: entry.value.accountId });
if (!account.enabled || account.tokenStatus !== "available") {
return false;
}
const emoji = entry.value.runningEmoji;
// Persisted state can only authorize removal of glyphs the retired feature owned.
if (
emoji &&
HISTORICAL_RUNNING_EMOJIS.has(emoji) &&
!reservedReactionEmojis(api, account.config.ackReaction).has(emoji)
) {
await removeReactionDiscord(entry.value.channelId, entry.value.messageId, emoji, {
cfg: api.config,
accountId: entry.value.accountId,
});
}
// Persisted ownership must survive until the external reaction is gone.
await store.consume(entry.key);
return true;
} catch (error) {
logFailure(api, "startup cleanup", error);
return false;
}
}
async function recoverDiscordSubagentProgressImpl(api: ProgressCleanupApi) {
let store: PluginStateKeyedStore<PersistedProgressRun>;
try {
store = api.runtime.state.openKeyedStore<PersistedProgressRun>({
namespace: "subagent-progress",
maxEntries: MAX_TRACKED_RUNS,
overflowPolicy: "reject-new",
defaultTtlMs: PROGRESS_STORE_TTL_MS,
});
} catch (error) {
logFailure(api, "state store open", error);
scheduleRecoveryRetry(api);
return;
}
let persistedRuns: Array<{ key: string; value: PersistedProgressRun }>;
let entries: PluginStateEntry<PersistedProgressRun>[];
try {
persistedRuns = await store.entries();
entries = await store.entries();
} catch (error) {
logFailure(api, "startup recovery list", error);
scheduleStartupRecoveryRetry(api);
scheduleRecoveryRetry(api);
return;
}
clearStartupRecoveryRetry(api);
// Subagents share the gateway process, so no active run survives a cold
// start. Replaying every row repairs both interrupted and pending cleanup.
for (const entry of persistedRuns) {
await handleEnded(
api,
{
phase: "ended",
runId: entry.key,
outcome: persistedTerminalOutcome(entry.value) ?? "unknown",
},
entry.value,
);
let retryNeeded = false;
for (const entry of entries) {
if (!(await cleanPersistedReaction(api, store, entry))) {
retryNeeded = true;
}
}
if (retryNeeded) {
scheduleRecoveryRetry(api);
} else {
clearRecoveryRetry(api);
}
}
function resetDiscordSubagentProgressForTest() {
for (const tracker of trackers.values()) {
stopTyping(tracker);
}
trackers.clear();
trackerKeyByRunId.clear();
for (const timer of terminalRetryTimers.values()) {
clearTimeout(timer);
}
terminalRetryTimers.clear();
terminalRetryExpiresAt.clear();
terminalRetryAttempts.clear();
for (const retry of startupRecoveryRetries.values()) {
for (const [api, retry] of recoveryRetries) {
if (retry.timer) {
clearTimeout(retry.timer);
}
recoveryRetries.delete(api);
}
startupRecoveryRetries.clear();
resetDiscordSubagentProgressStateForTest();
}
export const handleDiscordSubagentProgress = Object.assign(handleDiscordSubagentProgressImpl, {
export const recoverDiscordSubagentProgress = Object.assign(recoverDiscordSubagentProgressImpl, {
resetForTest: resetDiscordSubagentProgressForTest,
});
-4
View File
@@ -16,10 +16,6 @@ export function registerDiscordSubagentHooks(api: OpenClawPluginApi): void {
const { recoverDiscordSubagentProgress } = await loadDiscordSubagentProgressModule();
await recoverDiscordSubagentProgress(api);
});
api.on("subagent_progress", async (event) => {
const { handleDiscordSubagentProgress } = await loadDiscordSubagentProgressModule();
await handleDiscordSubagentProgress(api, event);
});
api.on("subagent_ended", async (event) => {
const { handleDiscordSubagentEnded } = await loadDiscordSubagentHooksModule();
handleDiscordSubagentEnded(event);
@@ -38,7 +38,6 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = {
"gateway_start",
"subagent_delivery_target",
"subagent_ended",
"subagent_progress",
],
"extensions/feishu/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
"extensions/matrix/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],