Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const FLEET_SHELL_BUDGET = {
// About 14% above the observed 75 KB. The window holds the working run only: 2 nodes, 2 subagents,
// 1 plan, 1 provider turn and the last finished turn's checkpoint. When finished runs ride along
// it holds 82 nodes, 82 subagents, 41 plans, 41 provider turns, 40 checkpoints and 740 KB.
// The working run's ready handoff ships without its 256 KB history; with it the open is 339 KB.
const STALE_TODO_BUDGET = {
decodedBytes: 85_000,
rows: 75,
Expand Down
47 changes: 46 additions & 1 deletion apps/server/integration/TransferBudgetV2Fixture.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CheckpointId,
CheckpointRef,
CheckpointScopeId,
ContextHandoffId,
EventId,
MessageId,
NodeId,
Expand Down Expand Up @@ -446,6 +447,7 @@ export const STALE_TODO_THREAD_ID = ThreadId.make("transfer-budget-stale-todo-li
export const STALE_TODO_SETTLED_RUNS = 40;
export const STALE_TODO_SUBAGENTS_PER_RUN = 2;
export const STALE_TODO_LIVE_ROWS = 600;
const STALE_TODO_HANDOFF_HISTORY_BYTES = 256 * 1024;
const staleTodoCheckpointId = (index: number) =>
CheckpointId.make(`checkpoint:thread:${STALE_TODO_THREAD_ID}:ordinal:${index + 1}`);
/** The checkpoint clients read the last finished turn's diff from. */
Expand Down Expand Up @@ -742,7 +744,50 @@ export function staleTodoListThreadBatches(
exitCode: 0,
},
});
if (live) return events;
if (live) {
// A ready handoff rides along with every window. Its history and delivery are for the
// provider turn start and must not reach a client.
const earlierItemId = TurnItemId.make(`turn-item:stale-todo:${index}:handoff-source`);
events.push({
...common,
id: EventId.make(`stale-todo-handoff-${index}`),
type: "context-handoff.updated",
payload: {
id: ContextHandoffId.make(`handoff:run:${runKey}`),
transferId: null,
threadId,
targetRunId: runId,
fromProviderThreadIds: [providerThreadId],
toProviderThreadId: providerThreadId,
coveredRunOrdinals: { from: 1, to: index },
strategy: "full_thread_summary",
status: "ready",
summaryMessageId: null,
summaryText: text(index, 6, 1_024),
history: {
messages: [
{
role: "user",
text: text(index, 7, STALE_TODO_HANDOFF_HISTORY_BYTES),
threadId,
runId: null,
itemId: earlierItemId,
providerThreadId,
status: "completed",
kind: "user_message",
},
],
coverage: "full",
omittedItems: 0,
},
delivery: { nativeThreadId: "native", status: "injected", itemIds: [earlierItemId] },
createdByProviderInstanceId: providerInstanceId,
createdAt: now,
updatedAt: now,
},
});
return events;
}

message("assistant", text(index, 5, 1_200));
const checkpointId = staleTodoCheckpointId(index);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/integration/transferBudgetV2.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () =>
staleTodo.subagents.filter((subagent) => subagent.runId === STALE_TODO_LIVE_RUN_ID),
STALE_TODO_SUBAGENTS_PER_RUN,
);
// The byte cap below also passes when the window loses the handoff.
assert.lengthOf(staleTodo.contextHandoffs, 1);
const staleTodoColdOpen = {
http: staleTodoHttp,
rows: staleTodo.visibleTurnItems.length,
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,10 @@ export interface OrchestratorV2Shape {
options: ProjectionTimelinePageOptions,
) => Effect.Effect<ProjectionTimelinePage, OrchestratorProjectionError>;
readonly getMessageCount: (threadId: ThreadId) => Effect.Effect<number, OrchestratorV2Error>;
readonly getTranscriptByteFloor: (
threadId: ThreadId,
limit: number,
) => Effect.Effect<number, OrchestratorV2Error>;
readonly getTurnItem: (input: {
readonly threadId: ThreadId;
readonly itemId: TurnItemId;
Expand Down Expand Up @@ -15418,6 +15422,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
.getMessageCount(threadId)
.pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })));
}),
getTranscriptByteFloor: Effect.fn("OrchestratorV2.getTranscriptByteFloor")(
function (threadId, limit) {
return projectionStore
.getTranscriptByteFloor(threadId, limit)
.pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })));
},
),
getTurnItem: Effect.fn("OrchestratorV2.getTurnItem")(function (input) {
return projectionStore
.getTurnItem(input)
Expand Down
138 changes: 138 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ContextTransferId,
CheckpointRef,
CheckpointScopeId,
ContextHandoffId,
MessageId,
type ModelSelection,
type OrchestrationV2ProviderThread,
Expand Down Expand Up @@ -55,6 +56,7 @@ import {
THREAD_HISTORY_PAGE_POLICY,
THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT,
} from "./threadHistoryPaging.ts";
import { buildBoundedThreadStreamSnapshot } from "./ThreadStream.ts";
import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";

import { layerReaderDatabase, traceSqlRouting } from "../persistence/testkit/SqliteRouting.ts";
Expand Down Expand Up @@ -2594,6 +2596,142 @@ it.effect("SQL snapshot windows keep cards for requests still pending", () =>
}).pipe(Effect.provide(layerTest)),
);

