Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
975b9de
feat(runtime): give a turn's system notes a RuntimeEvent of their own
Astro-Han Sep 5, 2026
a1b5896
feat(runtime): convert every legacy transcript row instead of droppin…
Astro-Han Sep 5, 2026
64fa312
feat(runtime): make the transcript conversion whole and resumable
Astro-Han Sep 5, 2026
fa058d9
refactor(runtime): read a running turn from its own ledger
Astro-Han Sep 5, 2026
88228a9
refactor(runtime): move runtime notes onto the ledger and retire the …
Astro-Han Sep 5, 2026
08cbf54
refactor(runtime): make the RuntimeEvent ledger the only transcript a…
Astro-Han Sep 5, 2026
aadef6e
fix(runtime): derive an admitted prompt's id once, and never seal a r…
Astro-Han Sep 6, 2026
7b0451c
fix(runtime): owe a run's prompt from before its invocation opens
Astro-Han Sep 6, 2026
3541457
fix(runtime): resume transcript migration safely after interruption
M4n5ter Sep 6, 2026
a5544ad
fix(storage): seek transcript pages before projecting ledger messages
M4n5ter Sep 6, 2026
f92f76a
fix(runtime): resume a conversion a released build left part-written
Astro-Han Sep 6, 2026
8c29d2d
fix(runtime): convert a legacy transcript a page at a time
Astro-Han Sep 6, 2026
8041671
refactor(runtime): make the read model the only transcript definition
Astro-Han Sep 7, 2026
a32e71a
fix(runtime): keep the compaction notes on the ledger after the rebase
Astro-Han Sep 7, 2026
69e110e
fix(runtime): resume a released conversion by the rows it already con…
Astro-Han Sep 7, 2026
09f0bf2
test(runtime): guard the absence this cutover is named for
Astro-Han Sep 7, 2026
1491fee
test(desktop): read a Turn's retained-output flag from its recorded s…
Astro-Han Sep 7, 2026
fe4e366
fix(storage): reassemble chunked records in the paged transcript scan
Astro-Han Sep 7, 2026
d45be86
refactor(runtime-host): drop the durable-coverage claim from transcri…
Astro-Han Sep 7, 2026
ad351d2
fix(desktop): ask for one older row instead of assuming a transcript …
Astro-Han Sep 7, 2026
0d886e9
test(desktop): scroll until the transcript range moves
Astro-Han Sep 7, 2026
4cddf6f
fix(runtime-host): read the WorkHub Coordination transcript from its …
Astro-Han Sep 7, 2026
86f1d76
fix(runtime): bound a transcript Turn before reading it and keep the …
Astro-Han Sep 7, 2026
41ae92c
fix(storage): keep a replayed projection from moving the catalog prev…
Astro-Han Sep 7, 2026
1b79c6d
Merge branch 'main' into refactor/4791-single-transcript-authority
Astro-Han Sep 7, 2026
957c269
fix(runtime): mint a converted Session's ordinals in the order its tu…
Astro-Han Sep 7, 2026
16f70e8
perf(storage): page a transcript off the ordinal index
Astro-Han Sep 7, 2026
a1db59a
Merge origin/main into refactor/4791-single-transcript-authority
Astro-Han Sep 7, 2026
94c24b0
perf(storage): materialize the ordinal renumber instead of recomputin…
Astro-Han Sep 7, 2026
3e8aad4
perf(storage): page the transcript forward off the ordinal index
Astro-Han Sep 7, 2026
8e20e6d
refactor(runtime): drop the retained-output fact nothing reads
Astro-Han Sep 7, 2026
3a4371a
fix(core): keep retired keys readable where the shape is exact
Astro-Han Sep 7, 2026
e2f304f
fix(runtime-host): page overlapping Turns in sequence order
Astro-Han Sep 7, 2026
f09c677
Merge origin/main into refactor/4791-single-transcript-authority
Astro-Han Sep 7, 2026
4e670b7
fix(storage): keep an undecodable payload from wedging the ledger schema
Astro-Han Sep 7, 2026
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
12 changes: 1 addition & 11 deletions apps/desktop/src/main/__tests__/runtime-host-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,6 @@ test('derives turn records from bounded contribution pages', async () => {
firstSequence: 0,
latestState: null,
userPromptPreview: 'hello',
hasAssistantMessage: true,
hasAssistantOutput: true,
hasToolResult: false,
hasFailedToolResult: false,
hasAbortNote: false,
}],
nextPosition: 2,
};
Expand All @@ -105,11 +100,6 @@ test('derives turn records from bounded contribution pages', async () => {
},
},
userPromptPreview: null,
hasAssistantMessage: false,
hasAssistantOutput: false,
hasToolResult: true,
hasFailedToolResult: false,
hasAbortNote: false,
}],
nextPosition: null,
};
Expand All @@ -124,7 +114,7 @@ test('derives turn records from bounded contribution pages', async () => {
userPromptPreview: 'hello',
status: 'completed',
statusSource: 'recorded',
partialOutputRetained: true,
partialOutputRetained: false,
}]);
assert.deepEqual(positions, [0, 2]);
await client.close();
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1648,7 +1648,7 @@ export class DesktopRuntimeHostClient {
}
return [...contributions.values()]
.sort((left, right) => left.firstSequence - right.firstSequence)
.map(projectSessionTurnContribution);
.flatMap((contribution) => projectSessionTurnContribution(contribution) ?? []);
}

