fix(mattermost): latch send failures; include patchInterval in onSettled cleanup

Two fixes:

1. Failure latch (Codex ID=2964357928): add previewSendFailed boolean that is set true
   in both the initial-send and patch-edit catch blocks (alongside stopPatchInterval).
   schedulePatch() checks it before re-arming the interval, so subsequent onPartialReply
   calls during a run with a permanent failure (missing permission, DM-creation error)
   no longer recreate the timer and retry indefinitely.

2. patchInterval in onSettled guard (Codex ID=2964357925): onSettled now triggers
   cleanup when patchInterval is non-null, even if streamMessageId and patchSending
   are still falsy. This covers the window between schedulePatch arming the interval
   and the first 200ms tick flipping patchSending — if the run ends in that window
   (same-target messaging-tool sends, empty/heartbeat replies), the interval is now
   stopped and the pending state is cleared.
This commit is contained in:
teconomix 2026-03-20 08:21:53 +00:00
parent 0ecdda1433
commit 374c92947a

View File

@ -1409,6 +1409,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
let lastSentText = "";
let patchInterval: ReturnType<typeof setInterval> | null = null;
let patchSending = false; // prevents concurrent network calls
// Latches true after the first send/edit failure to prevent the interval
// from being re-armed by a later onPartialReply call (ID=2964357928).
let previewSendFailed = false;
const STREAM_PATCH_INTERVAL_MS = 200;
// Edit-in-place streaming is opt-in: only activate when blockStreaming is
@ -1475,6 +1478,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const schedulePatch = (fullText: string) => {
if (!blockStreamingClient) return;
// Do not re-arm if a permanent send/edit failure has been latched.
if (previewSendFailed) return;
pendingPatchText = fullText;
if (patchInterval) return;
patchInterval = setInterval(() => {
@ -1499,9 +1504,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
runtime.log?.(`stream-patch started ${streamMessageId}`);
} catch (err) {
logVerboseMessage(`mattermost stream-patch send failed: ${String(err)}`);
// Stop retrying on initial-send failure (e.g. missing post permission,
// DM-channel creation failure). Without this the interval keeps firing
// every 200 ms and flooding the API/logs for the rest of the response.
// Latch the failure so schedulePatch() does not re-arm the interval
// on subsequent onPartialReply calls (which would retry indefinitely).
previewSendFailed = true;
stopPatchInterval();
}
} else {
@ -1514,9 +1519,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
runtime.log?.(`stream-patch edited ${streamMessageId}`);
} catch (err) {
logVerboseMessage(`mattermost stream-patch edit failed: ${String(err)}`);
// Stop retrying on patch failure — the post may not be editable
// (missing edit_post permission, deleted, etc.). The preview stays
// frozen; final delivery will patch or replace it via deliver().
// Latch the failure so schedulePatch() does not re-arm the interval
// on subsequent onPartialReply calls.
previewSendFailed = true;
stopPatchInterval();
}
}
@ -1709,7 +1714,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
// (patchSending=true, streamMessageId=null): stopPatchInterval() prevents new
// ticks, and the async cleanup waits for patchSending to clear so it can capture
// the messageId that the in-flight send will set.
if ((streamMessageId || patchSending) && blockStreamingClient) {
if ((streamMessageId || patchSending || patchInterval) && blockStreamingClient) {
stopPatchInterval();
pendingPatchText = "";
lastSentText = "";