Exec approvals: improve batching text + retries

This commit is contained in:
Aaron Ring 2026-01-26 22:21:53 -08:00
parent 340e947d49
commit 9f84b8c266
5 changed files with 77 additions and 29 deletions

View File

@ -14,6 +14,7 @@ vi.mock("../../gateway/call.js", () => ({
vi.mock("../../infra/exec-approval-forwarder.js", () => ({ vi.mock("../../infra/exec-approval-forwarder.js", () => ({
getBatchApprovalIds: vi.fn(), getBatchApprovalIds: vi.fn(),
deleteBatch: vi.fn(), deleteBatch: vi.fn(),
updateBatchApprovalIds: vi.fn(),
})); }));
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) { function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
@ -153,6 +154,7 @@ describe("/approve-batch command", () => {
expect(result.reply?.text).toContain("3 commands approved"); expect(result.reply?.text).toContain("3 commands approved");
expect(mockCallGateway).toHaveBeenCalledTimes(3); expect(mockCallGateway).toHaveBeenCalledTimes(3);
expect(approvalForwarder.deleteBatch).toHaveBeenCalledWith("batch-123"); expect(approvalForwarder.deleteBatch).toHaveBeenCalledWith("batch-123");
expect(approvalForwarder.updateBatchApprovalIds).not.toHaveBeenCalled();
}); });
it("denies all commands in batch", async () => { it("denies all commands in batch", async () => {
@ -170,6 +172,8 @@ describe("/approve-batch command", () => {
expect(result.shouldContinue).toBe(false); expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("2 commands approved"); expect(result.reply?.text).toContain("2 commands approved");
expect(mockCallGateway).toHaveBeenCalledTimes(2); expect(mockCallGateway).toHaveBeenCalledTimes(2);
expect(approvalForwarder.deleteBatch).toHaveBeenCalledWith("batch-456");
expect(approvalForwarder.updateBatchApprovalIds).not.toHaveBeenCalled();
}); });
it("reports partial failures", async () => { it("reports partial failures", async () => {
@ -190,5 +194,7 @@ describe("/approve-batch command", () => {
expect(result.shouldContinue).toBe(false); expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("2 succeeded"); expect(result.reply?.text).toContain("2 succeeded");
expect(result.reply?.text).toContain("1 failed"); expect(result.reply?.text).toContain("1 failed");
expect(approvalForwarder.deleteBatch).not.toHaveBeenCalled();
expect(approvalForwarder.updateBatchApprovalIds).toHaveBeenCalledWith("batch-789", ["id2"]);
}); });
}); });

View File

@ -1,7 +1,11 @@
import { callGateway } from "../../gateway/call.js"; import { callGateway } from "../../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { getBatchApprovalIds, deleteBatch } from "../../infra/exec-approval-forwarder.js"; import {
getBatchApprovalIds,
deleteBatch,
updateBatchApprovalIds,
} from "../../infra/exec-approval-forwarder.js";
import type { CommandHandler } from "./commands-types.js"; import type { CommandHandler } from "./commands-types.js";
const COMMAND = "/approve"; const COMMAND = "/approve";
@ -187,8 +191,12 @@ export const handleApproveBatchCommand: CommandHandler = async (params, allowTex
} }
} }
// Clean up the batch const failedIds = results.filter((r) => !r.success).map((r) => r.id);
deleteBatch(parsed.batchId); if (failedIds.length === 0) {
deleteBatch(parsed.batchId);
} else {
updateBatchApprovalIds(parsed.batchId, failedIds);
}
const succeeded = results.filter((r) => r.success).length; const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length; const failed = results.filter((r) => !r.success).length;

View File

