Skip to content
Merged
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 @@ -50,6 +50,9 @@
- 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.
- Let the provider decide whether a request fits, and anchored the estimate that decides when to compact on the last request the provider actually counted. `token_usage` records now persist that anchor under a new `lastRequestAnchor` key. **Sessions this build writes do not open in earlier releases:** those decode `token_usage` against a closed allowlist, so the unknown key fails the record and, with it, the Session that contains it. Downgrading therefore needs a copy of the workspace's `runtime.sqlite` taken before the upgrade. Retired with the local verdict: nothing produces the `context_budget_exhausted` stop reason any more — a request that really is too large is compacted and retried once, then reported as a `context_overflow` provider error — though sessions that already recorded it still decode and present. The Runtime Host compatibility epoch moves to 94.
- 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 > 95);
});

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 = 95 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 96 as const;
// 96: Read image tool results may carry durable `session_context` refs.
// 95: Catalog entries carry `describedByMetadata`, so a client asks the
// Host-resolved entry — not its own bundled table — whether a model needs a
// hand-written capability declaration. The field is required, so a newer Host's
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 @@ -74,14 +74,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 @@ -205,15 +200,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 @@ -242,7 +238,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 @@ -258,7 +254,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 @@ -291,6 +287,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 @@ -400,7 +418,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 @@ -1545,6 +1583,7 @@ export async function createExecutionRuntimeHostComposition(
stores,
artifacts: openedArtifactStore,
sessionTodo: sessionTodoStore,
...(contextOffloadAuthority ? { contextOffload: contextOffloadAuthority } : {}),
manager,
admission: sessionAdmission,
continuity: continuityCoordinator,
Expand All @@ -1569,20 +1608,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
Loading