async listSessionTurnLandmarks(
Expand Down
15 changes: 4 additions & 11 deletions packages/cli/src/pi-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
} from '@maka/core/events';
import {
deriveTurnRecords,
isRuntimeSystemNoteKind,
STEP_LIMIT_NOTICE_TEXT,
type StoredMessage,
type SystemNoteMessage,
Expand Down Expand Up @@ -1344,14 +1345,10 @@ function tokenDelta(before: number | undefined, after: number | undefined): numb
}

function systemNoteText(message: SystemNoteMessage): string | undefined {
// Retired kinds are still decoded off legacy transcript rows, and none of
// them ever had a line here worth reading.
if (!isRuntimeSystemNoteKind(message.kind)) return undefined;
switch (message.kind) {
case 'session_start':
case 'session_resume':
return undefined;
case 'mode_change':
return 'Permission mode changed.';
case 'model_change':
return 'Model changed.';
case 'context_compacted':
return 'Context compacted to keep this task within the model window.';
case 'context_compaction_failed_open':
Expand Down Expand Up @@ -1408,10 +1405,6 @@ function systemNoteText(message: SystemNoteMessage): string | undefined {
}
case 'step_limit':
return STEP_LIMIT_NOTICE_TEXT;
case 'error':
return 'Session recorded an error.';
case 'abort':
return 'Session was stopped.';
}
}

