Files
openclaw/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts
T
Peter Steinberger 4273ca9dbd refactor(sessions): remove file-era transcript runtime (#113233)
* refactor(sessions): keep helper transcripts in memory

* refactor(sessions): remove file-era transcript storage

* test(sessions): use SQLite identity in attempt persistence

* test(codex): isolate legacy transcript fixtures

* fix(sessions): preserve SQLite transcript identity

* fix(sessions): harden transcript lifecycle invariants

* fix(sessions): validate transcript identities

* fix(sessions): close identity compatibility gaps

* fix(sessions): preserve leaf and plugin identities

* fix(sessions): retain dispatch transcript targets

* fix(sessions): preserve active transcript context

* fix(sessions): isolate artifact accounting

* fix(sessions): bound SQLite usage accounting

* fix(sessions): retain bounded latest usage

* fix(sessions): align rebased transcript targets

* test(sessions): align accessor scope fixture

* fix(telegram): derive SQLite transcript identity

* refactor(sessions): remove file-era compaction residue

* chore(sessions): lower max-lines baseline

* fix(sessions): preserve structured transcript identity

* test(sessions): align doctor identity assertions

* fix(sessions): isolate default SDK database

* refactor(sessions): remove dead file-era exports

* fix(sessions): reconcile SQLite transcript identity

* fix(sessions): pass checkpoint identity explicitly

* test(sessions): make entry field probe explicit

* test(sessions): satisfy transcript cleanup lint

* test(sessions): align diagnostics identity proof

* fix(sessions): finish transcript runtime teardown

* fix(sessions): preserve transcript identity invariants

* fix(sessions): harden transcript compatibility edges

* fix(sessions): preserve checkpoint transcript anchors

* fix(sessions): preserve SQLite lifecycle invariants

* fix(sessions): retarget compaction successors

* test(sessions): preserve transcript fixture semantics

* feat(plugin-sdk): add command transcript targets

* fix(sessions): serialize transcript rewrites

* fix(sessions): validate legacy successor identity

* fix(sessions): normalize compaction ownership

* fix(sessions): validate successor identity before adoption

* fix(sessions): preserve plugin transcript ownership

* fix(sessions): carry transcript identity through commands

* fix(sessions): import legacy checkpoint artifacts into SQLite

* fix(sessions): preserve successor transcript ownership

* fix(sessions): align transcript consumers with target identity

* fix(sessions): scope transcript token estimates

* fix(sessions): retain agent identity across lifecycle hooks

* fix(sessions): resolve scoped SQLite targets

* fix(sessions): isolate lifecycle transcript targets

* fix(sessions): validate compaction agent ownership

* fix(sessions): preserve reset and cleanup lifecycle

* fix(sessions): serialize prompt cleanup lifecycle

* fix(sessions): remove stale lock import

* fix(sessions): preserve reset target context

* fix(sessions): fence prompt reload takeover

* fix(sessions): unblock abort and default lifecycle reads

* fix(sessions): validate legacy successor scope

* fix(sessions): reject metadata-only runtime rows

* fix(sessions): propagate custom transcript stores

* fix(sessions): preserve adopted retry targets

* fix(sessions): allow unkeyed usage reads

* fix(sessions): harden runtime target boundaries

* fix(sessions): serialize retry transcript writes

* fix(sessions): bound prompt reload disposal

* fix(sessions): complete retry marker identity

* fix(sessions): keep legacy marker identity minimal

* test(sessions): tighten teardown fixture types

* fix(sessions): preserve compatibility target identity

* test(sessions): persist post-checkpoint boundary turn

* test(sessions): align runtime store mock contracts

* style(sessions): simplify persisted identity guard

* fix(sessions): prefer complete typed targets

* fix(sessions): recover legacy marker targets

* test(sessions): align marker lookup fixture scope

* fix(sessions): validate partial transcript targets

* fix(sessions): reconcile partial transcript identities

* fix(sessions): canonicalize compatibility identities

* test(sessions): cover compatibility aliases

* fix(sessions): adopt legacy successor identity

* fix(sessions): preserve usage read identity

* fix(sessions): preserve partial marker compatibility

* fix(sessions): validate legacy successor mappings

* fix(sessions): reconcile marker store mappings

* fix(sessions): preserve legacy fallback identity

* fix(sessions): harden marker alias resolution

* fix(sessions): prefer verified successor aliases

* fix(sessions): resolve preferred marker aliases

* fix(sessions): serialize cleanup admission

* fix(sessions): align marker lookup scopes

* fix(codex): type marker alias summaries

* style(sessions): satisfy changed lint

* test(sessions): align structured target assertions

* fix(sessions): reconcile latest identity contracts

* fix(sessions): validate transcript identity boundaries

* docs(sessions): explain stable registry keys

* fix(sessions): harden compatibility target round trips

* fix(sessions): port usage identity to split modules

* test(sessions): align subagent transcript identity

* fix(sessions): finish transcript identity migration

* fix(agents): route subagent completion capture through transcript targets

* fix(agents): settle SQLite prompt handoff during cleanup

* chore: shrink max-lines baseline after teardown

* fix(sessions): port teardown across split runtime owners

* fix(sessions): carry transcript targets through split owners

* test(agents): use SQLite compaction target in abort coverage

* chore: retain unrelated max-lines suppressions

* chore: shrink max-lines baseline after main splits

* style(agents): const compaction checkpoint locals

* fix(sessions): harden SQLite teardown boundaries

* test(sessions): use typed metadata in predicate isolation fixture

* test(agents): cover malformed settlement rejections lint-safely

* fix(sessions): close remaining SQLite identity races

* fix(agents): fail closed on incomplete successor targets

* fix(sessions): preserve transcript identity fallbacks

* fix(agents): preserve session-key abort admission

* fix(trajectory): validate incomplete export targets

* test(sessions): drop retired pricing cache imports

* fix(sessions): validate partial transcript identities

* fix(sessions): close transcript identity edge cases

* fix(plugins): reserve retired transcript locator slot

* fix(sessions): scope transcript locks by target

* style(sessions): simplify SDK initialization error

* fix(sessions): preserve initialized transcript state

* fix(codex): verify mirrored history session keys

* fix(sessions): reject stale transcript ownership

* fix(sessions): anchor asynchronous transcript ownership

* fix(sessions): measure active transcript state

* fix(sessions): preserve scoped transcript compaction

* fix(sessions): harden transcript identity and lifecycle

* fix(sessions): resolve scoped command transcript stores

* fix(sessions): make transcript appends failure-atomic

* fix(sessions): enforce scoped transcript ownership

* fix(sessions): reject cross-owner transcript handoffs

* fix(sessions): fence cleanup transcript ownership

* fix(sessions): retire stale write ownership contexts

* fix(sessions): preserve pending session migration state

* fix(sessions): validate migrated transcript ownership

* fix(sessions): validate usage transcript targets

* fix(sessions): clear predecessor transcript metadata

* fix(sessions): align durable session event targets

* fix(sessions): fence late prompt handoffs

* fix(sessions): fence lifecycle transcript fallbacks

* fix(sessions): bound zero-length memory capture

* fix(sessions): preserve transcript teardown ownership

* fix(sessions): reject duplicate cleanup ownership

* fix(sessions): serialize runtime writes with sqlite leases

* fix(sessions): close sqlite teardown concurrency gaps

* fix(sessions): preserve nested lifecycle failures

* fix(sessions): canonicalize sqlite transcript ownership

* fix(sessions): settle disposed prompt handoffs

* fix(sessions): resolve canonical attempt lock targets

* test(sessions): align canonical target fixtures

* test(sessions): retire redundant jsonl parser coverage

* refactor(sessions): split active transcript cursors

* test(memory): retire legacy marker fixture

* fix(sessions): preserve canonical transcript access after rebase

* fix(sessions): fence prompt lease and return transcript targets

* fix(sessions): colocate transcript leases with target store

* fix(sessions): canonicalize transcript lease and worker targets

* fix(sessions): preserve plugin and fork identity markers

* fix(sessions): complete sqlite transcript target migration

* fix(sessions): integrate canonical followup identity

* fix(sessions): preserve bounded transcript topology

* fix(sessions): validate transcript identity boundaries

* fix(context): separate caller and successor targets

* test(sessions): split persistence compatibility coverage

* test(sessions): preserve fixture topology efficiently

* chore(sdk): refresh plugin api baseline

* test(agents): align compaction lock target mocks

* test(sessions): seed malformed transcript fixtures directly

* fix(agents): canonicalize transcript compatibility inputs

* fix(agents): type optional tool result ids

* test(ci): stabilize loaded process timing

* test(tui): wait for collect queue admission
2026-07-27 22:33:24 -04:00

383 lines
14 KiB
TypeScript

/**
* Settles async tools and compaction, then snapshots the completed stream.
*/
import { formatErrorMessage } from "../../../infra/errors.js";
import type { AssistantMessage } from "../../../llm/types.js";
import type { AgentRunAttemptFailureSource } from "../../agent-run-terminal-outcome.js";
import type { subscribeEmbeddedAgentSession } from "../../embedded-agent-subscribe.js";
import type { AgentMessage } from "../../runtime/index.js";
import type { AgentSession, SessionManager } from "../../sessions/index.js";
import { projectToolSearchTargetTranscriptMessages } from "../../tool-search.js";
import { hasNonzeroUsage, normalizeUsage, type NormalizedUsage } from "../../usage.js";
import { isRunnerAbortError } from "../abort.js";
import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "../cache-ttl.js";
import { log } from "../logger.js";
import {
completePromptCacheObservation,
type PromptCacheBreak,
type PromptCacheChange,
} from "../prompt-cache-observability.js";
import {
flushSessionManagerTranscript,
normalizeCompactionRecoveryTranscriptTail,
} from "./attempt-transcript-helpers.js";
import {
shouldWaitForCompletionRequiredAsyncTasks,
waitForCompletionRequiredAsyncTasks,
type CompletionRequiredAsyncTaskWaitResult,
} from "./attempt.async-tasks.js";
import {
buildContextEnginePromptCacheInfo,
findCurrentAttemptAssistantMessage,
findLatestUncompactedAttemptUsageSnapshot,
resolvePromptCacheTouchTimestamp,
} from "./attempt.context-engine-helpers.js";
import type { createEmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import { appendAttemptCacheTtlIfNeeded } from "./attempt.thread-helpers.js";
import {
hasActiveCompactionRetryWork,
waitForCompactionRetryWithAggregateTimeout,
} from "./compaction-retry-aggregate-timeout.js";
import { selectCompactionTimeoutSnapshot } from "./compaction-timeout.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
type EmbeddedAttemptSubscription = ReturnType<typeof subscribeEmbeddedAgentSession>;
type AttemptSessionLockController = Awaited<
ReturnType<typeof createEmbeddedAttemptSessionLockController>
>;
type PromptCacheRetention = Parameters<typeof buildContextEnginePromptCacheInfo>[0]["retention"];
type ToolSearchTargetTranscriptProjections = Parameters<
typeof projectToolSearchTargetTranscriptMessages
>[1];
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
type StreamSettleResult = {
promptError: unknown;
promptErrorSource: AgentRunAttemptFailureSource | null;
timedOutDuringCompaction: boolean;
compactionOccurredThisAttempt: boolean;
messagesSnapshot: AgentMessage[];
sessionIdUsed: string;
lastAssistant: EmbeddedRunAttemptResult["lastAssistant"];
currentAttemptAssistant: EmbeddedRunAttemptResult["currentAttemptAssistant"];
currentAttemptCompletedAssistant: EmbeddedRunAttemptResult["currentAttemptCompletedAssistant"];
attemptUsage: EmbeddedRunAttemptResult["attemptUsage"];
cacheBreak: PromptCacheBreak | null;
lastCallUsage: NormalizedUsage | undefined;
promptCache: EmbeddedRunAttemptResult["promptCache"];
};
export async function settleEmbeddedAttemptStream(input: {
attempt: EmbeddedRunAttemptParams;
activeSession: AgentSession;
sessionManager: SessionManager;
sessionLockController: AttemptSessionLockController;
withOwnedSessionWriteLock: WithOwnedSessionWriteLock;
subscription: EmbeddedAttemptSubscription;
state: {
promptError: unknown;
promptErrorSource: AgentRunAttemptFailureSource | null;
yieldAborted: boolean;
sessionIdUsed: string;
};
readLifecycleState: () => {
aborted: boolean;
timedOut: boolean;
timedOutDuringCompaction: boolean;
};
markTimedOutDuringCompaction: () => void;
runAbortDeadlineAtMs: number;
runAbortSignal: AbortSignal;
isProbeSession: boolean;
onBlockReplyFlush?: (payload: {
reason: "pre_compaction";
attemptAccepted: boolean;
}) => Promise<void> | void;
abortable: <T>(promise: Promise<T>) => Promise<T>;
prePromptMessageCount: number;
toolSearchTargetTranscriptProjections: ToolSearchTargetTranscriptProjections;
cache: {
observabilityEnabled: boolean;
changesForTurn: PromptCacheChange[] | null;
retention: PromptCacheRetention;
};
shouldFlushForContextEngine: boolean;
}): Promise<StreamSettleResult> {
const { attempt, activeSession, sessionManager, subscription, state } = input;
let { promptError, promptErrorSource, sessionIdUsed } = state;
if (
shouldWaitForCompletionRequiredAsyncTasks({
sessionKey: attempt.sessionKey,
toolMetas: subscription.toolMetas,
yieldDetected: state.yieldAborted,
})
) {
const getAsyncStartedToolMetas = () =>
subscription.toolMetas
.filter(
(
entry,
): entry is {
toolName: string;
asyncStarted?: boolean;
asyncTaskRunId?: string;
asyncTaskId?: string;
} => typeof entry.toolName === "string" && entry.toolName.trim().length > 0,
)
.map((entry) => ({
toolName: entry.toolName,
asyncStarted: entry.asyncStarted,
asyncTaskRunId: entry.asyncTaskRunId,
asyncTaskId: entry.asyncTaskId,
}));
const completionRequiredAsyncDeadlineAtMs = Math.max(
Date.now(),
input.runAbortDeadlineAtMs - 500,
);
let asyncTaskWait: CompletionRequiredAsyncTaskWaitResult;
try {
asyncTaskWait = await waitForCompletionRequiredAsyncTasks({
getToolMetas: getAsyncStartedToolMetas,
sessionKey: attempt.sessionKey,
deadlineAtMs: completionRequiredAsyncDeadlineAtMs,
abortSignal: input.runAbortSignal,
});
} catch (err) {
if (!input.readLifecycleState().timedOut || !isRunnerAbortError(err)) {
throw err;
}
asyncTaskWait = await waitForCompletionRequiredAsyncTasks({
getToolMetas: getAsyncStartedToolMetas,
sessionKey: attempt.sessionKey,
deadlineAtMs: Date.now(),
});
}
if (asyncTaskWait.timedOutRunIds.length > 0) {
promptError = new Error(
`Timed out waiting for async task completion: ${asyncTaskWait.timedOutRunIds.join(", ")}`,
);
promptErrorSource = "prompt";
state.promptError = promptError;
state.promptErrorSource = promptErrorSource;
}
}
// Snapshot only outside compaction. Compaction rewrites history in place and
// cannot be allowed to leave the timeout result with a half-written view.
const wasCompactingBefore = activeSession.isCompacting;
const snapshot = activeSession.messages.slice();
const wasCompactingAfter = activeSession.isCompacting;
const preCompactionSnapshot = wasCompactingBefore || wasCompactingAfter ? null : snapshot;
const preCompactionSessionId = activeSession.sessionId;
const aggregateTimeoutMs = 60_000;
try {
if (input.onBlockReplyFlush) {
const currentAssistant = findCurrentAttemptAssistantMessage({
messagesSnapshot: snapshot,
prePromptMessageCount: input.prePromptMessageCount,
});
const attemptAccepted =
!promptError &&
!input.readLifecycleState().aborted &&
!input.readLifecycleState().timedOut &&
!state.yieldAborted &&
currentAssistant?.stopReason === "stop";
await input.onBlockReplyFlush({ reason: "pre_compaction", attemptAccepted });
}
const compactionRetryWait = state.yieldAborted
? { timedOut: false }
: await waitForCompactionRetryWithAggregateTimeout({
waitForCompactionRetry: subscription.waitForCompactionRetry,
abortable: input.abortable,
aggregateTimeoutMs,
isCompactionRetryStillActive: () =>
hasActiveCompactionRetryWork({
isCompactionInFlight: subscription.isCompactionInFlight(),
isSessionStreaming: activeSession.isStreaming,
}),
});
if (compactionRetryWait.timedOut) {
input.markTimedOutDuringCompaction();
if (!input.isProbeSession) {
log.warn(
`compaction retry aggregate timeout (${aggregateTimeoutMs}ms): ` +
`proceeding with pre-compaction state runId=${attempt.runId} sessionId=${attempt.sessionId}`,
);
}
}
} catch (err) {
if (!isRunnerAbortError(err)) {
throw err;
}
if (!promptError) {
promptError = err;
promptErrorSource = "compaction";
state.promptError = promptError;
state.promptErrorSource = promptErrorSource;
}
if (!input.isProbeSession) {
log.debug(`compaction wait aborted: runId=${attempt.runId} sessionId=${attempt.sessionId}`);
}
}
let compactionOccurredThisAttempt = false;
let messagesSnapshot: AgentMessage[] = [];
let lastAssistant: AssistantMessage | undefined;
let currentAttemptAssistant: AssistantMessage | undefined;
let currentAttemptCompletedAssistant: AssistantMessage | undefined;
let attemptUsage: EmbeddedRunAttemptResult["attemptUsage"];
let cacheBreak: PromptCacheBreak | null = null;
let lastCallUsage: NormalizedUsage | undefined;
let promptCache: EmbeddedRunAttemptResult["promptCache"];
await input.withOwnedSessionWriteLock(async () => {
const { timedOutDuringCompaction } = input.readLifecycleState();
compactionOccurredThisAttempt = subscription.getCompactionCount() > 0;
appendAttemptCacheTtlIfNeeded({
sessionManager,
timedOutDuringCompaction,
compactionOccurredThisAttempt,
config: attempt.config,
provider: attempt.provider,
modelId: attempt.modelId,
modelApi: attempt.model.api,
isCacheTtlEligibleProvider,
});
if (timedOutDuringCompaction) {
const removedEntries = normalizeCompactionRecoveryTranscriptTail({
activeSession,
sessionManager,
});
if (removedEntries > 0 && !input.isProbeSession) {
log.warn(
`normalized compaction timeout transcript tail: removedEntries=${removedEntries} ` +
`runId=${attempt.runId} sessionId=${attempt.sessionId}`,
);
}
}
const snapshotSelection = selectCompactionTimeoutSnapshot({
timedOutDuringCompaction,
preCompactionSnapshot,
preCompactionSessionId,
currentSnapshot: activeSession.messages.slice(),
currentSessionId: activeSession.sessionId,
});
if (timedOutDuringCompaction && !input.isProbeSession) {
log.warn(
`using ${snapshotSelection.source} snapshot: timed out during compaction ` +
`runId=${attempt.runId} sessionId=${attempt.sessionId}`,
);
}
messagesSnapshot = projectToolSearchTargetTranscriptMessages(
snapshotSelection.messagesSnapshot,
input.toolSearchTargetTranscriptProjections,
);
sessionIdUsed = snapshotSelection.sessionIdUsed;
lastAssistant = messagesSnapshot
.slice()
.toReversed()
.find((message): message is AssistantMessage => message.role === "assistant");
currentAttemptAssistant = findCurrentAttemptAssistantMessage({
messagesSnapshot,
prePromptMessageCount: input.prePromptMessageCount,
});
currentAttemptCompletedAssistant = subscription.getCurrentAttemptAssistant();
attemptUsage = subscription.getUsageTotals();
cacheBreak = input.cache.observabilityEnabled
? completePromptCacheObservation({
sessionId: attempt.sessionId,
promptCacheKey: attempt.promptCacheKey,
sessionKey: attempt.sessionKey,
usage: attemptUsage,
})
: null;
const transcriptUsageSnapshot = findLatestUncompactedAttemptUsageSnapshot({
messagesSnapshot,
prePromptMessageCount: input.prePromptMessageCount,
compactionOccurred: compactionOccurredThisAttempt,
});
const completedAssistantUsage = normalizeUsage(currentAttemptCompletedAssistant?.usage);
lastCallUsage =
subscription.getLastAssistantUsage() ??
(hasNonzeroUsage(completedAssistantUsage)
? completedAssistantUsage
: transcriptUsageSnapshot?.usage);
// Keep cache timing bound to the assistant that supplied the exact usage.
// A terminal zero-usage abort must not advance TTL for the previous call.
const usageAssistant = hasNonzeroUsage(completedAssistantUsage)
? currentAttemptCompletedAssistant
: transcriptUsageSnapshot?.assistant;
const promptCacheObservation =
input.cache.observabilityEnabled &&
(cacheBreak || input.cache.changesForTurn || typeof attemptUsage?.cacheRead === "number")
? {
broke: Boolean(cacheBreak),
...(typeof cacheBreak?.previousCacheRead === "number"
? { previousCacheRead: cacheBreak.previousCacheRead }
: {}),
...(typeof cacheBreak?.cacheRead === "number"
? { cacheRead: cacheBreak.cacheRead }
: typeof attemptUsage?.cacheRead === "number"
? { cacheRead: attemptUsage.cacheRead }
: {}),
changes: cacheBreak?.changes ?? input.cache.changesForTurn,
}
: undefined;
const fallbackLastCacheTouchAt = readLastCacheTtlTimestamp(sessionManager, {
provider: attempt.provider,
modelId: attempt.modelId,
});
promptCache = buildContextEnginePromptCacheInfo({
retention: input.cache.retention,
lastCallUsage,
observation: promptCacheObservation,
lastCacheTouchAt: resolvePromptCacheTouchTimestamp({
lastCallUsage,
assistantTimestamp: usageAssistant?.timestamp,
fallbackLastCacheTouchAt,
}),
});
if (promptError && promptErrorSource === "prompt" && !compactionOccurredThisAttempt) {
try {
sessionManager.appendCustomEntry("openclaw:prompt-error", {
timestamp: Date.now(),
runId: attempt.runId,
sessionId: attempt.sessionId,
provider: attempt.provider,
model: attempt.modelId,
api: attempt.model.api,
error: formatErrorMessage(promptError),
});
} catch (entryErr) {
log.warn(`failed to persist prompt error entry: ${String(entryErr)}`);
}
}
if (input.shouldFlushForContextEngine) {
flushSessionManagerTranscript(sessionManager);
}
});
return {
promptError,
promptErrorSource,
timedOutDuringCompaction: input.readLifecycleState().timedOutDuringCompaction,
compactionOccurredThisAttempt,
messagesSnapshot,
sessionIdUsed,
lastAssistant,
currentAttemptAssistant,
currentAttemptCompletedAssistant,
attemptUsage,
cacheBreak,
lastCallUsage,
promptCache,
};
}