Files
adolf/extensions/copilot/src/replay-shim.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

252 lines
8.0 KiB
TypeScript
Executable File

// Replay-shim for the GitHub Copilot agent runtime.
//
// Owns three concerns:
// 1. Pre-call: should this attempt resume an existing SDK session or
// start a new one? Honours `initialReplayState.sdkSessionId` and
// `initialReplayState.replayInvalid`.
// 2. Post-call: if `resumeSession` fails, was the failure recoverable
// (session-gone) so we should downgrade to `createSession`, or
// unrecoverable so the error should surface as a prompt error?
// 3. Result-time: compute the `replayMetadata` to attach to the attempt
// result, propagating prior state with worst-case-wins semantics so
// the orchestrator never replays an attempt that may have committed
// partial side effects.
//
// Host back-pointers (NOT imported here to keep the package boundary
// clean):
// - `src/agents/pi-embedded-runner/replay-state.ts` — canonical
// `EmbeddedRunReplayState` / `EmbeddedRunReplayMetadata` shapes
// and `replayMetadataFromState`.
// - `src/agents/pi-embedded-runner/run/types.ts` —
// `AgentHarnessAttemptResult.replayMetadata` field requirement.
export type ReplayDecision =
| {
readonly action: "resume";
readonly sdkSessionId: string;
readonly downgradedFromResume: false;
}
| {
readonly action: "create";
readonly downgradedFromResume: boolean;
readonly downgradeReason: "no-replay-state" | "no-sdk-session-id" | "replay-invalid";
};
export interface ReplayShimInput {
readonly sdkSessionId?: string;
readonly replayInvalid?: boolean;
}
function normalizeSdkSessionId(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Pure pre-call decision: should attempt.ts call resumeSession or
* createSession?
*
* Rules:
* - No input → create (no-replay-state)
* - No (trimmed) sdkSessionId → create (no-sdk-session-id)
* - sdkSessionId + replayInvalid=true → create (replay-invalid),
* downgradedFromResume=true
* - sdkSessionId + replayInvalid=false → resume
*/
export function decideReplayAction(input?: ReplayShimInput): ReplayDecision {
if (!input) {
return {
action: "create",
downgradedFromResume: false,
downgradeReason: "no-replay-state",
};
}
const sdkSessionId = normalizeSdkSessionId(input.sdkSessionId);
if (!sdkSessionId) {
return {
action: "create",
downgradedFromResume: false,
downgradeReason: "no-sdk-session-id",
};
}
if (input.replayInvalid === true) {
return {
action: "create",
downgradedFromResume: true,
downgradeReason: "replay-invalid",
};
}
return {
action: "resume",
sdkSessionId,
downgradedFromResume: false,
};
}
export type ResumeFailureKind = "missing" | "unknown";
export interface ResumeFailureClassification {
readonly recoverable: boolean;
readonly kind: ResumeFailureKind;
}
const MISSING_SESSION_CODES = new Set([
"SESSION_NOT_FOUND",
"session_not_found",
"NotFound",
"ENOENT",
]);
const MISSING_SESSION_MESSAGE_PATTERNS: readonly RegExp[] = [
/\bsession not found\b/i,
/\bsession .* not found\b/i,
/\bunknown session id\b/i,
/\bsession id .* (does not exist|not found)\b/i,
/\bsession .* does not exist\b/i,
/\bno such session\b/i,
];
function readErrorField(error: unknown, key: string): unknown {
if (!error || typeof error !== "object") {
return undefined;
}
return (error as Record<string, unknown>)[key];
}
/**
* Post-call: classify a resumeSession() failure so attempt.ts can
* decide whether to downgrade silently to createSession.
*
* Conservative: only treats clearly session-gone signals as recoverable.
* Structured signals (status === 404, recognised code strings) are
* checked first; message matching is a fallback because SDK error
* messages are not part of the typed contract.
*
* Everything else (transport errors, auth failures, generic Error) is
* unrecoverable and should surface to the outer attempt.ts try/catch
* which converts it to a prompt error.
*/
export function classifyResumeFailure(error: unknown): ResumeFailureClassification {
if (error === undefined || error === null) {
return { recoverable: false, kind: "unknown" };
}
const status = readErrorField(error, "status");
if (status === 404) {
return { recoverable: true, kind: "missing" };
}
const statusCode = readErrorField(error, "statusCode");
if (statusCode === 404) {
return { recoverable: true, kind: "missing" };
}
const code = readErrorField(error, "code");
if (typeof code === "string" && MISSING_SESSION_CODES.has(code)) {
return { recoverable: true, kind: "missing" };
}
const message =
error instanceof Error
? error.message
: typeof error === "object"
? typeof (error as { message?: unknown }).message === "string"
? (error as { message: string }).message
: undefined
: undefined;
if (typeof message === "string") {
for (const pattern of MISSING_SESSION_MESSAGE_PATTERNS) {
if (pattern.test(message)) {
return { recoverable: true, kind: "missing" };
}
}
}
return { recoverable: false, kind: "unknown" };
}
export interface ReplayMetadataComputeInput {
readonly priorReplayInvalid?: boolean;
readonly priorHadPotentialSideEffects?: boolean;
readonly thisAttemptTimedOut?: boolean;
readonly thisAttemptHadPotentialSideEffects?: boolean;
readonly thisAttemptDowngradedFromResume?: boolean;
readonly thisAttemptResumeFailureRecovered?: boolean;
}
export interface ComputedReplayMetadata {
readonly hadPotentialSideEffects: boolean;
readonly replaySafe: boolean;
}
/**
* Compute the `EmbeddedRunReplayMetadata` to attach to the attempt
* result. Worst-case-wins:
*
* hadPotentialSideEffects = priorHadPotentialSideEffects OR timedOut
* OR thisAttemptHadPotentialSideEffects
* (timeout means we cannot prove the prompt was not partially
* committed server-side; treat as side-effecting so the
* orchestrator will not blindly re-issue the same prompt).
*
* replaySafe = NOT (
* priorReplayInvalid
* OR thisAttemptDowngradedFromResume
* OR thisAttemptResumeFailureRecovered
* OR hadPotentialSideEffects
* )
*
* Matches the parity rule in
* `src/agents/pi-embedded-runner/replay-state.ts#replayMetadataFromState`.
*/
export function computeReplayMetadata(input: ReplayMetadataComputeInput): ComputedReplayMetadata {
const priorReplayInvalid = input.priorReplayInvalid === true;
const priorHadPotentialSideEffects = input.priorHadPotentialSideEffects === true;
const timedOut = input.thisAttemptTimedOut === true;
const thisAttemptHadPotentialSideEffects = input.thisAttemptHadPotentialSideEffects === true;
const downgraded = input.thisAttemptDowngradedFromResume === true;
const recovered = input.thisAttemptResumeFailureRecovered === true;
const hadPotentialSideEffects =
priorHadPotentialSideEffects || timedOut || thisAttemptHadPotentialSideEffects;
const replaySafe = !(priorReplayInvalid || downgraded || recovered || hadPotentialSideEffects);
return { hadPotentialSideEffects, replaySafe };
}
const COPILOT_REPLAY_SAFE_READ_ONLY_TOOL_NAMES = new Set([
"get",
"file_read",
"glob",
"grep",
"inspect",
"list",
"ls",
"memory_get",
"probe",
"query",
"read",
"search",
"sessions_history",
"sessions_list",
"status",
"tool_search",
"update_plan",
"view",
"web_fetch",
"web_search",
]);
export function copilotToolMetasHavePotentialSideEffects(
toolMetas?: readonly { asyncStarted?: boolean; toolName: string }[],
): boolean {
return (toolMetas ?? []).some(
(entry) => entry.asyncStarted === true || !isReplaySafeReadOnlyToolName(entry.toolName),
);
}
function isReplaySafeReadOnlyToolName(toolName: string): boolean {
const normalized = toolName.trim().toLowerCase();
return COPILOT_REPLAY_SAFE_READ_ONLY_TOOL_NAMES.has(normalized);
}