Expand Down
46 changes: 43 additions & 3 deletions packages/core/src/runtime-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ import {
type OrchestrationMode,
} from './orchestration.js';
import { isToolMode, type ToolMode } from './tool-mode.js';
import type { PersistedBackendKind } from './session.js';
import {
isRuntimeSystemNoteKind,
type PersistedBackendKind,
type RuntimeSystemNoteKind,
} from './session.js';
import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js';
import type { UserQuestionRequest } from './user-question.js';
import {
Expand Down Expand Up @@ -217,6 +221,22 @@ export interface RuntimeEventFunctionResponseContent {
modelProjection?: DurableToolResultProjection;
}

/**
* A note the runtime wrote about what happened during an invocation — context
* was compacted, the step cap was reached, the turn was aborted.
*
* It is a transcript row, not a model-facing payload: nothing replays it to a
* provider. It lives here because it is a fact of the invocation, and the
* invocation's events are the only record of those. Notes that happen between
* turns have no invocation, so they stay Session transcript rows.
*/
export interface RuntimeEventSystemNoteContent {
kind: 'system_note';
note: RuntimeSystemNoteKind;
/** Shape depends on `note`, exactly as it does on the transcript row. */
data?: unknown;
}

export interface RuntimeEventErrorContent {
kind: 'error';
code?: string;
Expand Down Expand Up @@ -337,6 +357,7 @@ export type RuntimeEventContent =
| RuntimeEventFunctionCallContent
| RuntimeEventFunctionResponseContent
| RuntimeEventErrorContent
| RuntimeEventSystemNoteContent
| RuntimeEventInvocationOpenedContent;

export const RUNTIME_EVENT_CONTENT_KINDS = [
Expand All @@ -345,6 +366,7 @@ export const RUNTIME_EVENT_CONTENT_KINDS = [
'function_call',
'function_response',
'error',
'system_note',
'invocation_opened',
] as const;
export type RuntimeEventContentKind = (typeof RUNTIME_EVENT_CONTENT_KINDS)[number];
Expand All @@ -365,6 +387,12 @@ export interface RuntimeEventTokenUsage extends TokenUsageFields {}
*/
export interface RuntimeEventPermissionDecision extends PermissionResponse {
toolName?: string;
/**
* What the prompt told the user they were approving. Normally read off the
* paired request; carried here when the decision is the only surviving
* evidence that the prompt happened.
*/
hint?: string;
}

export const TOOL_BOUNDARY_PROTOCOL_V1 = 't1_after_preflight_v1' as const;
Expand Down Expand Up @@ -696,6 +724,10 @@ const ERROR_CONTENT_SHAPE = defineObjectShape<RuntimeEventErrorContent>()(
['kind', 'message'],
['code', 'reason', 'details'],
);
const SYSTEM_NOTE_CONTENT_SHAPE = defineObjectShape<RuntimeEventSystemNoteContent>()(
['kind', 'note'],
['data'],
);
const INVOCATION_OPENED_CONTENT_SHAPE = defineObjectShape<RuntimeEventInvocationOpenedContent>()(
['kind', 'protocol', 'route', 'configuration', 'root', 'source'],
['lineage'],
Expand Down Expand Up @@ -803,7 +835,7 @@ const PERMISSION_CLOSURE_ACCEPTED_SHAPE =
defineObjectShape<RuntimeEventPermissionClosureAccepted>()(['requestId', 'reason'], []);
const RUNTIME_PERMISSION_DECISION_SHAPE = defineObjectShape<RuntimeEventPermissionDecision>()(
['requestId', 'decision'],
['rememberForTurn', 'reviewer', 'rationale', 'riskLevel', 'toolName'],
['rememberForTurn', 'reviewer', 'rationale', 'riskLevel', 'toolName', 'hint'],
);
const UTF8 = new TextEncoder();
const RUNTIME_TOOL_DISPATCH_SHAPE = defineObjectShape<RuntimeEventToolDispatch>()(
Expand Down Expand Up @@ -1022,6 +1054,12 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent {
typeof value.message === 'string' &&
(value.details === undefined || isStringArray(value.details) || isRecord(value.details))
);
case 'system_note':
return (
hasExactShape(value, SYSTEM_NOTE_CONTENT_SHAPE) &&
typeof value.note === 'string' &&
isRuntimeSystemNoteKind(value.note)
);
case 'invocation_opened':
return isRuntimeInvocationOpened(value);
default:
Expand Down Expand Up @@ -1266,7 +1304,8 @@ function isRuntimeEventPermissionDecision(value: unknown): value is RuntimeEvent
(value.toolName === undefined ||
(typeof value.toolName === 'string' &&
value.toolName.length > 0 &&
UTF8.encode(value.toolName).byteLength <= INTERACTION_TOOL_NAME_MAX_BYTES))
UTF8.encode(value.toolName).byteLength <= INTERACTION_TOOL_NAME_MAX_BYTES)) &&
isOptionalString(value.hint)
);
}

Expand Down Expand Up @@ -1485,6 +1524,7 @@ export function runtimeEventHasModelVisibleContent(event: RuntimeEvent): boolean
case 'function_response':
return true;
case 'error':
case 'system_note':
case 'invocation_opened':
return false;
}
Expand Down
92 changes: 47 additions & 45 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export interface SessionHeader {
/** Immutable Connection entity identity. Optional only on legacy Session records. */
llmConnectionId?: string;
llmConnectionSlug: string;
/** True after first UserMessage is flushed. Storage self-heals (§5.2). */
/** True once the Session's first UserMessage is durable. One-way. */
connectionLocked: boolean;
/** Sticky session default model id, captured when the session is created. */
model: string;
Expand Down Expand Up @@ -789,20 +789,12 @@ export function userFacingText(message: Pick<UserMessage, 'text' | 'displayText'
return message.displayText ?? message.text;
}

const USER_VISIBLE_SESSION_SYSTEM_NOTES = new Set([
'context_compacted',
'context_compaction_failed_open',
'context_provider_dropping',
'context_window_suggestion',
'context_window_overrun',
'context_reported_window_exceeded',
'context_overflow_after_compaction',
'step_limit',
]);

/** Closed policy for system notes that are part of the user-visible transcript. */
/**
* Closed policy for system notes that are part of the user-visible transcript:
* exactly the notes the runtime writes.
*/
export function isUserVisibleSessionSystemNote(kind: string): boolean {
return USER_VISIBLE_SESSION_SYSTEM_NOTES.has(kind);
return isRuntimeSystemNoteKind(kind);
}

export interface AssistantMessage {
Expand Down Expand Up @@ -1147,27 +1139,49 @@ export interface TurnRecord {
partialOutputRetained: boolean;
}

/**
* The notes the runtime writes: things that happened inside one invocation and
* are part of what that invocation did. Their record is its RuntimeEvent ledger.
*/
export const RUNTIME_SYSTEM_NOTE_KINDS = [
'context_compacted',
'context_compaction_failed_open',
'context_provider_dropping',
'context_window_suggestion',
'context_window_overrun',
'context_reported_window_exceeded',
'context_overflow_after_compaction',
'step_limit',
] as const;

/**
* Notes only legacy transcripts carry, still decoded so those rows stay
* readable. Nothing writes them: the Session header and the invocation's
* opening and terminal facts already own what each of them said.
*/
export const RETIRED_SYSTEM_NOTE_KINDS = [
'session_start',
'session_resume',
'mode_change',
'model_change',
'error',
'abort',
] as const;

export type RuntimeSystemNoteKind = (typeof RUNTIME_SYSTEM_NOTE_KINDS)[number];
export type SystemNoteKind = RuntimeSystemNoteKind | (typeof RETIRED_SYSTEM_NOTE_KINDS)[number];

export function isRuntimeSystemNoteKind(kind: string): kind is RuntimeSystemNoteKind {
return (RUNTIME_SYSTEM_NOTE_KINDS as readonly string[]).includes(kind);
}

export interface SystemNoteMessage {
type: 'system_note';
id: string;
/** Session-level notes omit turnId. */
/** Retired session-level notes omit turnId. */
turnId?: string;
ts: number;
kind:
| 'session_start'
| 'session_resume'
| 'mode_change'
| 'model_change'
| 'context_compacted'
| 'context_compaction_failed_open'
| 'context_provider_dropping'
| 'context_window_suggestion'
| 'context_window_overrun'
| 'context_reported_window_exceeded'
| 'context_overflow_after_compaction'
| 'step_limit'
| 'error'
| 'abort';
kind: SystemNoteKind;
/**
* Shape depends on `kind`. `context_compaction_failed_open` carries
* `{ failOpenReason?: string }` — the reason the fold was refused (e.g.
Expand Down Expand Up @@ -1408,21 +1422,9 @@ const ASSISTANT_THINKING_SHAPE = defineObjectShape<AssistantThinking>()(
['text'],
['signature', 'providerOptions', 'parts'],
);
const SYSTEM_NOTE_KINDS = new Set([
'session_start',
'session_resume',
'mode_change',
'model_change',
'context_compacted',
'context_compaction_failed_open',
'context_provider_dropping',
'context_window_suggestion',
'context_window_overrun',
'context_reported_window_exceeded',
'context_overflow_after_compaction',
'step_limit',
'error',
'abort',
const SYSTEM_NOTE_KINDS = new Set<string>([
...RUNTIME_SYSTEM_NOTE_KINDS,
...RETIRED_SYSTEM_NOTE_KINDS,
]);

export function decodeCanonicalMessage(value: unknown): StoredMessage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ test('cancels managed approval owners and joiners with the canonical provider id
header: sessionHeader(),
connection: llmConnection(),
modelId: 'model-1',
appendMessage: async () => undefined,
readExecutionBoundary: async () =>
createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0),
newId: nextId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
stopReplacedWorkHubRoot,
} from '../server/execution-composition.js';
import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';
import { readLedgerMessages } from './fixtures/ledger-transcript.js';

const require = createRequire(import.meta.url);
const FAKE_CONNECTION_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
Expand Down Expand Up @@ -462,6 +463,9 @@ test('production recovery preserves legacy Automation history and closes an orph
const composition = await createExecutionRuntimeHostComposition(compositionContext(owner));
try {
await composition.recover();
// The legacy transcript itself, as the converter reads it: recovery must
// leave a pre-ledger Automation's origin intact for the import that
// follows on the Session's first read.
const history = await stores.sessionStore.readMessages(historical.id);
assert.deepEqual(history[0]?.type === 'user' ? history[0].origin : undefined, {
kind: 'legacy_automation',
Expand Down Expand Up @@ -2019,7 +2023,7 @@ async function assertUniqueGraphExecutionFacts(
): Promise<void> {
const [runs, messages, runtimeEvents] = await Promise.all([
stores.runtimeEventStore.listSessionInvocations(claim.targetSessionId),
stores.sessionStore.readMessages(claim.targetSessionId),
readLedgerMessages(stores.runtimeEventStore, claim.targetSessionId),
stores.runtimeEventStore.readImmutableRuntimeEvents(claim.targetSessionId, claim.targetRunId),
]);
assert.deepEqual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
} from '../protocol/index.js';
import { SessionAdmissionGate } from '../server/session-admission-gate.js';
import { FramedTransport } from '../transport/framed-transport.js';
import { readLedgerMessages } from './fixtures/ledger-transcript.js';

import {
CONNECTION_EFFECT_MODEL_IDS,
Expand Down Expand Up @@ -773,7 +774,7 @@ test('startup recovery canonically closes pending linked child admissions withou
assert.equal(terminal.fact.failureClass, 'app_restarted');
}
const userMessages: StoredMessage[] = (
await stores.sessionStore.readMessages(recovered.sessionId)
await readLedgerMessages(stores.runtimeEventStore, recovered.sessionId)
).filter((message) => message.type === 'user' && message.turnId === recovered.turnId);
assert.equal(userMessages.length, recovered.kind === 'linked_child_provider_retry' ? 0 : 1);
if (recovered.kind !== 'linked_child_provider_retry') {
Expand Down
Loading
Loading