it.effect("SQL snapshot windows leave handoff bodies in SQLite without changing the wire", () =>
Effect.gen(function* () {
const store = yield* ProjectionStore.ProjectionStoreV2;
const now = yield* DateTime.now;
const threadId = ThreadId.make("thread:handoff-window");
yield* store.apply({
id: EventId.make("event:handoff-window:thread"),
type: "thread.created",
threadId,
occurredAt: now,
payload: {
createdBy: "user",
creationSource: "web",
id: threadId,
projectId: ProjectId.make("project:handoff-window"),
title: "Handoff window",
providerInstanceId,
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
activeProviderThreadId: null,
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
forkedFrom: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
settledOverride: null,
settledAt: null,
lastVisitedAt: null,
deletedAt: null,
},
});
const providerThreadId = ProviderThreadId.make("provider-thread:handoff-window");
const earlierItemId = TurnItemId.make("item:handoff-window:earlier");
const handoff = (id: string, status: "ready" | "superseded") => ({
id: ContextHandoffId.make(`handoff:handoff-window:${id}`),
transferId: null,
threadId,
targetRunId: RunId.make("run:handoff-window"),
fromProviderThreadIds: [providerThreadId],
toProviderThreadId: providerThreadId,
coveredRunOrdinals: { from: 1, to: 1 },
strategy: "full_thread_summary" as const,
status,
summaryMessageId: null,
summaryText: `Summary ${id}`,
history: {
messages: [
{
role: "user" as const,
text: "Earlier request ".repeat(1_000),
threadId,
runId: null,
itemId: earlierItemId,
providerThreadId,
status: "completed",
kind: "user_message",
},
],
coverage: "full",
omittedItems: 0,
},
delivery: { nativeThreadId: "native", status: "injected" as const, itemIds: [earlierItemId] },
createdByProviderInstanceId: providerInstanceId,
createdAt: now,
updatedAt: now,
});
// The window keeps a ready handoff and one its rows show; the third is outside it.
const handoffs = [
handoff("ready", "ready"),
handoff("shown", "superseded"),
handoff("old", "superseded"),
];
for (const payload of handoffs) {
yield* store.apply({
id: EventId.make(`event:${payload.id}`),
type: "context-handoff.updated",
threadId,
occurredAt: now,
payload,
});
}
yield* store.apply({
id: EventId.make("event:handoff-window:item"),
type: "turn-item.updated",
threadId,
occurredAt: now,
payload: {
id: TurnItemId.make("item:handoff-window:handoff"),
threadId,
runId: null,
nodeId: null,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal: 1,
status: "completed",
title: null,
startedAt: now,
completedAt: now,
updatedAt: now,
type: "handoff",
contextHandoffId: handoffs[1]!.id,
fromProviderThreadIds: [providerThreadId],
toProviderThreadId: providerThreadId,
fromProviderInstanceIds: [providerInstanceId],
toProviderInstanceId: providerInstanceId,
strategy: "full_thread_summary",
},
});

const window = yield* store.getThreadSnapshotWindow(threadId, { rowLimit: 10 });
const windowHandoffs = window.projection.contextHandoffs;
assert.deepEqual(
windowHandoffs.map((row) => row.id),
[handoffs[0]!.id, handoffs[1]!.id],
);
for (const row of windowHandoffs) {
assert.isUndefined(row.history);
assert.isUndefined(row.delivery);
}
const full = yield* store.getThreadSnapshot(threadId);
assert.deepEqual(full.projection.contextHandoffs, handoffs);
assert.deepEqual(
buildBoundedThreadStreamSnapshot(window),
buildBoundedThreadStreamSnapshot({
...window,
projection: { ...window.projection, contextHandoffs: handoffs.slice(0, 2) },
}),
);
}).pipe(Effect.provide(layerTest)),
);

