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