/**
* Action Fusion: run an optional `then_run` command after a successful mutation.
*
* Two per-file queues nest here, with different jobs:
* - Pi's `withFileMutationQueue` (entered by the mutation runner) serializes
* the read-modify-write itself and is shared with every tool that uses it.
* It is released as soon as the file is published.
* - The executor's own queue wraps mutation, freshness check, and command, so
* a later fused call on the same file waits until this command finishes and
* cannot change the revision the command started from. Its key resolves the nearest
* existing ancestor and folds case on Windows, so it is never looser than
* Pi's key. It is always taken first and Pi's queue never waits on it, so
* the nesting cannot deadlock.
*
* Writers outside these queues are not blocked; the commit layer's freshness
* observation against `publishedRevision` detects them, and the command is
* skipped or the result reports the changed freshness. A failed command never
* rolls back the published file. Fusion states only the command: its status,
* output, and termination request; the file state belongs to the mutation result.
*
* @module pi-hashline-edit/pi
*/
import { realpath } from "node:fs/promises";
import { basename, dirname, resolve } from "node:path";
import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent";
import { Type, type Static, type TObject, type TProperties } from "typebox";
import { commitFreshness, observeFreshness, type FreshnessObservation } from "./file-commit.ts";
import {
CommandNotRun,
commandText,
type CommandFacts,
type MutationOutcome,
type MutationResultDetails,
} from "./mutation-result.ts";
import { errnoCode, errorMessage } from "../core/errors.ts";
import { throwIfCancelled } from "./error-text.ts";
import type {
ActionFusionProgress,
CommandResult,
CommandStatus,
Freshness,
MutationToolName,
} from "./report-schema.ts";
const MILLISECONDS_PER_SECOND = 1_000;
// Pi's built-in Bash rejects timeouts above the setTimeout millisecond limit.
const MAX_BASH_TIMEOUT_SECONDS = 2_147_483_647 / MILLISECONDS_PER_SECOND;
// Pi's Bash runs on this host, and Pi's prompt does not name the OS; only Windows needs the path note.
const COMMAND_DESCRIPTION =
process.platform === "win32"
? "Bash command to run. This host is Windows: avoid Unix-only paths such as /tmp."
: "Bash command to run.";
export const ACTION_FUSION_GUIDELINES = [
"When a check covers several changes, run it once after all of those changes.",
];
type ProgressReporter = (progress: ActionFusionProgress) => void;
function createThenRunSchema(tool: MutationToolName) {
return Type.Optional(
Type.Object(
{
command: Type.String({ minLength: 1, pattern: "\\S", description: COMMAND_DESCRIPTION }),
timeout: Type.Optional(
Type.Number({
exclusiveMinimum: 0,
maximum: MAX_BASH_TIMEOUT_SECONDS,
description: "Timeout in seconds; none when omitted.",
}),
),
},
{
description: `Command to run once after ${tool} succeeds; failure does not roll back the file.`,
additionalProperties: false,
},
),
);
}
/** Add the optional then_run field to a mutation schema when Action Fusion is enabled. */
export function withThenRunSchema
(
schema: TObject
,
tool: MutationToolName,
actionFusion: boolean,
): TObject
| TObject
}> {
return actionFusion
? Type.Object(
{ ...schema.properties, then_run: createThenRunSchema(tool) },
{ additionalProperties: false },
)
: schema;
}
export type ThenRunInput = NonNullable>>;
/** Name then_run in a mutation tool's description when Action Fusion is enabled. */
export function withThenRunDescription(description: string, actionFusion: boolean): string {
return actionFusion
? `${description} Set the then_run parameter for checks that can run immediately after this change. Use a separate bash call when the command requires approval, needs information from this tool's result, must wait for reload, or checks several changes from one response.`
: description;
}
/** What a fused call leaves for the mutation runner to report; Fusion builds no result itself. */
export interface FusedMutation {
readonly outcome: MutationOutcome;
readonly observation: FreshnessObservation;
/** Absent when the call has no then_run. */
readonly command?: CommandFacts;
/** Preserve the session tool's request to stop after this tool batch. */
readonly terminate?: boolean;
}
/** Keep command state independent of its diagnostic text and of file publication. */
interface CommandOutcome {
readonly status: Exclude;
readonly result: CommandResult;
/** Preserve the session tool's request to stop after this tool batch. */
readonly terminate?: boolean;
}
type CommandRunner = (
input: ThenRunInput,
signal: AbortSignal | undefined,
ctx: ExtensionToolContext,
onUpdate?: AgentToolUpdateCallback,
) => Promise;
async function defaultCommandRunner(
input: ThenRunInput,
signal: AbortSignal | undefined,
ctx: ExtensionToolContext,
onUpdate?: AgentToolUpdateCallback,
): Promise {
// Use the session's callable Bash, including overrides, validation, permission
// hooks and tool_result hooks. Tool failures resolve with isError, not rejection.
const { result, isError } = await ctx.executeTool("bash", input, { signal, onUpdate });
const command = {
terminate: result.terminate,
result: { content: result.content, details: result.details },
};
if (!isError) return { status: "succeeded", ...command };
if (signal?.aborted) return { status: "cancelled", ...command };
const structured = result.structuredContent;
const hasExitCode =
structured !== null &&
typeof structured === "object" &&
"exit_code" in structured &&
typeof structured.exit_code === "number";
// Pi 0.99.1 supplies exit_code for ordinary exits, but serializes a thrown
// timeout as text. Only recognize its final diagnostic for the requested
// timeout; a failed command's output may itself mention or quote a timeout.
const timeoutDiagnostic = `Command timed out after ${input.timeout} seconds`;
const output = commandText(result.content);
const timedOut =
!hasExitCode &&
input.timeout !== undefined &&
(output === timeoutDiagnostic || output.endsWith(`\n${timeoutDiagnostic}`));
return { status: timedOut ? "timeout" : "failed", ...command };
}
async function canonicalQueueKey(path: string): Promise {
const folded = (key: string) => (process.platform === "win32" ? key.toLowerCase() : key);
const resolved = resolve(path);
let current = resolved;
const missing: string[] = [];
while (true) {
try {
return folded(resolve(await realpath(current), ...missing));
} catch (error) {
const code = errnoCode(error);
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
const parent = dirname(current);
if (parent === current) return folded(resolved);
missing.unshift(basename(current));
current = parent;
}
}
}
/**
* One then_run call's display state and the observers that follow it. Display callbacks are
* observers: their failures must not change publication or command execution, so the first is
* kept for the report. The tracker owns the heartbeat lifetime; Pi Bash owns timeout and
* process cleanup.
*/
function createProgressTracker({
toolCallId,
thenRun,
onProgress,
onUpdate,
}: {
toolCallId: string;
thenRun: ThenRunInput;
onProgress: ProgressReporter | undefined;
onUpdate: AgentToolUpdateCallback> | undefined;
}) {
let mutationDetails: TDetails | undefined;
let failure: unknown;
let commandResult: CommandResult | undefined;
let commandTiming: { readonly timeoutSeconds: number; readonly deadlineMs: number } | undefined;
let heartbeat: ReturnType | undefined;
const report = (command: ActionFusionProgress["command"], freshness: Freshness) => {
const progress: ActionFusionProgress = {
toolCallId,
commandText: thenRun.command,
command,
freshness,
...(commandResult ? { result: commandResult } : {}),
mutationCompleted: mutationDetails !== undefined,
...(commandTiming
? {
timing: {
timeoutSeconds: commandTiming.timeoutSeconds,
remainingSeconds: Math.max(
0,
Math.ceil((commandTiming.deadlineMs - Date.now()) / MILLISECONDS_PER_SECOND),
),
},
}
: {}),
};
for (const notify of [
() => onProgress?.(progress),
() => onUpdate?.({ content: [], details: { ...mutationDetails, actionFusion: progress } }),
]) {
try {
notify();
} catch (error) {
failure ??= error;
}
}
};
/** Follow the running command's partial results. */
const followCommand: AgentToolUpdateCallback = (partial) => {
// Pi Bash emits an initial update even for silent commands. Start its
// countdown here, after session permission hooks have allowed execution.
if (!commandTiming && thenRun.timeout !== undefined) {
commandTiming = {
timeoutSeconds: thenRun.timeout,
deadlineMs: Date.now() + thenRun.timeout * MILLISECONDS_PER_SECOND,
};
if (onProgress || onUpdate) {
heartbeat = setInterval(() => report("running", "unknown"), MILLISECONDS_PER_SECOND);
heartbeat.unref();
}
}
commandResult = { content: partial.content, details: partial.details };
report("running", "unknown");
};
return {
report,
followCommand,
failure: () => failure,
mutationCompleted(details: TDetails) {
mutationDetails = details;
},
stopHeartbeat() {
if (heartbeat) clearInterval(heartbeat);
},
commandSettled(result: CommandResult) {
commandResult = result;
},
};
}
export function createActionFusionExecutor(
commandRunner: CommandRunner = defaultCommandRunner,
onProgress?: ProgressReporter,
) {
const queueTails = new Map>();
async function withQueue(path: string, work: () => Promise): Promise {
const key = await canonicalQueueKey(path);
const previous = queueTails.get(key) ?? Promise.resolve();
let release!: () => void;
const owned = new Promise((resolve) => {
release = resolve;
});
const tail = previous.then(() => owned);
queueTails.set(key, tail);
await previous;
try {
return await work();
} finally {
release();
if (queueTails.get(key) === tail) queueTails.delete(key);
}
}
return async function execute({
toolCallId,
absolutePath,
thenRun,
mutate,
signal,
ctx,
onUpdate,
}: {
toolCallId: string;
absolutePath: string;
thenRun: ThenRunInput | undefined;
mutate: () => Promise>;
signal: AbortSignal | undefined;
ctx: ExtensionToolContext;
onUpdate?: AgentToolUpdateCallback>;
}): Promise> {
// Without a command, the mutation still waits for a fused call on the same file.
if (thenRun === undefined) {
return withQueue(absolutePath, async () => {
throwIfCancelled(signal);
const outcome = await mutate();
return { outcome, observation: commitFreshness(outcome.commit) };
});
}
const progress = createProgressTracker({
toolCallId,
thenRun,
onProgress,
onUpdate,
});
/** A mutation that did not complete keeps its own failure; Fusion adds only the command status. */
const notRun = (error: unknown, command: "skipped" | "cancelled") => {
progress.report(command, "unknown");
return new CommandNotRun(error, command, progress.failure());
};
progress.report("waiting", "unknown");
return withQueue(absolutePath, async () => {
try {
throwIfCancelled(signal);
} catch (error) {
throw notRun(error, "cancelled");
}
let outcome: MutationOutcome;
try {
outcome = await mutate();
progress.mutationCompleted(outcome.details);
} catch (error) {
throw notRun(error, "skipped");
}
const { publishedRevision } = outcome.commit;
progress.report("waiting", "unknown");
const settle = (observation: FreshnessObservation, command: CommandFacts) => {
progress.report(command.status, observation.freshness);
return {
outcome,
observation,
command: { ...command, progressError: progress.failure() },
};
};
// A completed mutation remains successful; the command is only started on a fresh target.
const before = await observeFreshness(absolutePath, publishedRevision, true);
if (signal?.aborted) return settle(before, { status: "cancelled" });
if (before.freshness !== "unchanged") return settle(before, { status: "skipped" });
progress.report("running", "unchanged");
let command: CommandOutcome;
try {
command = await commandRunner(thenRun, signal, ctx, progress.followCommand);
} catch (error) {
command = {
status: signal?.aborted ? "cancelled" : "failed",
result: { content: [{ type: "text", text: errorMessage(error) }] },
};
} finally {
progress.stopHeartbeat();
}
progress.commandSettled(command.result);
const settled = settle(await observeFreshness(absolutePath, publishedRevision), {
status: command.status,
result: command.result,
});
return command.terminate === undefined
? settled
: { ...settled, terminate: command.terminate };
}).catch((error: unknown) => {
// Failures before the mutation started (such as resolving the queue key) also skip the command.
if (error instanceof CommandNotRun) throw error;
throw notRun(error, "skipped");
});
};
}