Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- Unified context management under one Runtime-owned policy. `MAKA_CONTEXT_*` environment overrides no longer tune or disable compaction and Tool Result pruning; model-visible archive placeholders are read on demand through bounded `ArchiveRead` calls instead of eager hydration. Previously supported overrides are ignored on upgrade: if Tool Result pruning was set to `off`, pruning is re-enabled, and there is currently no supported replacement opt-out.
- Moved Read image snapshots into the durable context-offload store with Runtime-owned
lifecycle identity, exact branch and revision copying, recovery-safe cleanup, and bounded
physical garbage collection after Session retirement.

## 0.1.11 - 2026-08-18

Expand Down
51 changes: 48 additions & 3 deletions packages/runtime-host/src/__tests__/execution-composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { fingerprintAgentGraphRunnableIntent } from '@maka/runtime/stream-graph-
import type { AgentGraphRunnableIntent } from '@maka/runtime/stream-graph-readiness';
import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store';
import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores';
import { createSessionStore } from '@maka/storage/session-store';
import {
LONG_TERM_MEMORY_DATABASE_NAME,
openInteractiveLongTermMemoryStoreForWrite,
Expand Down Expand Up @@ -103,8 +104,34 @@ test('production composition owns the long-term memory database lifecycle', asyn
});
});

test('production composition reaches Ready when the optional context reader cannot open', async () => {
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 sessionStore = createSessionStore(root);
await 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 sessionStore.close?.();
await mkdir(join(root, CONTEXT_OFFLOAD_DATABASE_NAME));
const originalConsoleError = console.error;
const diagnostics: string[] = [];
Expand All @@ -114,12 +141,30 @@ test('production composition reaches Ready when the optional context reader cann
composition = await createExecutionRuntimeHostComposition(compositionContext(owner));
assert.equal(composition.workspaceExecution.state, 'ready');
assert.equal(
diagnostics.some((message) => message.includes('optional context-offload reader')),
diagnostics.some((message) => message.includes('optional context-offload Store')),
true,
);
await composition.recover();
assert.equal(
diagnostics.some((message) =>
message.includes('conversation copy cleanup deferred during recovery'),
),
true,
);
} finally {
console.error = originalConsoleError;
await composition?.close();
if (composition) {
await composition.close();
}
}
const reopened = createSessionStore(root);
try {
assert.equal(
(await reopened.readHeaderSnapshot(preparingSessionId)).conversationCopy?.state,
'preparing',
);
} finally {
await reopened.close?.();
}
});
});
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ describe('Runtime Host bootstrap protocol', () => {
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 78);
});

test('publishes a new compatibility epoch for Read image Session context refs', () => {
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 93);
});

