You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by ku...@apache.org on 2026/09/01 14:35:32 UTC
(maka) 02/02: fix(runtime-host): close Read context lifecycle gaps
This is an automated email from the ASF dual-hosted git repository.
likun666661 pushed a commit to branch refactor/read-image-context-writer
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 34f2783151dbaa9d56781f8c40bf03c25d8bffff
Author: likun666661 <90...@users.noreply.github.com>
AuthorDate: Tue Sep 1 22:34:58 2026 +0800
fix(runtime-host): close Read context lifecycle gaps
---
...09-01-read-image-context-writer-review-fixes.md | 109 +++++++++++++++++++++
.../src/__tests__/execution-composition.test.ts | 53 +++++++++-
.../src/server/execution-composition.ts | 17 +++-
.../src/server/session-revision-coordinator.ts | 5 +-
.../__tests__/builtin-tools-file-worker.test.ts | 68 ++++++++++++-
.../src/__tests__/conversation-copy.test.ts | 55 +++++++++--
.../src/__tests__/filesystem-authority.test.ts | 1 +
.../tool-runtime-durable-boundary.test.ts | 48 +++++++--
packages/runtime/src/builtin-tools.ts | 37 ++++++-
packages/runtime/src/conversation-copy.ts | 58 +++++++----
packages/runtime/src/tool-runtime.ts | 18 ++++
11 files changed, 423 insertions(+), 46 deletions(-)
diff --git a/docs/changelogs/2026-09-01-read-image-context-writer-review-fixes.md b/docs/changelogs/2026-09-01-read-image-context-writer-review-fixes.md
new file mode 100644
index 0000000000..51c217ba79
--- /dev/null
+++ b/docs/changelogs/2026-09-01-read-image-context-writer-review-fixes.md
@@ -0,0 +1,109 @@
+# Read image context writer review fixes
+
+## 1. Current state and problem
+
+PR #4184 moves Read image snapshots from Session artifacts to the durable Session context Store.
+Review found four reachable ownership and lifecycle defects in the initial cut:
+
+- B1: snapshot identity used the provider-scoped tool call id rather than Runtime's durable tool
+ operation id.
+- B2: conversation copy collected context refs from the complete source event set and opaque JSON,
+ not only the selected typed projection it rewrites.
+- F1: a known T2 result-commit failure left the already-created snapshot reference live.
+- F2: recovery could discard the durable preparing-copy metadata while context retirement was
+ unavailable.
+
+## 2. Required behavior
+
+- B3: Read image snapshots are owned by the Runtime-issued durable operation id and fail closed
+ when that identity is absent.
+- B4: branch, revision, and Side Conversation copies collect only typed attachment/image refs in
+ copied Messages, selected AgentRun RuntimeEvents, and inspected archived results.
+- B5: a known T2 failure attempts to release the Read snapshot without replacing the authoritative
+ persistence error if compensation also fails.
+- B6: an unavailable context Store keeps preparing-copy cleanup retryable by failing retirement
+ before stable Session metadata deletion.
+
+## 3. Architecture and alternatives
+
+- D1: use `MakaToolContext.operationId`, already derived from invocation id plus provider tool-call
+ id, as the context Store owner id. Re-deriving or accepting the provider id would duplicate or
+ weaken Runtime authority.
+- D2: mirror the existing typed Session-file collector instead of structurally walking arbitrary
+ payloads. This keeps collection and rewrite sites identical.
+- D3: add one optional ToolRuntime T2 compensation hook and bind it only for Read image snapshots.
+ Compensation receives the normalized durable result so it releases exactly the ref T2 attempted
+ to publish.
+- D4: inject a failing context copy/retirement authority when the optional Store cannot open. The
+ preparing Session header remains the durable retry anchor.
+- R1: hard-crash or outcome-unknown windows cannot be perfectly compensated; whole-Session
+ retirement remains the durable backstop.
+- R2: ordinary branch/revision copies continue rejecting archived source-owned image refs; exact
+ archived-result migration remains outside this PR.
+
+## 4. Implementation design
+
+- Runtime `Read` refuses image snapshotting without `operationId` and passes it to the snapshot
+ Store as `ownerId`.
+- `MakaTool.compensateDurableOutcomeCommitFailure` runs only after `commitToolOutcome` rejects.
+ The Read binding recognizes only a same-Session `session_context` image result and calls
+ `releaseReference`.
+- `collectConversationCopySessionContextRefIds` accepts copied Messages, selected RuntimeEvents,
+ and archived serialized results. It decodes only canonical `ToolResultContent` image sites.
+- Runtime Host passes the same available-or-failing context authority to copy recovery and Session
+ retirement, so sidecar purge must succeed before metadata discard.
+- Compatibility epoch 88 marks the newly emitted `session_context` tool result shape after rebasing
+ onto epoch 87.
+
+## 5. Verification plan
+
+- E2E Required: no. The stable cross-package boundary is the repository's two-client UDS Session
+ revision integration test; no external service stack is involved.
+- Build `@maka/core`, `@maka/storage`, `@maka/runtime`, and `@maka/runtime-host` in dependency order.
+- Run focused Runtime tests for Read wiring, filesystem authority, conversation copy, and the
+ durable T1/T2 boundary.
+- Run focused Runtime Host tests for production composition recovery, protocol epoch, and Session
+ retirement.
+- Run the Session revision two-client UDS integration test because copy projection and restart
+ recovery cross package boundaries.
+- Run `git diff --check`; the repository has no `make lint-fix` target.
+
+## 6. Serial checklist
+
+- [x] Rebase the PR branch onto current `origin/main` and reconcile SessionTodo/protocol changes.
+- [x] Replace provider snapshot identity with Runtime durable operation identity.
+- [x] Restrict context-ref collection to selected typed rewrite sites.
+- [x] Add best-effort T2 snapshot compensation.
+- [x] Retain preparing-copy cleanup metadata while context retirement is unavailable.
+- [x] Complete final focused and integration verification.
+- [x] Complete the final six-pass diff review with no remaining actionable finding.
+- [ ] Scan secrets, publish, and monitor the updated PR.
+
+## 7. Outcome and evidence
+
+| ID | Implementation | Evidence | Status |
+| --- | --- | --- | --- |
+| B3 / D1 | Read snapshot owner is `MakaToolContext.operationId`; missing identity fails closed. | Builtin Read tests cover the exact owner and missing-identity refusal. | DONE |
+| B4 / D2 | Context refs are collected only from typed copied Messages, selected run events, and inspected archives. | Conversation-copy tests prove typed images are selected while opaque lookalikes are ignored. | DONE |
+| B5 / D3 / F1 | T2 rejection invokes Read snapshot release best-effort without masking T2. | Durable-boundary and Read compensation tests cover success and compensation failure. | DONE |
+| B6 / D4 / F2 | Unavailable context authority refuses preparing-copy retirement before metadata discard. | Production composition recovery test reopens storage and observes the preparing header intact. | DONE |
+| R1 | Hard-crash compensation remains outside the known-failure hook. | Session retirement continues to retire all context refs durably. | DONE |
+| R2 | Ordinary archived owned refs remain rejected rather than copied unsafely. | Archive preflight recognizes source-owned `session_context` images. | DONE |
+
+Final commands, all run from the repository root:
+
+- `npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build` — pass.
+- `node --test packages/runtime/dist/__tests__/builtin-tools-file-worker.test.js packages/runtime/dist/__tests__/filesystem-authority.test.js packages/runtime/dist/__tests__/conversation-copy.test.js packages/runtime/dist/__tests__/tool-runtime-durable-boundary.test.js` — 99 tests pass.
+- `node --test packages/runtime-host/dist/__tests__/execution-composition.test.js packages/runtime-host/dist/__tests__/protocol.test.js packages/runtime-host/dist/__tests__/session-retirement-coordinator.test.js` — 105 tests pass.
+- `node --test packages/runtime-host/dist/__tests__/session-revision-two-client-uds.test.js` — 1 integration test passes.
+- `git diff --check origin/main` — pass.
+- Lint fix was not run because the repository has no `Makefile` or `make lint-fix` target.
+
+The net diff was reviewed in behavior/scope, architecture, tests, security/compatibility,
+operations, and documentation passes. No migration, deployment-order change, credential, or
+external-service dependency is introduced.
+
+## 8. Remaining work
+
+- Scan secrets, push the rebased branch, resolve the six addressed GitHub threads, and monitor the
+ current head until CI and review state are clean.
diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts
index a427b76a36..655752f73a 100644
--- a/packages/runtime-host/src/__tests__/execution-composition.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts
@@ -105,6 +105,32 @@ test('production composition owns the long-term memory database lifecycle', asyn
test('production composition reaches Ready when the optional context Store cannot open', async () => {
await withCompositionRoot(async ({ root, owner }) => {
+ const requestFingerprint = `sha256:${'a'.repeat(64)}` as const;
+ const preparingSessionId = 'preparing-context-copy';
+ const stores = await openInteractiveExecutionStoresForWrite(owner.lease);
+ await stores.sessionStore.createStableSession({
+ sessionId: preparingSessionId,
+ requestFingerprint,
+ input: {
+ cwd: root,
+ llmConnectionId: FAKE_CONNECTION_ID,
+ llmConnectionSlug: 'fake',
+ model: 'fake-model',
+ permissionMode: 'ask',
+ name: 'Preparing context copy',
+ labels: [],
+ parentSessionId: 'source-session',
+ branchOfTurnId: 'source-turn',
+ conversationCopy: {
+ kind: 'branch',
+ sourceSessionId: 'source-session',
+ sourceTurnId: 'source-turn',
+ requestFingerprint,
+ state: 'preparing',
+ },
+ },
+ });
+ await stores.sessionStore.close?.();
await mkdir(join(root, CONTEXT_OFFLOAD_DATABASE_NAME));
const originalConsoleError = console.error;
const diagnostics: string[] = [];
@@ -117,9 +143,34 @@ test('production composition reaches Ready when the optional context Store canno
diagnostics.some((message) => message.includes('optional context-offload Store')),
true,
);
+ await assert.rejects(
+ composition.recover(),
+ (error) =>
+ error instanceof AggregateError &&
+ error.errors.some((failure) =>
+ String(failure).includes(
+ 'Context-offload Store is unavailable during Session retirement',
+ ),
+ ),
+ );
} finally {
console.error = originalConsoleError;
- await composition?.close();
+ if (composition) {
+ await assert.rejects(
+ composition.close(),
+ /Unable to close Runtime Host execution composition/,
+ );
+ }
+ }
+ const reopened = await openInteractiveExecutionStoresForWrite(owner.lease);
+ try {
+ assert.equal(
+ (await reopened.sessionStore.readHeaderSnapshot(preparingSessionId)).conversationCopy
+ ?.state,
+ 'preparing',
+ );
+ } finally {
+ await reopened.sessionStore.close?.();
}
});
});
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index 1297a2cb41..89be087c8f 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -288,10 +288,15 @@ export async function createExecutionRuntimeHostComposition(
const openedContextOffloadReader = openedContextOffloadStore
? createInteractiveContextOffloadReader(openedContextOffloadStore)
: undefined;
- const contextOffloadRetirement = openedContextOffloadStore
+ const contextOffloadAuthority = openedContextOffloadStore
? openedContextOffloadStore
: storage.contextOffloadUnavailable
? {
+ copyReferences: async (): Promise<never> => {
+ throw new Error('Context-offload Store is unavailable during Session copy', {
+ cause: storage.contextOffloadUnavailable?.cause,
+ });
+ },
retireSession: async (_sessionId: string): Promise<never> => {
throw new Error('Context-offload Store is unavailable during Session retirement', {
cause: storage.contextOffloadUnavailable?.cause,
@@ -421,6 +426,12 @@ export async function createExecutionRuntimeHostComposition(
bytes: input.bytes,
mimeType: input.mimeType,
}),
+ releaseImageSnapshot: async (input: {
+ readonly sessionId: string;
+ readonly refId: string;
+ }) => {
+ await openedContextOffloadStore.releaseReference(input);
+ },
}
: {}),
...(sandboxManager ? { sandboxManager } : {}),
@@ -1560,7 +1571,7 @@ export async function createExecutionRuntimeHostComposition(
stores,
artifacts: openedArtifactStore,
sessionTodo: sessionTodoStore,
- ...(contextOffloadRetirement ? { contextOffload: contextOffloadRetirement } : {}),
+ ...(contextOffloadAuthority ? { contextOffload: contextOffloadAuthority } : {}),
manager,
admission: sessionAdmission,
continuity: continuityCoordinator,
@@ -1585,7 +1596,7 @@ export async function createExecutionRuntimeHostComposition(
continuity: continuityCoordinator,
artifacts: openedArtifactStore,
sessionTodo: sessionTodoStore,
- ...(contextOffloadRetirement ? { contextOffload: contextOffloadRetirement } : {}),
+ ...(contextOffloadAuthority ? { contextOffload: contextOffloadAuthority } : {}),
purgeOperationalState: async (sessionId) => {
await stores.purgeConversationOperationalState(sessionId);
await openedPlanStore.purgeSessionState(sessionId);
diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts
index dd051dcfc8..68de60efac 100644
--- a/packages/runtime-host/src/server/session-revision-coordinator.ts
+++ b/packages/runtime-host/src/server/session-revision-coordinator.ts
@@ -511,8 +511,9 @@ export class HostSessionRevisionCoordinator {
);
const sourceContextRefIds = collectConversationCopySessionContextRefIds({
sourceSessionId: input.sourceSessionId,
- copiedMessages: slice.messages,
- plan,
+ messages: slice.messages,
+ runtimeEvents: plan.runs.flatMap(({ runtimeEvents }) => runtimeEvents),
+ archivedResults: archivePreflight.serializedResults,
});
if (sourceContextRefIds.length > 0 && !this.options.contextOffload) {
throw new Error('Session context copy authority is unavailable');
diff --git a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts
index fe9c9a1c24..26050bea7c 100644
--- a/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts
+++ b/packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts
@@ -192,7 +192,72 @@ describe('builtin file tools use the sandboxed worker', () => {
assert.equal(calls.length, 1);
assert.deepEqual(calls[0]?.operation, { kind: 'read', path: 'image.png', offset: 1, limit: 1 });
- assert.equal(snapshotOwnerId, 'tool-Read');
+ assert.equal(snapshotOwnerId, 'toolop-Read');
+ });
+
+ test('releases a Read image snapshot when durable result commit fails', async () => {
+ const released: Array<{ sessionId: string; refId: string }> = [];
+ const read = buildBuiltinTools({
+ releaseImageSnapshot: async (input) => {
+ released.push(input);
+ },
+ }).find((candidate) => candidate.name === 'Read');
+ if (!read?.compensateDurableOutcomeCommitFailure) {
+ throw new Error('Read image compensation missing');
+ }
+
+ await read.compensateDurableOutcomeCommitFailure({
+ result: {
+ kind: 'image',
+ mimeType: 'image/png',
+ ref: { kind: 'session_context', sessionId: 'session-1', refId: 'context-1' },
+ },
+ isError: false,
+ sessionId: 'session-1',
+ operationId: 'toolop-Read',
+ });
+
+ assert.deepEqual(released, [{ sessionId: 'session-1', refId: 'context-1' }]);
+ });
+
+ test('refuses to snapshot a Read image without a durable operation identity', async () => {
+ const tools = buildBuiltinTools({
+ filesystemWorker: {
+ execute: async () => ({
+ kind: 'read_image',
+ base64: 'iVBORw0KGgo=',
+ mimeType: 'image/png',
+ }),
+ },
+ snapshotImage: async () => {
+ assert.fail('snapshot must not run without a durable operation identity');
+ },
+ sandboxPlatform: 'darwin',
+ });
+ const read = tools.find((candidate) => candidate.name === 'Read');
+ if (!read) throw new Error('Read tool missing');
+
+ await assert.rejects(
+ Promise.resolve(
+ read.impl(
+ { path: 'image.png' },
+ {
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ toolCallId: 'provider-call-reused',
+ cwd: await temporaryDirectory('maka-file-worker-cwd-'),
+ permissionMode: 'ask',
+ executionBoundary: createManagedExecutionBoundary(
+ createWorkspaceWritePermissionProfile(),
+ 0,
+ ),
+ abortSignal: new AbortController().signal,
+ emitOutput: () => {},
+ },
+ ),
+ ),
+ /require a durable tool operation identity/,
+ );
});
test('serializes writes through real and symlinked cwd paths', async () => {
@@ -428,6 +493,7 @@ async function runTool(
sessionId: 'session-1',
turnId: 'turn-1',
toolCallId: `tool-${name}`,
+ operationId: `toolop-${name}`,
cwd,
permissionMode: 'ask',
executionBoundary: createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0),
diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts
index 7c1504ad39..5bf195594d 100644
--- a/packages/runtime/src/__tests__/conversation-copy.test.ts
+++ b/packages/runtime/src/__tests__/conversation-copy.test.ts
@@ -745,6 +745,11 @@ test('conversation copy rewrites owned references without changing opaque tool p
sessionId: 'session-source',
runId: 'run-source',
artifactId: 'artifact-source',
+ opaqueStorageRefShape: {
+ kind: 'session_context',
+ sessionId: 'session-source',
+ refId: 'context-opaque',
+ },
},
providerOptions: {
sourceInvocationId: 'invocation-source',
@@ -833,15 +838,49 @@ test('conversation copy rewrites owned references without changing opaque tool p
assert.deepEqual(
collectConversationCopySessionContextRefIds({
sourceSessionId: 'session-source',
- copiedMessages: messages,
- plan: {
- sourceSessionId: 'session-source',
- copyTurnIds: ['turn-1'],
- inlineRuntimeEvents: [],
- runs: [],
- },
+ messages,
+ runtimeEvents: [
+ runtimeEvent({
+ id: 'selected-image-result',
+ role: 'tool',
+ author: 'tool',
+ content: {
+ kind: 'function_response',
+ id: 'tool-image',
+ name: 'Read',
+ result: {
+ kind: 'image',
+ mimeType: 'image/png',
+ ref: {
+ kind: 'session_context',
+ sessionId: 'session-source',
+ refId: 'context-selected-event',
+ },
+ },
+ },
+ }),
+ runtimeEvent({
+ id: 'opaque-json-result',
+ role: 'tool',
+ author: 'tool',
+ content: {
+ kind: 'function_response',
+ id: 'tool-opaque',
+ name: 'opaque',
+ result: {
+ kind: 'json',
+ value: {
+ kind: 'session_context',
+ sessionId: 'session-source',
+ refId: 'context-opaque-event',
+ },
+ },
+ },
+ }),
+ ],
+ archivedResults: [],
}),
- ['context-source'],
+ ['context-selected-event', 'context-source'],
);
assert.throws(
() =>
diff --git a/packages/runtime/src/__tests__/filesystem-authority.test.ts b/packages/runtime/src/__tests__/filesystem-authority.test.ts
index c755002fa4..ed20bbbe63 100644
--- a/packages/runtime/src/__tests__/filesystem-authority.test.ts
+++ b/packages/runtime/src/__tests__/filesystem-authority.test.ts
@@ -78,6 +78,7 @@ function runTool(
turnId: 'turn-1',
cwd,
toolCallId: 'tool-1',
+ operationId: 'toolop-1',
abortSignal: new AbortController().signal,
emitOutput: () => {},
...(executionBoundary ? { executionBoundary } : {}),
diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts
index 71b470df11..0c87cb82a4 100644
--- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts
+++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts
@@ -1593,6 +1593,7 @@ describe('ToolRuntime durable boundary', () => {
it('does not publish an implementation result when T2 fails', async () => {
let implementationCalls = 0;
+ const compensations: unknown[] = [];
const harness = makeHarness({
commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }),
commitToolOutcome: async () => {
@@ -1600,15 +1601,15 @@ describe('ToolRuntime durable boundary', () => {
},
});
- await assert.rejects(
- harness.execute(
- tool(() => {
- implementationCalls += 1;
- return { ok: true };
- }),
- ),
- /T2 unavailable/,
- );
+ const target = tool(() => {
+ implementationCalls += 1;
+ return { ok: true };
+ });
+ target.compensateDurableOutcomeCommitFailure = async (input) => {
+ compensations.push(input);
+ };
+
+ await assert.rejects(harness.execute(target), /T2 unavailable/);
assert.equal(implementationCalls, 1);
assert.equal(
@@ -1619,6 +1620,35 @@ describe('ToolRuntime durable boundary', () => {
harness.messages.some((message) => message.type === 'tool_result'),
false,
);
+ assert.equal(compensations.length, 1);
+ const compensation = compensations[0] as {
+ result: unknown;
+ isError: boolean;
+ sessionId: string;
+ operationId: string;
+ };
+ assert.deepEqual({ ...compensation, operationId: '<runtime-owned>' }, {
+ result: { kind: 'json', value: { ok: true } },
+ isError: false,
+ sessionId: 'session-1',
+ operationId: '<runtime-owned>',
+ });
+ assert.match(compensation.operationId, /^toolop_/);
+ });
+
+ it('keeps the T2 persistence error authoritative when compensation also fails', async () => {
+ const harness = makeHarness({
+ commitToolPrepared: async () => ({ created: true, runtimeEventSeq: 1 }),
+ commitToolOutcome: async () => {
+ throw new Error('T2 unavailable');
+ },
+ });
+ const target = tool(() => ({ ok: true }));
+ target.compensateDurableOutcomeCommitFailure = async () => {
+ throw new Error('compensation unavailable');
+ };
+
+ await assert.rejects(harness.execute(target), /T2 unavailable/);
});
it('commits a normalized error outcome before returning a thrown tool failure to the model', async () => {
diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts
index 37a2f43011..18ddde6aff 100644
--- a/packages/runtime/src/builtin-tools.ts
+++ b/packages/runtime/src/builtin-tools.ts
@@ -39,7 +39,7 @@ import { basename, dirname, isAbsolute } from 'node:path';
import { compilePermissionProfile } from '@maka/core/permission-profile-compiler';
import { parseAttachmentResourceRef } from '@maka/core/attachments';
import { type SandboxBoundaryExpansion } from '@maka/core/sandbox-boundary';
-import { type StorageRef, type ToolResultContent } from '@maka/core/events';
+import { isStorageRef, type StorageRef, type ToolResultContent } from '@maka/core/events';
import { type PermissionProfile } from '@maka/core/permission-profile';
import { bashToolResultToModelOutput } from './bash-model-output.js';
import { fileWriteToolResultToModelOutput } from './file-tool-model-output.js';
@@ -188,6 +188,7 @@ export interface BuildBuiltinToolsOptions {
bytes: Uint8Array;
mimeType: string;
}) => Promise<Extract<StorageRef, { kind: 'session_context' }>>;
+ releaseImageSnapshot?: (input: { sessionId: string; refId: string }) => Promise<void>;
}
export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaTool[] {
@@ -355,6 +356,35 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT
description: readDescription,
parameters: readParameters,
executionFacts,
+ ...(options.releaseImageSnapshot
+ ? {
+ compensateDurableOutcomeCommitFailure: async (input: {
+ readonly result: unknown;
+ readonly sessionId: string;
+ }) => {
+ const result = input.result;
+ if (
+ !result ||
+ typeof result !== 'object' ||
+ (result as { kind?: unknown }).kind !== 'image'
+ ) {
+ return;
+ }
+ const ref = (result as { ref?: unknown }).ref;
+ if (
+ !isStorageRef(ref) ||
+ ref.kind !== 'session_context' ||
+ ref.sessionId !== input.sessionId
+ ) {
+ return;
+ }
+ await options.releaseImageSnapshot!({
+ sessionId: ref.sessionId,
+ refId: ref.refId,
+ });
+ },
+ }
+ : {}),
impl: async (input, ctx) => {
const { cwd, sessionId, abortSignal } = ctx;
if ('ref' in input) {
@@ -397,9 +427,12 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT
if (result.kind === 'read_image') {
if (!options.snapshotImage)
throw new Error('Read image snapshots are not available in this toolset.');
+ if (!ctx.operationId) {
+ throw new Error('Read image snapshots require a durable tool operation identity.');
+ }
const ref = await options.snapshotImage({
sessionId,
- ownerId: ctx.toolCallId,
+ ownerId: ctx.operationId,
bytes: result.bytes,
mimeType: result.mimeType,
});
diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts
index fe42b81b12..1448fcfd96 100644
--- a/packages/runtime/src/conversation-copy.ts
+++ b/packages/runtime/src/conversation-copy.ts
@@ -25,7 +25,7 @@ import type {
} from '@maka/core/agent-run';
import type { RuntimeEvent } from '@maka/core/runtime-event';
import type { RuntimeEventStore } from '@maka/core/runtime-event-store';
-import { isStorageRef, type StorageRef, type ToolResultContent } from '@maka/core/events';
+import { type StorageRef, type ToolResultContent } from '@maka/core/events';
import { parseAttachmentResourceRef } from '@maka/core/attachments';
import { markPersisted } from '@maka/core/persisted-value';
import type { StoredMessage } from '@maka/core/session';
@@ -163,29 +163,44 @@ export interface ConversationRuntimeLedgerCopyPlan {
/** Finds durable Session context references that the exact copy will rewrite. */
export function collectConversationCopySessionContextRefIds(input: {
readonly sourceSessionId: string;
- readonly copiedMessages: readonly StoredMessage[];
- readonly plan: ConversationRuntimeLedgerCopyPlan;
+ readonly messages: readonly StoredMessage[];
+ readonly runtimeEvents: readonly RuntimeEvent[];
+ readonly archivedResults: readonly string[];
}): readonly string[] {
const refIds = new Set<string>();
- const seen = new WeakSet<object>();
- const visit = (value: unknown): void => {
- if (isStorageRef(value)) {
- if (value.kind === 'session_context' && value.sessionId === input.sourceSessionId) {
- refIds.add(value.refId);
- }
- return;
+ const addRef = (ref: StorageRef): void => {
+ if (ref.kind === 'session_context' && ref.sessionId === input.sourceSessionId) {
+ refIds.add(ref.refId);
}
- if (typeof value !== 'object' || value === null || seen.has(value)) return;
- seen.add(value);
- if (Array.isArray(value)) {
- for (const item of value) visit(item);
- return;
+ };
+ const addContent = (content: ToolResultContent): void => {
+ if (content.kind === 'image') addRef(content.ref);
+ };
+ const addSerialized = (value: unknown): void => {
+ if (isArchivedToolResultPlaceholder(value)) return;
+ try {
+ addContent(decodePersistedToolResultContent(markPersisted<ToolResultContent>(value)));
+ } catch {
+ // Opaque tool results carry no typed Session context reference.
}
- for (const item of Object.values(value)) visit(item);
};
- visit(input.copiedMessages);
- visit(input.plan.inlineRuntimeEvents);
- for (const run of input.plan.runs) visit(run.runtimeEvents);
+ for (const message of input.messages) {
+ if (message.type === 'user' && message.attachments) {
+ for (const attachment of message.attachments) addRef(attachment.ref);
+ } else if (message.type === 'tool_result') {
+ addContent(message.content);
+ }
+ }
+ for (const event of input.runtimeEvents) {
+ if (event.content?.kind === 'text' && event.content.attachments) {
+ for (const attachment of event.content.attachments) addRef(attachment.ref);
+ } else if (event.content?.kind === 'function_response') {
+ addSerialized(event.content.result);
+ }
+ }
+ for (const serializedResult of input.archivedResults) {
+ addSerialized(deserializeToolResultArchive(serializedResult));
+ }
return [...refIds].sort();
}
@@ -676,7 +691,10 @@ export function archivedToolResultContainsConversationOwnedReferences(
if (content.kind === 'archived_tool_result') return true;
if (content.kind === 'image') {
- return content.ref.kind === 'session_file' && content.ref.sessionId === sourceSessionId;
+ return (
+ (content.ref.kind === 'session_file' || content.ref.kind === 'session_context') &&
+ content.ref.sessionId === sourceSessionId
+ );
}
if (content.kind === 'subagent') {
const [linked] = conversationCopyLinkedChildReferences(content);
diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts
index 5214ffda49..cb81443d34 100644
--- a/packages/runtime/src/tool-runtime.ts
+++ b/packages/runtime/src/tool-runtime.ts
@@ -223,6 +223,13 @@ export interface MakaTool<P = any, R = unknown> {
* settlement instead of detaching, so late side effects cannot outlive `exec`.
*/
impl: (args: P, ctx: MakaToolContext) => Promise<R> | R;
+ /** Best-effort compensation after T2 rejects a result that already produced side effects. */
+ compensateDurableOutcomeCommitFailure?: (input: {
+ readonly result: unknown;
+ readonly isError: boolean;
+ readonly sessionId: string;
+ readonly operationId: string;
+ }) => Promise<void> | void;
/** Optional synchronous provider-visible content mapping, used for screenshot image parts. */
toModelOutput?: (options: {
toolCallId: string;
@@ -2312,6 +2319,17 @@ export class ToolRuntime {
committedAt: responseEvent.ts,
});
} catch (error) {
+ try {
+ await input.tool.compensateDurableOutcomeCommitFailure?.({
+ result,
+ isError,
+ sessionId: this.input.sessionId,
+ operationId,
+ });
+ } catch {
+ // T2 remains authoritative. Compensation is deliberately best-effort
+ // and must never replace the persistence failure that triggered it.
+ }
throw new RuntimeCommitBoundaryError('T2', error);
}
committedOutcome = {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org