it.effect("lists only active subagent IDs without loading shell data", () =>
Effect.gen(function* () {
const store = yield* ProjectionStore.ProjectionStoreV2;
Expand Down
46 changes: 45 additions & 1 deletion apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,11 @@ export interface ProjectionStoreV2Shape {
options: ProjectionTimelinePageOptions,
) => Effect.Effect<ProjectionTimelinePage, ProjectionStoreV2Error>;
readonly getMessageCount: (threadId: ThreadId) => Effect.Effect<number, ProjectionStoreV2Error>;
/** Bytes of tool output a transcript export is sure to hold, or 0 when it can fit in `limit`. */
readonly getTranscriptByteFloor: (
threadId: ThreadId,
limit: number,
) => Effect.Effect<number, ProjectionStoreV2Error>;
readonly getSubagentCount: (threadId: ThreadId) => Effect.Effect<number, ProjectionStoreV2Error>;
/** Child threads of the thread's app-owned tasks, in task order, without decoding the tasks. */
readonly listAppOwnedChildThreadIds: (
Expand Down Expand Up @@ -716,6 +721,7 @@ export interface ProjectionStoreV2Shape {
},
ProjectionStoreV2Error
>;
/** Context handoffs come without `history` and `delivery`; no window reader uses them. */
readonly getThreadSnapshotWindow: (
threadId: ThreadId,
options: {
Expand Down Expand Up @@ -4003,7 +4009,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
WHERE thread_id = ${threadId} ORDER BY rowid ASC
`
: sql<PayloadRow>`
SELECT payload_json FROM orchestration_v2_projection_context_handoffs AS handoff
-- Megabytes on a long thread, and projectContextHandoffForWire drops both.
SELECT json_remove(payload_json, '$.history', '$.delivery') AS payload_json
FROM orchestration_v2_projection_context_handoffs AS handoff
WHERE handoff.thread_id = ${threadId}
AND (
handoff.status IN ('pending', 'ready')
Expand Down Expand Up @@ -5915,6 +5923,41 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
Effect.map((rows) => rows[0]?.count ?? 0),
Effect.mapError(controlReadError(threadId)),
);
// Tool output reaches the export unchanged, so the bytes of its text, also inside a structured
// result, are a floor for the export. Other stored bytes are not: decoding drops fields. A
// rolled back run is the only way the export hides a tool row, and rows a fork inherits only
// add to it. The output sum parses payloads, so it runs only when the tool rows exceed `limit`.
const getTranscriptByteFloor: ProjectionStoreV2Shape["getTranscriptByteFloor"] = (
threadId,
limit,
) =>
sql<{ bytes: number }>`
WITH tool_rows AS (
SELECT payload_json FROM orchestration_v2_projection_turn_items
WHERE thread_id = ${threadId}
AND type IN ('command_execution', 'dynamic_tool')
AND (run_id IS NULL OR run_id NOT IN (
SELECT run_id FROM orchestration_v2_projection_runs
WHERE thread_id = ${threadId} AND status = 'rolled_back'
))
)
SELECT CASE
WHEN (SELECT COALESCE(SUM(octet_length(payload_json)), 0) FROM tool_rows) <= ${limit}
THEN 0
ELSE (
-- json_valid is false past SQLite's nesting limit, where json_tree would fail.
SELECT COALESCE(SUM(CASE WHEN json_valid(payload_json) THEN (
SELECT COALESCE(SUM(octet_length(leaf.value)), 0)
FROM json_tree(tool_rows.payload_json, '$.output') AS leaf
WHERE leaf.type = 'text'
) ELSE 0 END), 0)
FROM tool_rows
)
END AS bytes
`.pipe(
Effect.map((rows) => rows[0]?.bytes ?? 0),
Effect.mapError(controlReadError(threadId)),
);
const getSubagentCount: ProjectionStoreV2Shape["getSubagentCount"] = (threadId) =>
sql<{
count: number;
Expand Down Expand Up @@ -7995,6 +8038,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest),
isNodeStored: readOnReader(isNodeStored),
getMessageCount: readOnReader(getMessageCount),
getTranscriptByteFloor: readOnReader(getTranscriptByteFloor),
getSubagentCount: readOnReader(getSubagentCount),
listAppOwnedChildThreadIds: readOnReader(listAppOwnedChildThreadIds),
hasPendingDelegatedCompletion: readOnReader(hasPendingDelegatedCompletion),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ it.effect(
getThreadHistoryPage: () => Effect.die("unused"),
getTimelinePage: () => Effect.die("Unused timeline read"),
getMessageCount: () => Effect.die("unused message count"),
getTranscriptByteFloor: () => Effect.die("unused transcript byte floor"),
hasPendingDelegatedCompletion: () => Effect.die("unused hasPendingDelegatedCompletion"),
getSubagentCount: () => Effect.die("unused subagent count"),
listAppOwnedChildThreadIds: () => Effect.die("unused listAppOwnedChildThreadIds"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,7 @@ it.effect(
Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
Layer.provide(
Layer.mock(Orchestrator.OrchestratorV2)({
getTranscriptByteFloor: () => Effect.succeed(0),
getThreadSnapshot: (threadId) =>
Queue.offer(started, threadId).pipe(
Effect.andThen(Deferred.await(gates.get(threadId)!)),
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/orchestration-v2/ThreadManagementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,15 @@ const make = Effect.gen(function* () {
const transcriptExportPermits = yield* Semaphore.make(2);
const getThreadTranscript = Effect.fn("orchestrationV2.threadManagement.getThreadTranscript")(
function* (threadId: ThreadId) {
const { projection } = yield* getThreadSnapshot(threadId);
yield* ensureProjectionTranscript(threadId);
const byteFloor = yield* orchestrator.getTranscriptByteFloor(
threadId,
PROVIDER_SEND_TURN_MAX_FILE_BYTES,
);
if (byteFloor > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
return yield* new ThreadTranscriptTooLargeError({ threadId });
}
const { projection } = yield* orchestrator.getThreadSnapshot(threadId);
const environment = yield* ServerEnvironment.ServerEnvironmentIdentity;
const transcript = {
threadId: projection.thread.id,
Expand Down
Loading
Loading