test('rejects the legacy connection update result in the current compatibility epoch', () => {
assert.throws(
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type { ConnectionContext } from '../server/operation-dispatcher.js';
import { SessionAdmissionGate } from '../server/session-admission-gate.js';
import { MemoryExtractionSessionLane } from '../server/memory-extraction-session-lane.js';
import { HostSessionRetirementCoordinator } from '../server/session-retirement-coordinator.js';
import { purgeSessionSidecars } from '../server/session-sidecar-purge.js';
import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';

const CONNECTION_CONTEXT: ConnectionContext = {
Expand All @@ -51,6 +52,37 @@ const CONNECTION_CONTEXT: ConnectionContext = {
};

describe('Host Session retirement coordinator', () => {
test('retires context refs before draining every physical garbage batch', async () => {
const contextActions: string[] = [];
let garbageBatches = 0;
await purgeSessionSidecars(
{
artifacts: { purgeSessionArtifacts: async () => {} },
sessionTodo: { purgeSessionState: async () => {} },
contextOffload: {
retireSession: async (sessionId) => {
contextActions.push(`retire:${sessionId}`);
return { releasedReferences: 1, releasedLogicalBytes: 10 };
},
collectGarbage: async (input) => {
contextActions.push(`collect:${input.maxBlobs}`);
garbageBatches += 1;
return { deletedBlobs: 1, deletedBytes: 10, hasMore: garbageBatches < 3 };
},
},
purgeOperationalState: async () => {},
},
'session-context',
);

assert.deepEqual(contextActions, [
'retire:session-context',
'collect:64',
'collect:64',
'collect:64',
]);
});

test('rejects ordinary archive and remove operations for the Coordination Session', async () => {
await withHarness(async (harness) => {
const created = await harness.store.createStableSession({
Expand Down Expand Up @@ -239,7 +271,7 @@ describe('Host Session retirement coordinator', () => {
'parent retirement cleanup did not converge',
);
assert.deepEqual(new Set(harness.actions.purgedArtifacts), new Set(harness.familyIds));
assert.deepEqual(new Set(harness.actions.checkedContext), new Set(harness.familyIds));
assert.deepEqual(new Set(harness.actions.retiredContext), new Set(harness.familyIds));
});
});

Expand Down Expand Up @@ -1027,7 +1059,7 @@ interface RetirementActions {
readonly retiredCapabilities: string[];
readonly retiredMessages: string[];
readonly purgedArtifacts: string[];
readonly checkedContext: string[];
readonly retiredContext: string[];
readonly purgedTasks: string[];
readonly purgedOperationalState: string[];
readonly purgedAgentGraphs: string[];
Expand Down Expand Up @@ -1066,7 +1098,7 @@ async function withHarness(
retiredCapabilities: [],
retiredMessages: [],
purgedArtifacts: [],
checkedContext: [],
retiredContext: [],
purgedTasks: [],
purgedOperationalState: [],
purgedAgentGraphs: [],
Expand Down Expand Up @@ -1232,8 +1264,12 @@ async function withHarness(
actions.purgedTasks.push(sessionId);
},
},
assertNoContextOffloadReferences: async (sessionIds) => {
actions.checkedContext.push(...sessionIds);
contextOffload: {
retireSession: async (sessionId) => {
actions.retiredContext.push(sessionId);
return { releasedReferences: 0, releasedLogicalBytes: 0 };
},
collectGarbage: async () => ({ deletedBlobs: 0, deletedBytes: 0, hasMore: false }),
},
purgeOperationalState: async (sessionId) => {
actions.purgedOperationalState.push(sessionId);
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 93 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 94 as const;
// 94: Read image tool results may carry durable `session_context` refs.
// 93: Configuration credential transfer binds proxy destinations and
// Connection credentials to exact Host-owned targets before secret access.
// Proxy policy and credentials commit through one recoverable Host command;
Expand Down
86 changes: 56 additions & 30 deletions packages/runtime-host/src/server/execution-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,9 @@ import {
import { type MakaTool } from '@maka/runtime/tool-runtime';
import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority';
import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store';
import {
createArtifactAttachmentResourceReader,
createReadImageSnapshotter,
} from '@maka/storage/artifact-stores';
import {
isSessionNotFoundError,
SessionMetadataConflictError,
} from '@maka/storage/execution-stores';
import { createArtifactAttachmentResourceReader } from '@maka/storage/artifact-stores';
import { createReadImageSnapshotStore } from '@maka/storage/read-image-snapshot-store';
import { isSessionNotFoundError } from '@maka/storage/execution-stores';
import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions';
import { createGitWorktreeChildExecutor } from '@maka/storage/git-worktree-child-executor';
import { runWithStorageRootLease } from '@maka/storage/root-authority';
Expand Down Expand Up @@ -208,15 +203,16 @@ export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition
readonly plugins: HostPluginPlatform;
}

const CONTEXT_OFFLOAD_READER_LIMITS: ContextOffloadLimits = Object.freeze({
const GIBIBYTE = 1024 * 1024 * 1024;
const CONTEXT_OFFLOAD_LIMITS: ContextOffloadLimits = Object.freeze({
ownerMaxBytes: Object.freeze({
read_image_snapshot: MAX_READ_IMAGE_BYTES,
tool_result_archive: 0,
}),
// This expand slice opens only the reader path. Zero quotas make accidental
// non-empty puts fail closed until the writer/lifecycle cutover lands.
sessionLogicalBytes: 0,
workspacePhysicalBytes: 0,
// Read images are bounded individually and logically per Session. Physical
// bytes are content-addressed across Sessions and bounded per workspace.
sessionLogicalBytes: GIBIBYTE,
Comment thread
likun666661 marked this conversation as resolved.
workspacePhysicalBytes: 20 * GIBIBYTE,
});

export interface CreateExecutionRuntimeHostCompositionOptions {
Expand Down Expand Up @@ -245,7 +241,7 @@ export async function createExecutionRuntimeHostComposition(
dependencies: ExecutionRuntimeHostCompositionDependencies = {},
): Promise<ExecutionRuntimeHostComposition> {
const storage = await openStorageWriterComposition(context.owner.lease, {
contextOffloadLimits: CONTEXT_OFFLOAD_READER_LIMITS,
contextOffloadLimits: CONTEXT_OFFLOAD_LIMITS,
afterRuntimePolicyOpened: async (stores) => {
if (options.bootstrapRuntimePolicy !== false) {
await ensureBootstrapRuntimePolicy({
Expand All @@ -261,7 +257,7 @@ export async function createExecutionRuntimeHostComposition(
});
if (storage.contextOffloadUnavailable) {
console.error(
`[runtime-host] optional context-offload reader could not be opened: ${generalizedErrorMessage(storage.contextOffloadUnavailable.cause)}`,
`[runtime-host] optional context-offload Store could not be opened: ${generalizedErrorMessage(storage.contextOffloadUnavailable.cause)}`,
);
}
const stores = storage.execution;
Expand Down Expand Up @@ -294,6 +290,28 @@ export async function createExecutionRuntimeHostComposition(
const openedContextOffloadReader = openedContextOffloadStore
? createInteractiveContextOffloadReader(openedContextOffloadStore)
: undefined;
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> => {
Comment thread
likun666661 marked this conversation as resolved.
throw new Error('Context-offload Store is unavailable during Session retirement', {
cause: storage.contextOffloadUnavailable?.cause,
});
},
collectGarbage: async (): Promise<never> => {
throw new Error(
'Context-offload Store is unavailable during context garbage collection',
{ cause: storage.contextOffloadUnavailable?.cause },
);
},
}
: undefined;
const openedUsageStores = storage.usage;
const openedShellRunStore = storage.shellRuns;
const worktreeChildExecutor = createGitWorktreeChildExecutor({
Expand Down Expand Up @@ -403,7 +421,27 @@ export async function createExecutionRuntimeHostComposition(
}),
backgroundTasks: runtimeResources,
ptyControls: runtimeResources,
snapshotImage: createReadImageSnapshotter(openedArtifactStore),
...(openedContextOffloadStore
? {
snapshotImage: async (input: {
readonly sessionId: string;
readonly ownerId: string;
readonly bytes: Uint8Array;
readonly mimeType: string;
}) =>
createReadImageSnapshotStore(openedContextOffloadStore, input.sessionId).snapshot({
Comment thread
likun666661 marked this conversation as resolved.
ownerId: input.ownerId,
bytes: input.bytes,
mimeType: input.mimeType,
}),
releaseImageSnapshot: async (input: {
readonly sessionId: string;
readonly refId: string;
}) => {
await openedContextOffloadStore.releaseReference(input);
},
}
: {}),
...(sandboxManager ? { sandboxManager } : {}),
...(filesystemWorker ? { filesystemWorker } : {}),
};
Expand Down Expand Up @@ -1548,6 +1586,7 @@ export async function createExecutionRuntimeHostComposition(
stores,
artifacts: openedArtifactStore,
sessionTodo: sessionTodoStore,
...(contextOffloadAuthority ? { contextOffload: contextOffloadAuthority } : {}),
manager,
admission: sessionAdmission,
continuity: continuityCoordinator,
Expand All @@ -1572,20 +1611,7 @@ export async function createExecutionRuntimeHostComposition(
continuity: continuityCoordinator,
artifacts: openedArtifactStore,
sessionTodo: sessionTodoStore,
assertNoContextOffloadReferences: async (sessionIds) => {
if (!openedContextOffloadStore) {
throw new Error('Context-offload reader is unavailable during Session removal', {
cause: storage.contextOffloadUnavailable?.cause,
});
}
for (const sessionId of sessionIds) {
if ((await openedContextOffloadStore.usage(sessionId)).references > 0) {
throw new SessionMetadataConflictError(
'Session removal does not support Session context references yet',
);
}
}
},
...(contextOffloadAuthority ? { contextOffload: contextOffloadAuthority } : {}),
purgeOperationalState: async (sessionId) => {
await stores.purgeConversationOperationalState(sessionId);
await openedPlanStore.purgeSessionState(sessionId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from '@maka/storage/execution-stores';
import { type SessionManager } from '@maka/runtime/session-manager';
import type { InteractiveSessionTodoWriter } from '@maka/storage/session-todo-authority';
import type { InteractiveContextOffloadWriter } from '@maka/storage/context-offload-store';
import {
type OperationOutcome,
type SessionCatalogItem,
Expand Down Expand Up @@ -122,7 +123,10 @@ export interface HostSessionRetirementCoordinatorOptions {
readonly continuity: RetirementContinuity;
readonly artifacts: Pick<InteractiveArtifactStoreWriter, 'purgeSessionArtifacts'>;
readonly sessionTodo: Pick<InteractiveSessionTodoWriter, 'purgeSessionState'>;
readonly assertNoContextOffloadReferences?: (sessionIds: readonly string[]) => Promise<void>;
readonly contextOffload?: Pick<
InteractiveContextOffloadWriter,
'retireSession' | 'collectGarbage'
>;
readonly purgeOperationalState: (sessionId: string) => Promise<void>;
readonly purgeAgentGraphState: (sessionId: string) => Promise<void>;
readonly worktrees?: Pick<SubagentWorktreeExecutor, 'retire'>;
Expand Down Expand Up @@ -194,7 +198,7 @@ export class HostSessionRetirementCoordinator {
readonly #continuity: RetirementContinuity;
readonly #artifacts: HostSessionRetirementCoordinatorOptions['artifacts'];
readonly #sessionTodo: HostSessionRetirementCoordinatorOptions['sessionTodo'];
readonly #assertNoContextOffloadReferences: HostSessionRetirementCoordinatorOptions['assertNoContextOffloadReferences'];
readonly #contextOffload: HostSessionRetirementCoordinatorOptions['contextOffload'];
readonly #purgeOperationalState: HostSessionRetirementCoordinatorOptions['purgeOperationalState'];
readonly #purgeAgentGraphState: HostSessionRetirementCoordinatorOptions['purgeAgentGraphState'];
readonly #worktrees: HostSessionRetirementCoordinatorOptions['worktrees'];
Expand Down Expand Up @@ -222,7 +226,7 @@ export class HostSessionRetirementCoordinator {
this.#continuity = options.continuity;
this.#artifacts = options.artifacts;
this.#sessionTodo = options.sessionTodo;
this.#assertNoContextOffloadReferences = options.assertNoContextOffloadReferences;
this.#contextOffload = options.contextOffload;
this.#purgeOperationalState = options.purgeOperationalState;
this.#purgeAgentGraphState = options.purgeAgentGraphState;
this.#worktrees = options.worktrees;
Expand Down Expand Up @@ -338,7 +342,6 @@ export class HostSessionRetirementCoordinator {
if (plan.archive.sessionIds.length > 0) {
archiveHandles = await this.#prepareRetirement(plan.archive, 'archive');
}
await this.#assertNoContextOffloadReferences?.(plan.remove.sessionIds);
const allSessionIds = [...plan.remove.sessionIds, ...plan.archive.sessionIds];
await this.#finalizeWorkspacePatches(allSessionIds);
await this.#disposeBackends(allSessionIds);
Expand Down Expand Up @@ -725,6 +728,7 @@ export class HostSessionRetirementCoordinator {
{
artifacts: this.#artifacts,
sessionTodo: this.#sessionTodo,
...(this.#contextOffload ? { contextOffload: this.#contextOffload } : {}),
purgeOperationalState: this.#purgeOperationalState,
},
sessionId,
Expand Down
Loading