feat(exec-events): bead-5 - instrument exec lifecycle

This commit is contained in:
Grace (Clawdbot) 2026-01-27 14:25:01 +01:00
parent 5bde9de32f
commit ecfa976280
3 changed files with 228 additions and 35 deletions

View File

@ -1,4 +1,5 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { ExecEventSessionState } from "../infra/exec-events.js";
import { createSessionSlug as createSessionSlugId } from "./session-slug.js"; import { createSessionSlug as createSessionSlugId } from "./session-slug.js";
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
@ -47,6 +48,7 @@ export interface ProcessSession {
exited: boolean; exited: boolean;
truncated: boolean; truncated: boolean;
backgrounded: boolean; backgrounded: boolean;
execEvents?: ExecEventSessionState;
} }
export interface FinishedSession { export interface FinishedSession {

View File

@ -4,6 +4,7 @@ import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { loadConfig } from "../config/config.js";
import { import {
type ExecAsk, type ExecAsk,
type ExecHost, type ExecHost,
@ -19,6 +20,16 @@ import {
resolveExecApprovals, resolveExecApprovals,
resolveExecApprovalsFromFile, resolveExecApprovalsFromFile,
} from "../infra/exec-approvals.js"; } from "../infra/exec-approvals.js";
import { getExecEventContext } from "../infra/exec-events-context.js";
import {
createThrottledExecOutputBuffer,
emitExecEvent,
matchExecCommandAgainstWhitelist,
resolveExecEventsConfig,
toExecEventRuntimeConfig,
type ExecEventContext,
type ExecEventSessionState,
} from "../infra/exec-events.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { buildNodeShellCommand } from "../infra/node-shell.js"; import { buildNodeShellCommand } from "../infra/node-shell.js";
import { import {
@ -251,6 +262,21 @@ function normalizeNotifyOutput(value: string) {
return value.replace(/\s+/g, " ").trim(); return value.replace(/\s+/g, " ").trim();
} }
function normalizeExecEventContext(
context: ExecEventContext | undefined,
): ExecEventContext | undefined {
if (!context) return undefined;
const runId = context.runId?.trim();
const toolCallId = context.toolCallId?.trim();
const sessionKey = context.sessionKey?.trim();
if (!runId && !toolCallId && !sessionKey) return undefined;
return {
...(runId ? { runId } : {}),
...(toolCallId ? { toolCallId } : {}),
...(sessionKey ? { sessionKey } : {}),
} satisfies ExecEventContext;
}
function normalizePathPrepend(entries?: string[]) { function normalizePathPrepend(entries?: string[]) {
if (!Array.isArray(entries)) return []; if (!Array.isArray(entries)) return [];
const seen = new Set<string>(); const seen = new Set<string>();
@ -361,6 +387,16 @@ async function runExecProcess(opts: {
let child: ChildProcessWithoutNullStreams | null = null; let child: ChildProcessWithoutNullStreams | null = null;
let pty: PtyHandle | null = null; let pty: PtyHandle | null = null;
let stdin: SessionStdin | undefined; let stdin: SessionStdin | undefined;
const execEventsConfig = resolveExecEventsConfig(loadConfig());
const execEventsMatch =
execEventsConfig.emitEvents === true
? matchExecCommandAgainstWhitelist(opts.command, execEventsConfig)
: { matched: false };
const execEventsEnabled = execEventsConfig.emitEvents === true && execEventsMatch.matched;
const execEventContext = normalizeExecEventContext(getExecEventContext());
const execEventsRuntimeConfig = execEventsEnabled
? toExecEventRuntimeConfig(execEventsConfig)
: undefined;
if (opts.sandbox) { if (opts.sandbox) {
const { child: spawned } = await spawnWithFallback({ const { child: spawned } = await spawnWithFallback({
@ -517,9 +553,88 @@ async function runExecProcess(opts: {
exitSignal: undefined as NodeJS.Signals | number | null | undefined, exitSignal: undefined as NodeJS.Signals | number | null | undefined,
truncated: false, truncated: false,
backgrounded: false, backgrounded: false,
execEvents: {
enabled: execEventsEnabled,
commandName: execEventsMatch.commandName,
context: execEventContext,
config: execEventsRuntimeConfig,
startedEmitted: false,
completedEmitted: false,
} satisfies ExecEventSessionState,
} satisfies ProcessSession; } satisfies ProcessSession;
addSession(session); addSession(session);
const execEventsState = session.execEvents;
if (execEventsState?.enabled && execEventsState.config) {
execEventsState.outputBuffer = createThrottledExecOutputBuffer({
throttleMs: execEventsState.config.outputThrottleMs,
maxChunkBytes: execEventsState.config.outputMaxChunkBytes,
maxBufferBytes: execEventsState.config.outputBufferMaxBytes,
onFlush: (chunks) => {
if (!execEventsState.enabled) return;
for (const chunk of chunks) {
emitExecEvent("exec.output", {
sessionId,
pid: session.pid,
command: session.command,
commandName: execEventsState.commandName,
context: execEventsState.context,
stream: chunk.stream,
output: chunk.output,
...(chunk.truncated ? { truncated: true } : {}),
});
}
},
});
}
const emitExecStarted = () => {
if (!execEventsState?.enabled || execEventsState.startedEmitted) return;
execEventsState.startedEmitted = true;
emitExecEvent("exec.started", {
sessionId,
pid: session.pid,
command: session.command,
commandName: execEventsState.commandName,
context: execEventsState.context,
startedAt,
cwd: session.cwd,
});
};
const finalizeExecEvents = (params: {
status: "completed" | "failed";
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
timedOut?: boolean;
reason?: string;
}) => {
if (!execEventsState?.enabled || execEventsState.completedEmitted) return;
execEventsState.completedEmitted = true;
execEventsState.outputBuffer?.flushAll();
execEventsState.outputBuffer?.dispose();
const completedAt = Date.now();
emitExecEvent("exec.completed", {
sessionId,
pid: session.pid,
command: session.command,
commandName: execEventsState.commandName,
context: execEventsState.context,
startedAt,
completedAt,
durationMs: completedAt - startedAt,
status: params.status,
exitCode: params.exitCode,
exitSignal: params.exitSignal,
...(params.timedOut ? { timedOut: true } : {}),
...(params.reason ? { reason: params.reason } : {}),
});
};
emitExecStarted();
const execOutputBuffer = execEventsState?.outputBuffer;
let settled = false; let settled = false;
let timeoutTimer: NodeJS.Timeout | null = null; let timeoutTimer: NodeJS.Timeout | null = null;
let timeoutFinalizeTimer: NodeJS.Timeout | null = null; let timeoutFinalizeTimer: NodeJS.Timeout | null = null;
@ -539,6 +654,13 @@ async function runExecProcess(opts: {
maybeNotifyOnExit(session, "failed"); maybeNotifyOnExit(session, "failed");
const aggregated = session.aggregated.trim(); const aggregated = session.aggregated.trim();
const reason = `Command timed out after ${opts.timeoutSec} seconds`; const reason = `Command timed out after ${opts.timeoutSec} seconds`;
finalizeExecEvents({
status: "failed",
exitCode: null,
exitSignal: "SIGKILL",
timedOut: true,
reason,
});
settle({ settle({
status: "failed", status: "failed",
exitCode: null, exitCode: null,
@ -587,6 +709,9 @@ async function runExecProcess(opts: {
const str = sanitizeBinaryOutput(data.toString()); const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) { for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk); appendOutput(session, "stdout", chunk);
if (execOutputBuffer) {
execOutputBuffer.append("stdout", chunk);
}
emitUpdate(); emitUpdate();
} }
}; };
@ -595,6 +720,9 @@ async function runExecProcess(opts: {
const str = sanitizeBinaryOutput(data.toString()); const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) { for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk); appendOutput(session, "stderr", chunk);
if (execOutputBuffer) {
execOutputBuffer.append("stderr", chunk);
}
emitUpdate(); emitUpdate();
} }
}; };
@ -625,23 +753,32 @@ async function runExecProcess(opts: {
const wasSignal = exitSignal != null; const wasSignal = exitSignal != null;
const isSuccess = code === 0 && !wasSignal && !timedOut; const isSuccess = code === 0 && !wasSignal && !timedOut;
const status: "completed" | "failed" = isSuccess ? "completed" : "failed"; const status: "completed" | "failed" = isSuccess ? "completed" : "failed";
markExited(session, code, exitSignal, status); const failureReason = !isSuccess
maybeNotifyOnExit(session, status); ? timedOut
if (!session.child && session.stdin) {
session.stdin.destroyed = true;
}
if (settled) return;
const aggregated = session.aggregated.trim();
if (!isSuccess) {
const reason = timedOut
? `Command timed out after ${opts.timeoutSec} seconds` ? `Command timed out after ${opts.timeoutSec} seconds`
: wasSignal && exitSignal : wasSignal && exitSignal
? `Command aborted by signal ${exitSignal}` ? `Command aborted by signal ${exitSignal}`
: code === null : code === null
? "Command aborted before exit code was captured" ? "Command aborted before exit code was captured"
: `Command exited with code ${code}`; : `Command exited with code ${code}`
const message = aggregated ? `${aggregated}\n\n${reason}` : reason; : undefined;
markExited(session, code, exitSignal, status);
maybeNotifyOnExit(session, status);
if (!session.child && session.stdin) {
session.stdin.destroyed = true;
}
finalizeExecEvents({
status,
exitCode: code ?? null,
exitSignal: exitSignal ?? null,
...(timedOut ? { timedOut: true } : {}),
...(failureReason ? { reason: failureReason } : {}),
});
if (settled) return;
const aggregated = session.aggregated.trim();
if (!isSuccess) {
const message = aggregated ? `${aggregated}\n\n${failureReason}` : failureReason;
settle({ settle({
status: "failed", status: "failed",
exitCode: code ?? null, exitCode: code ?? null,
@ -649,7 +786,7 @@ async function runExecProcess(opts: {
durationMs, durationMs,
aggregated, aggregated,
timedOut, timedOut,
reason: message, reason: message ?? undefined,
}); });
return; return;
} }
@ -679,8 +816,16 @@ async function runExecProcess(opts: {
if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer); if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer);
markExited(session, null, null, "failed"); markExited(session, null, null, "failed");
maybeNotifyOnExit(session, "failed"); maybeNotifyOnExit(session, "failed");
const reason = String(err);
finalizeExecEvents({
status: "failed",
exitCode: null,
exitSignal: null,
...(timedOut ? { timedOut: true } : {}),
reason,
});
const aggregated = session.aggregated.trim(); const aggregated = session.aggregated.trim();
const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err); const message = aggregated ? `${aggregated}\n\n${reason}` : reason;
settle({ settle({
status: "failed", status: "failed",
exitCode: null, exitCode: null,

View File

@ -116,6 +116,13 @@ const WRAPPER_COMMANDS = new Set([
const WRAPPER_SUBCOMMANDS = new Set(["exec", "run", "run-script", "dlx", "x"]); const WRAPPER_SUBCOMMANDS = new Set(["exec", "run", "run-script", "dlx", "x"]);
type ExecEventsConfigInput = {
emitEvents?: boolean;
commandWhitelist?: string[];
outputThrottleMs?: number;
outputMaxChunkBytes?: number;
};
type WhitelistMatchResult = { type WhitelistMatchResult = {
matched: boolean; matched: boolean;
commandName?: string; commandName?: string;
@ -131,6 +138,21 @@ export type ExecEventsConfigResolved = {
outputBufferMaxBytes: number; outputBufferMaxBytes: number;
}; };
export type ExecEventRuntimeConfig = Pick<
ExecEventsConfigResolved,
"outputThrottleMs" | "outputMaxChunkBytes" | "outputBufferMaxBytes"
>;
export type ExecEventSessionState = {
enabled: boolean;
commandName?: string;
context?: ExecEventContext;
config?: ExecEventRuntimeConfig;
startedEmitted: boolean;
completedEmitted: boolean;
outputBuffer?: ExecOutputBuffer;
};
function normalizeCommandName(value: string): string { function normalizeCommandName(value: string): string {
return path.basename(value.trim()).toLowerCase(); return path.basename(value.trim()).toLowerCase();
} }
@ -158,7 +180,8 @@ function resolvePositiveInt(value: unknown, fallback: number, min?: number): num
} }
export function resolveExecEventsConfig(cfg?: MoltbotConfig): ExecEventsConfigResolved { export function resolveExecEventsConfig(cfg?: MoltbotConfig): ExecEventsConfigResolved {
const execCfg = cfg?.hooks?.exec; const hooks = cfg?.hooks as { exec?: ExecEventsConfigInput } | undefined;
const execCfg = hooks?.exec;
const commandWhitelist = normalizeCommandWhitelist(execCfg?.commandWhitelist); const commandWhitelist = normalizeCommandWhitelist(execCfg?.commandWhitelist);
const commandWhitelistSet = new Set(commandWhitelist); const commandWhitelistSet = new Set(commandWhitelist);
const outputThrottleMs = resolvePositiveInt(execCfg?.outputThrottleMs, DEFAULT_OUTPUT_THROTTLE_MS); const outputThrottleMs = resolvePositiveInt(execCfg?.outputThrottleMs, DEFAULT_OUTPUT_THROTTLE_MS);
@ -183,6 +206,14 @@ export function resolveExecEventsConfig(cfg?: MoltbotConfig): ExecEventsConfigRe
} satisfies ExecEventsConfigResolved; } satisfies ExecEventsConfigResolved;
} }
export function toExecEventRuntimeConfig(cfg: ExecEventsConfigResolved): ExecEventRuntimeConfig {
return {
outputThrottleMs: cfg.outputThrottleMs,
outputMaxChunkBytes: cfg.outputMaxChunkBytes,
outputBufferMaxBytes: cfg.outputBufferMaxBytes,
};
}
function includesWhitelistToken(command: string, tokens: string[]): boolean { function includesWhitelistToken(command: string, tokens: string[]): boolean {
if (!command || tokens.length === 0) return false; if (!command || tokens.length === 0) return false;
const lower = command.toLowerCase(); const lower = command.toLowerCase();
@ -381,19 +412,34 @@ function sliceByMaxBytes(text: string, maxBytes: number): string {
return text.slice(0, low); return text.slice(0, low);
} }
function trimSuffixByMaxBytes(text: string, maxBytes: number): string {
if (!text) return "";
if (maxBytes <= 0) return text.slice(-1);
if (byteLength(text) <= maxBytes) return text;
let low = 0;
let high = text.length;
while (low < high) {
const mid = Math.floor((low + high) / 2);
const suffix = text.slice(mid);
const bytes = byteLength(suffix);
if (bytes > maxBytes) {
low = mid + 1;
} else {
high = mid;
}
}
const trimmed = text.slice(low);
return trimmed || text.slice(-1);
}
function capBuffer(state: OutputStreamState, maxBufferBytes: number): void { function capBuffer(state: OutputStreamState, maxBufferBytes: number): void {
const totalBytes = byteLength(state.buffer); const totalBytes = byteLength(state.buffer);
if (totalBytes <= maxBufferBytes) return; if (totalBytes <= maxBufferBytes) return;
state.truncated = true; state.truncated = true;
// Drop oldest data until we're within the cap. state.buffer = trimSuffixByMaxBytes(state.buffer, maxBufferBytes);
let remaining = state.buffer;
while (remaining && byteLength(remaining) > maxBufferBytes) {
const overflowBytes = byteLength(remaining) - maxBufferBytes;
const dropChars = Math.min(remaining.length, Math.max(1, Math.floor(overflowBytes / 2)));
remaining = remaining.slice(dropChars);
}
state.buffer = remaining;
} }
export function createThrottledExecOutputBuffer(options: OutputBufferOptions): ExecOutputBuffer { export function createThrottledExecOutputBuffer(options: OutputBufferOptions): ExecOutputBuffer {
@ -405,6 +451,7 @@ export function createThrottledExecOutputBuffer(options: OutputBufferOptions): E
const stderr: OutputStreamState = { buffer: "", truncated: false }; const stderr: OutputStreamState = { buffer: "", truncated: false };
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null;
let disposed = false; let disposed = false;
let flushingAll = false;
const getState = (stream: OutputStream) => (stream === "stdout" ? stdout : stderr); const getState = (stream: OutputStream) => (stream === "stdout" ? stdout : stderr);
@ -440,25 +487,24 @@ export function createThrottledExecOutputBuffer(options: OutputBufferOptions): E
if (chunks.length > 0) { if (chunks.length > 0) {
options.onFlush(chunks); options.onFlush(chunks);
} }
if (!disposed && throttleMs > 0 && (stdout.buffer || stderr.buffer)) { if (!disposed && !flushingAll && throttleMs > 0 && (stdout.buffer || stderr.buffer)) {
schedule(); schedule();
} }
}; };
const flushAll = () => { const flushAll = () => {
if (disposed) return; if (disposed) return;
if (timer) { flushingAll = true;
clearTimeout(timer); try {
timer = null; if (timer) {
} clearTimeout(timer);
while (stdout.buffer || stderr.buffer) { timer = null;
flush();
if (throttleMs > 0 && (stdout.buffer || stderr.buffer)) {
// Prevent tight loops in extremely large output bursts by yielding back to the event loop.
// The next scheduled flush will continue draining.
schedule();
return;
} }
while (!disposed && (stdout.buffer || stderr.buffer)) {
flush();
}
} finally {
flushingAll = false;
} }
}; };