mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(discord): remove retired subagent progress runtime (#117802)
This commit is contained in:
committed by
GitHub
parent
c46f1e4496
commit
b1f97bb7b3
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user