@ -34,6 +34,8 @@ describe("exec approval forwarder", () => {
}); });
await forwarder.handleRequested(baseRequest); await forwarder.handleRequested(baseRequest);
expect(deliver).toHaveBeenCalledTimes(0);
await vi.advanceTimersByTimeAsync(1500);
expect(deliver).toHaveBeenCalledTimes(1); expect(deliver).toHaveBeenCalledTimes(1);
await forwarder.handleResolved({ await forwarder.handleResolved({
@ -69,6 +71,8 @@ describe("exec approval forwarder", () => {
}); });
await forwarder.handleRequested(baseRequest); await forwarder.handleRequested(baseRequest);
expect(deliver).toHaveBeenCalledTimes(0);
await vi.advanceTimersByTimeAsync(1500);
expect(deliver).toHaveBeenCalledTimes(1); expect(deliver).toHaveBeenCalledTimes(1);
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();

View File

@ -83,10 +83,10 @@ function cleanupExpiredBatches(nowMs: number): void {
} }
export function getBatchApprovalIds(batchId: string): string[] | null { export function getBatchApprovalIds(batchId: string): string[] | null {
const entry = batchRegistry.get(batchId);
if (!entry) return null;
// Cleanup expired batches opportunistically // Cleanup expired batches opportunistically
cleanupExpiredBatches(Date.now()); cleanupExpiredBatches(Date.now());
const entry = batchRegistry.get(batchId);
if (!entry) return null;
return entry.approvalIds; return entry.approvalIds;
} }
@ -94,6 +94,18 @@ export function deleteBatch(batchId: string): void {
batchRegistry.delete(batchId); batchRegistry.delete(batchId);
} }
export function updateBatchApprovalIds(batchId: string, approvalIds: string[]): void {
if (approvalIds.length === 0) {
batchRegistry.delete(batchId);
return;
}
// Cleanup expired batches opportunistically
cleanupExpiredBatches(Date.now());
const entry = batchRegistry.get(batchId);
if (!entry) return;
entry.approvalIds = approvalIds;
}
export type ExecApprovalForwarder = { export type ExecApprovalForwarder = {
handleRequested: (request: ExecApprovalRequest) => Promise<void>; handleRequested: (request: ExecApprovalRequest) => Promise<void>;
handleResolved: (resolved: ExecApprovalResolved) => Promise<void>; handleResolved: (resolved: ExecApprovalResolved) => Promise<void>;
@ -203,7 +215,10 @@ function buildBatchRequestMessage(
nowMs: number, nowMs: number,
): ApprovalMessage { ): ApprovalMessage {
const count = requests.length; const count = requests.length;
const lines: string[] = [`🔒 Exec approval required (${count} command${count > 1 ? "s" : ""})`]; const lines: string[] = [
`🔒 Exec approval required (${count} command${count > 1 ? "s" : ""})`,
`Batch ID: ${batchId}`,
];
lines.push(""); lines.push("");
// List each command with a number // List each command with a number
@ -211,7 +226,7 @@ function buildBatchRequestMessage(
const cmd = req.request.command; const cmd = req.request.command;
// Truncate long commands for readability // Truncate long commands for readability
const displayCmd = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd; const displayCmd = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
lines.push(`${idx + 1}. ${displayCmd}`); lines.push(`${idx + 1}. ${displayCmd} (${req.id})`);
}); });
lines.push(""); lines.push("");
@ -228,6 +243,8 @@ function buildBatchRequestMessage(
const earliestExpiry = Math.min(...requests.map((r) => r.expiresAtMs)); const earliestExpiry = Math.min(...requests.map((r) => r.expiresAtMs));
const expiresIn = Math.max(0, Math.round((earliestExpiry - nowMs) / 1000)); const expiresIn = Math.max(0, Math.round((earliestExpiry - nowMs) / 1000));
lines.push(`Expires in: ${expiresIn}s`); lines.push(`Expires in: ${expiresIn}s`);
lines.push(`Approve: /approve-batch ${batchId} allow-once|deny`);
lines.push("Or: /approve <id> allow-once|allow-always|deny");
const buttons = [ const buttons = [
[ [
@ -357,41 +374,52 @@ export function createExecApprovalForwarder(
const cfg = getConfig(); const cfg = getConfig();
const { requests, targets } = batch; const { requests, targets } = batch;
const liveRequests = requests.filter((req) => pending.has(req.id));
if (requests.length === 0 || targets.length === 0) return; if (liveRequests.length === 0 || targets.length === 0) return;
// Generate batch ID and register it let batchId: string | null = null;
const batchId = generateBatchId(); if (liveRequests.length > 1) {
const approvalIds = requests.map((r) => r.id); // Generate batch ID and register it
batchRegistry.set(batchId, { batchId = generateBatchId();
approvalIds, const approvalIds = liveRequests.map((r) => r.id);
sessionKey, // Cleanup any expired batches before inserting
createdAtMs: nowMs(), cleanupExpiredBatches(nowMs());
}); batchRegistry.set(batchId, {
approvalIds,
sessionKey,
createdAtMs: nowMs(),
});
// Update pending entries with batch ID // Update pending entries with batch ID
for (const req of requests) { for (const req of liveRequests) {
const entry = pending.get(req.id); const entry = pending.get(req.id);
if (entry) entry.batchId = batchId; if (entry) entry.batchId = batchId;
}
} }
log.info(`exec approvals: flushing batch ${batchId} with ${requests.length} requests`); log.info(
`exec approvals: flushing ${
liveRequests.length > 1 ? `batch ${batchId}` : "single request"
} with ${liveRequests.length} request${liveRequests.length > 1 ? "s" : ""}`,
);
// Send single or batch message depending on count // Send single or batch message depending on count
if (requests.length === 1) { if (liveRequests.length === 1) {
// Single request - use original format with all buttons // Single request - use original format with all buttons
const message = buildRequestMessage(requests[0], nowMs()); const message = buildRequestMessage(liveRequests[0], nowMs());
await deliverToTargets({ await deliverToTargets({
cfg, cfg,
targets, targets,
text: message.text, text: message.text,
buttons: message.buttons, buttons: message.buttons,
deliver, deliver,
shouldSend: () => pending.has(requests[0].id), shouldSend: () => pending.has(liveRequests[0].id),
}); });
} else { } else {
// Multiple requests - use batch format // Multiple requests - use batch format
const message = buildBatchRequestMessage(requests, batchId, nowMs()); if (!batchId) return;
const message = buildBatchRequestMessage(liveRequests, batchId, nowMs());
await deliverToTargets({ await deliverToTargets({
cfg, cfg,
targets, targets,

View File

@ -17,6 +17,7 @@ import { sendMessageSignal } from "../../signal/send.js";
import type { sendMessageSlack } from "../../slack/send.js"; import type { sendMessageSlack } from "../../slack/send.js";
import type { sendMessageTelegram } from "../../telegram/send.js"; import type { sendMessageTelegram } from "../../telegram/send.js";
import type { sendMessageWhatsApp } from "../../web/outbound.js"; import type { sendMessageWhatsApp } from "../../web/outbound.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { import {
appendAssistantMessageToSessionTranscript, appendAssistantMessageToSessionTranscript,
resolveMirroredTranscriptText, resolveMirroredTranscriptText,
@ -28,6 +29,8 @@ import type { OutboundChannel } from "./targets.js";
export type { NormalizedOutboundPayload } from "./payloads.js"; export type { NormalizedOutboundPayload } from "./payloads.js";
export { normalizeOutboundPayloads } from "./payloads.js"; export { normalizeOutboundPayloads } from "./payloads.js";
const log = createSubsystemLogger("gateway/outbound");
type SendMatrixMessage = ( type SendMatrixMessage = (
to: string, to: string,
text: string, text: string,
@ -321,12 +324,11 @@ export async function deliverOutboundPayloads(params: {
try { try {
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
params.onPayload?.(payloadSummary); params.onPayload?.(payloadSummary);
// Debug: check sendPayload path log.debug(
console.log( `outbound: channel=${channel} hasSendPayload=${!!handler.sendPayload} hasChannelData=${!!payload.channelData}`,
`[outbound] channel=${channel} hasSendPayload=${!!handler.sendPayload} hasChannelData=${!!payload.channelData} channelData=${JSON.stringify(payload.channelData)}`,
); );
if (handler.sendPayload && payload.channelData) { if (handler.sendPayload && payload.channelData) {
console.log(`[outbound] using sendPayload for ${channel}`); log.debug(`outbound: using sendPayload for ${channel}`);
results.push(await handler.sendPayload(payload)); results.push(await handler.sendPayload(payload));
continue; continue;
} }