Skip to content

Commit a003af7

Browse files
author
likun
committed
feat(runtime-host): write Read images to Session context
1 parent 407a687 commit a003af7

13 files changed

Lines changed: 193 additions & 96 deletions

packages/runtime-host/src/__tests__/execution-composition.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ test('production composition owns the long-term memory database lifecycle', asyn
9898
});
9999
});
100100

101-
test('production composition reaches Ready when the optional context reader cannot open', async () => {
101+
test('production composition reaches Ready when the optional context Store cannot open', async () => {
102102
await withCompositionRoot(async ({ root, owner }) => {
103103
await mkdir(join(root, CONTEXT_OFFLOAD_DATABASE_NAME));
104104
const originalConsoleError = console.error;
@@ -109,7 +109,7 @@ test('production composition reaches Ready when the optional context reader cann
109109
composition = await createExecutionRuntimeHostComposition(compositionContext(owner));
110110
assert.equal(composition.workspaceExecution.state, 'ready');
111111
assert.equal(
112-
diagnostics.some((message) => message.includes('optional context-offload reader')),
112+
diagnostics.some((message) => message.includes('optional context-offload Store')),
113113
true,
114114
);
115115
} finally {

packages/runtime-host/src/__tests__/protocol.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ describe('Runtime Host bootstrap protocol', () => {
134134
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 22);
135135
});
136136

137+
test('publishes a new compatibility epoch for Read image Session context refs', () => {
138+
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 67);
139+
});
140+
137141
test('rejects the legacy connection update result in the current compatibility epoch', () => {
138142
assert.throws(
139143
() =>

packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ describe('Host Session retirement coordinator', () => {
228228
'parent retirement cleanup did not converge',
229229
);
230230
assert.deepEqual(new Set(harness.actions.purgedArtifacts), new Set(harness.familyIds));
231-
assert.deepEqual(new Set(harness.actions.checkedContext), new Set(harness.familyIds));
231+
assert.deepEqual(new Set(harness.actions.retiredContext), new Set(harness.familyIds));
232232
});
233233
});
234234

@@ -1009,7 +1009,7 @@ interface RetirementActions {
10091009
readonly retiredCapabilities: string[];
10101010
readonly retiredMessages: string[];
10111011
readonly purgedArtifacts: string[];
1012-
readonly checkedContext: string[];
1012+
readonly retiredContext: string[];
10131013
readonly purgedTasks: string[];
10141014
readonly purgedOperationalState: string[];
10151015
readonly purgedAgentGraphs: string[];
@@ -1048,7 +1048,7 @@ async function withHarness(
10481048
retiredCapabilities: [],
10491049
retiredMessages: [],
10501050
purgedArtifacts: [],
1051-
checkedContext: [],
1051+
retiredContext: [],
10521052
purgedTasks: [],
10531053
purgedOperationalState: [],
10541054
purgedAgentGraphs: [],
@@ -1214,8 +1214,11 @@ async function withHarness(
12141214
actions.purgedTasks.push(sessionId);
12151215
},
12161216
},
1217-
assertNoContextOffloadReferences: async (sessionIds) => {
1218-
actions.checkedContext.push(...sessionIds);
1217+
contextOffload: {
1218+
retireSession: async (sessionId) => {
1219+
actions.retiredContext.push(sessionId);
1220+
return { releasedReferences: 0, releasedLogicalBytes: 0 };
1221+
},
12191222
},
12201223
purgeOperationalState: async (sessionId) => {
12211224
actions.purgedOperationalState.push(sessionId);

packages/runtime-host/src/protocol/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
9494
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
9595
// Increment when the same protocol version no longer guarantees safe Client-Host
9696
// interoperability. Mismatches are rejected before domain commands are admitted.
97-
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 67 as const;
97+
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 68 as const;
98+
// 68: Read image tool results may carry durable `session_context` refs.
9899
// 67: Message lifecycle queries expose durable execution ownership and
99100
// cancellation. Older peers cannot decode or provide the closed proof list.
100101
// 66: Peer Mesh queries expose one canonical transit selection and runtime metrics.

packages/runtime-host/src/server/execution-composition.ts

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,9 @@ import {
7777
import { type MakaTool } from '@maka/runtime/tool-runtime';
7878
import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority';
7979
import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store';
80-
import {
81-
createArtifactAttachmentResourceReader,
82-
createReadImageSnapshotter,
83-
} from '@maka/storage/artifact-stores';
84-
import {
85-
isSessionNotFoundError,
86-
SessionMetadataConflictError,
87-
} from '@maka/storage/execution-stores';
80+
import { createArtifactAttachmentResourceReader } from '@maka/storage/artifact-stores';
81+
import { createReadImageSnapshotStore } from '@maka/storage/read-image-snapshot-store';
82+
import { isSessionNotFoundError } from '@maka/storage/execution-stores';
8883
import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions';
8984
import { createGitWorktreeChildExecutor } from '@maka/storage/git-worktree-child-executor';
9085
import { runWithStorageRootLease } from '@maka/storage/root-authority';
@@ -202,15 +197,16 @@ export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition
202197
readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition;
203198
}
204199

205-
const CONTEXT_OFFLOAD_READER_LIMITS: ContextOffloadLimits = Object.freeze({
200+
const GIBIBYTE = 1024 * 1024 * 1024;
201+
const CONTEXT_OFFLOAD_LIMITS: ContextOffloadLimits = Object.freeze({
206202
ownerMaxBytes: Object.freeze({
207203
read_image_snapshot: MAX_READ_IMAGE_BYTES,
208204
tool_result_archive: 0,
209205
}),
210-
// This expand slice opens only the reader path. Zero quotas make accidental
211-
// non-empty puts fail closed until the writer/lifecycle cutover lands.
212-
sessionLogicalBytes: 0,
213-
workspacePhysicalBytes: 0,
206+
// Read images are bounded individually and logically per Session. Physical
207+
// bytes are content-addressed across Sessions and bounded per workspace.
208+
sessionLogicalBytes: GIBIBYTE,
209+
workspacePhysicalBytes: 20 * GIBIBYTE,
214210
});
215211

216212
export interface CreateExecutionRuntimeHostCompositionOptions {
@@ -239,7 +235,7 @@ export async function createExecutionRuntimeHostComposition(
239235
dependencies: ExecutionRuntimeHostCompositionDependencies = {},
240236
): Promise<ExecutionRuntimeHostComposition> {
241237
const storage = await openStorageWriterComposition(context.owner.lease, {
242-
contextOffloadLimits: CONTEXT_OFFLOAD_READER_LIMITS,
238+
contextOffloadLimits: CONTEXT_OFFLOAD_LIMITS,
243239
afterRuntimePolicyOpened: async (stores) => {
244240
if (options.bootstrapRuntimePolicy !== false) {
245241
await ensureBootstrapRuntimePolicy({
@@ -255,7 +251,7 @@ export async function createExecutionRuntimeHostComposition(
255251
});
256252
if (storage.contextOffloadUnavailable) {
257253
console.error(
258-
`[runtime-host] optional context-offload reader could not be opened: ${generalizedErrorMessage(storage.contextOffloadUnavailable.cause)}`,
254+
`[runtime-host] optional context-offload Store could not be opened: ${generalizedErrorMessage(storage.contextOffloadUnavailable.cause)}`,
259255
);
260256
}
261257
const stores = storage.execution;
@@ -285,6 +281,17 @@ export async function createExecutionRuntimeHostComposition(
285281
const openedContextOffloadReader = openedContextOffloadStore
286282
? createInteractiveContextOffloadReader(openedContextOffloadStore)
287283
: undefined;
284+
const contextOffloadRetirement = openedContextOffloadStore
285+
? openedContextOffloadStore
286+
: storage.contextOffloadUnavailable
287+
? {
288+
retireSession: async (_sessionId: string): Promise<never> => {
289+
throw new Error('Context-offload Store is unavailable during Session retirement', {
290+
cause: storage.contextOffloadUnavailable?.cause,
291+
});
292+
},
293+
}
294+
: undefined;
288295
const openedUsageStores = storage.usage;
289296
const openedShellRunStore = storage.shellRuns;
290297
const worktreeChildExecutor = createGitWorktreeChildExecutor({
@@ -395,7 +402,21 @@ export async function createExecutionRuntimeHostComposition(
395402
}),
396403
backgroundTasks: runtimeResources,
397404
ptyControls: runtimeResources,
398-
snapshotImage: createReadImageSnapshotter(openedArtifactStore),
405+
...(openedContextOffloadStore
406+
? {
407+
snapshotImage: async (input: {
408+
readonly sessionId: string;
409+
readonly ownerId: string;
410+
readonly bytes: Uint8Array;
411+
readonly mimeType: string;
412+
}) =>
413+
createReadImageSnapshotStore(openedContextOffloadStore, input.sessionId).snapshot({
414+
ownerId: input.ownerId,
415+
bytes: input.bytes,
416+
mimeType: input.mimeType,
417+
}),
418+
}
419+
: {}),
399420
...(sandboxManager ? { sandboxManager } : {}),
400421
...(filesystemWorker ? { filesystemWorker } : {}),
401422
};
@@ -1437,6 +1458,7 @@ export async function createExecutionRuntimeHostComposition(
14371458
stores,
14381459
artifacts: openedArtifactStore,
14391460
taskLedger: taskLedgerStore,
1461+
...(openedContextOffloadStore ? { contextOffload: openedContextOffloadStore } : {}),
14401462
manager,
14411463
admission: sessionAdmission,
14421464
continuity: continuityCoordinator,
@@ -1461,20 +1483,7 @@ export async function createExecutionRuntimeHostComposition(
14611483
continuity: continuityCoordinator,
14621484
artifacts: openedArtifactStore,
14631485
taskLedger: taskLedgerStore,
1464-
assertNoContextOffloadReferences: async (sessionIds) => {
1465-
if (!openedContextOffloadStore) {
1466-
throw new Error('Context-offload reader is unavailable during Session removal', {
1467-
cause: storage.contextOffloadUnavailable?.cause,
1468-
});
1469-
}
1470-
for (const sessionId of sessionIds) {
1471-
if ((await openedContextOffloadStore.usage(sessionId)).references > 0) {
1472-
throw new SessionMetadataConflictError(
1473-
'Session removal does not support Session context references yet',
1474-
);
1475-
}
1476-
}
1477-
},
1486+
...(contextOffloadRetirement ? { contextOffload: contextOffloadRetirement } : {}),
14781487
purgeOperationalState: async (sessionId) => {
14791488
await stores.purgeConversationOperationalState(sessionId);
14801489
await openedPlanStore.purgeSessionState(sessionId);

packages/runtime-host/src/server/session-retirement-coordinator.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from '@maka/storage/execution-stores';
3333
import { type SessionManager } from '@maka/runtime/session-manager';
3434
import type { InteractiveTaskLedgerWriter } from '@maka/storage/task-ledger-authority';
35+
import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store';
3536
import {
3637
type OperationOutcome,
3738
type SessionCatalogItem,
@@ -121,7 +122,7 @@ export interface HostSessionRetirementCoordinatorOptions {
121122
readonly continuity: RetirementContinuity;
122123
readonly artifacts: Pick<InteractiveArtifactStoreWriter, 'purgeSessionArtifacts'>;
123124
readonly taskLedger: Pick<InteractiveTaskLedgerWriter, 'purgeConversationTaskLedger'>;
124-
readonly assertNoContextOffloadReferences?: (sessionIds: readonly string[]) => Promise<void>;
125+
readonly contextOffload?: Pick<InteractiveContextOffloadWriter, 'retireSession'>;
125126
readonly purgeOperationalState: (sessionId: string) => Promise<void>;
126127
readonly purgeAgentGraphState: (sessionId: string) => Promise<void>;
127128
readonly worktrees?: Pick<SubagentWorktreeExecutor, 'retire'>;
@@ -192,7 +193,7 @@ export class HostSessionRetirementCoordinator {
192193
readonly #continuity: RetirementContinuity;
193194
readonly #artifacts: HostSessionRetirementCoordinatorOptions['artifacts'];
194195
readonly #taskLedger: HostSessionRetirementCoordinatorOptions['taskLedger'];
195-
readonly #assertNoContextOffloadReferences: HostSessionRetirementCoordinatorOptions['assertNoContextOffloadReferences'];
196+
readonly #contextOffload: HostSessionRetirementCoordinatorOptions['contextOffload'];
196197
readonly #purgeOperationalState: HostSessionRetirementCoordinatorOptions['purgeOperationalState'];
197198
readonly #purgeAgentGraphState: HostSessionRetirementCoordinatorOptions['purgeAgentGraphState'];
198199
readonly #worktrees: HostSessionRetirementCoordinatorOptions['worktrees'];
@@ -220,7 +221,7 @@ export class HostSessionRetirementCoordinator {
220221
this.#continuity = options.continuity;
221222
this.#artifacts = options.artifacts;
222223
this.#taskLedger = options.taskLedger;
223-
this.#assertNoContextOffloadReferences = options.assertNoContextOffloadReferences;
224+
this.#contextOffload = options.contextOffload;
224225
this.#purgeOperationalState = options.purgeOperationalState;
225226
this.#purgeAgentGraphState = options.purgeAgentGraphState;
226227
this.#worktrees = options.worktrees;
@@ -336,7 +337,6 @@ export class HostSessionRetirementCoordinator {
336337
if (plan.archive.sessionIds.length > 0) {
337338
archiveHandles = await this.#prepareRetirement(plan.archive, 'archive');
338339
}
339-
await this.#assertNoContextOffloadReferences?.(plan.remove.sessionIds);
340340
const allSessionIds = [...plan.remove.sessionIds, ...plan.archive.sessionIds];
341341
await this.#finalizeWorkspacePatches(allSessionIds);
342342
await this.#disposeBackends(allSessionIds);
@@ -672,6 +672,7 @@ export class HostSessionRetirementCoordinator {
672672
{
673673
artifacts: this.#artifacts,
674674
taskLedger: this.#taskLedger,
675+
...(this.#contextOffload ? { contextOffload: this.#contextOffload } : {}),
675676
purgeOperationalState: this.#purgeOperationalState,
676677
},
677678
sessionId,

packages/runtime-host/src/server/session-revision-coordinator.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
archivedToolResultContainsConversationOwnedReferences,
3636
cloneConversationRuntimeLedger,
3737
collectConversationCopyLinkedChildReferences,
38+
collectConversationCopySessionContextRefIds,
3839
createConversationCopySlice,
3940
prepareConversationRuntimeLedgerCopy,
4041
type ConversationRuntimeLedgerCopyPlan,
@@ -54,6 +55,7 @@ import {
5455
authenticateInteractiveTaskLedgerWriter,
5556
type InteractiveTaskLedgerWriter,
5657
} from '@maka/storage/task-ledger-authority';
58+
import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store';
5759
import type {
5860
OperationOutcome,
5961
SessionConversationCopyInput,
@@ -99,6 +101,10 @@ export interface HostSessionRevisionCoordinatorOptions {
99101
readonly stores: ExecutionStoresWriter<'interactive'>;
100102
readonly artifacts: InteractiveArtifactStoreWriter;
101103
readonly taskLedger: InteractiveTaskLedgerWriter;
104+
readonly contextOffload?: Pick<
105+
InteractiveContextOffloadWriter,
106+
'copyReferences' | 'retireSession'
107+
>;
102108
readonly manager: SessionManager;
103109
readonly admission: SessionAdmissionGate;
104110
readonly continuity: SessionContinuityCoordinator;
@@ -489,6 +495,28 @@ export class HostSessionRevisionCoordinator {
489495
)
490496
.map(({ descriptor, serializedResult }) => [descriptor.artifactId, serializedResult]),
491497
);
498+
const sourceContextRefIds = collectConversationCopySessionContextRefIds({
499+
sourceSessionId: input.sourceSessionId,
500+
copiedMessages: slice.messages,
501+
plan,
502+
});
503+
if (sourceContextRefIds.length > 0 && !this.options.contextOffload) {
504+
throw new Error('Session context copy authority is unavailable');
505+
}
506+
const contextCopy =
507+
sourceContextRefIds.length === 0
508+
? { ok: true as const, copied: [] }
509+
: await this.options.contextOffload!.copyReferences({
510+
sourceSessionId: input.sourceSessionId,
511+
targetSessionId: input.targetSessionId,
512+
references: sourceContextRefIds.map((sourceRefId) => ({
513+
sourceRefId,
514+
targetOwner: { kind: 'read_image_snapshot', ownerId: sourceRefId },
515+
})),
516+
});
517+
if (!contextCopy.ok) {
518+
throw new Error(`Session context references could not be copied: ${contextCopy.reason}`);
519+
}
492520
const artifactCopy = await this.#artifacts.copyConversationArtifacts({
493521
sourceSessionId: input.sourceSessionId,
494522
targetSessionId: input.targetSessionId,
@@ -511,6 +539,9 @@ export class HostSessionRevisionCoordinator {
511539
targetSessionId: input.targetSessionId,
512540
artifactIds: artifactCopy.artifactIds,
513541
relativePaths: artifactCopy.relativePaths,
542+
contextRefs: new Map(
543+
contextCopy.copied.map(({ sourceRefId, targetRefId }) => [sourceRefId, targetRefId]),
544+
),
514545
linkedChildren:
515546
kind === 'side_conversation'
516547
? {
@@ -803,6 +834,7 @@ export class HostSessionRevisionCoordinator {
803834
{
804835
artifacts: this.#artifacts,
805836
taskLedger: this.#taskLedger,
837+
...(this.options.contextOffload ? { contextOffload: this.options.contextOffload } : {}),
806838
purgeOperationalState: (sessionId) =>
807839
this.#stores.purgeConversationOperationalState(sessionId),
808840
},

packages/runtime-host/src/server/session-sidecar-purge.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919

2020
import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores';
2121
import type { InteractiveTaskLedgerWriter } from '@maka/storage/task-ledger-authority';
22+
import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store';
2223

2324
export interface SessionSidecarPurgeAuthority {
2425
readonly artifacts: Pick<InteractiveArtifactStoreWriter, 'purgeSessionArtifacts'>;
2526
readonly taskLedger: Pick<InteractiveTaskLedgerWriter, 'purgeConversationTaskLedger'>;
27+
readonly contextOffload?: Pick<InteractiveContextOffloadWriter, 'retireSession'>;
2628
readonly purgeOperationalState: (sessionId: string) => Promise<void>;
2729
}
2830

@@ -33,6 +35,7 @@ export async function purgeSessionSidecars(
3335
const outcomes = await Promise.allSettled([
3436
authority.artifacts.purgeSessionArtifacts(sessionId),
3537
authority.taskLedger.purgeConversationTaskLedger(sessionId),
38+
...(authority.contextOffload ? [authority.contextOffload.retireSession(sessionId)] : []),
3639
authority.purgeOperationalState(sessionId),
3740
]);
3841
const failures = outcomes.flatMap((outcome) =>

packages/runtime/src/__tests__/builtin-tools-file-worker.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,25 +169,30 @@ describe('builtin file tools use the sandboxed worker', () => {
169169
test('uses one worker read operation for image paths', async () => {
170170
const cwd = await temporaryDirectory('maka-file-worker-cwd-');
171171
const calls: FilesystemWorkerExecuteInput[] = [];
172+
let snapshotOwnerId: string | undefined;
172173
const tools = buildBuiltinTools({
173174
filesystemWorker: {
174175
execute: async (input) => {
175176
calls.push(input);
176177
return { kind: 'read_image', base64: 'iVBORw0KGgo=', mimeType: 'image/png' };
177178
},
178179
},
179-
snapshotImage: async () => ({
180-
kind: 'session_file',
181-
sessionId: 'session-1',
182-
relativePath: 'artifact-1',
183-
}),
180+
snapshotImage: async (input) => {
181+
snapshotOwnerId = input.ownerId;
182+
return {
183+
kind: 'session_context',
184+
sessionId: 'session-1',
185+
refId: 'context-1',
186+
};
187+
},
184188
sandboxPlatform: 'darwin',
185189
});
186190

187191
await runTool(tools, 'Read', { path: 'image.png', offset: 1, limit: 1 }, cwd);
188192

189193
assert.equal(calls.length, 1);
190194
assert.deepEqual(calls[0]?.operation, { kind: 'read', path: 'image.png', offset: 1, limit: 1 });
195+
assert.equal(snapshotOwnerId, 'tool-Read');
191196
});
192197

193198
test('serializes writes through real and symlinked cwd paths', async () => {

0 commit comments

Comments
 (0)