feat(core): add durable compaction barrier (#35371)

This commit is contained in:
Kit Langton
2026-07-06 21:26:05 -04:00
committed by GitHub
parent bd947658bb
commit 04b673432c
39 changed files with 1488 additions and 398 deletions
+2 -1
View File
@@ -126,8 +126,9 @@ describe("enqueueServerEvent", () => {
enqueue(partUpdated("old")) enqueue(partUpdated("old"))
enqueue({ enqueue({
id: "event-delete",
type: "session.deleted", type: "session.deleted",
properties: { sessionID: "session", info: { id: "session" } }, properties: { sessionID: "session" },
} as Event) } as Event)
enqueue(partUpdated("new")) enqueue(partUpdated("new"))
+5 -2
View File
@@ -168,8 +168,11 @@ export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.sessio
export type SessionShellOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E> export type SessionShellOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } export type Endpoint4_14Input = {
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>> readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
readonly id?: Endpoint4_14Request["payload"]["id"]
}
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>["data"]
export type SessionCompactOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E> export type SessionCompactOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
@@ -229,9 +229,15 @@ const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13I
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } type Endpoint4_14Input = {
readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
readonly id?: Endpoint4_14Request["payload"]["id"]
}
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
@@ -556,16 +556,17 @@ export function make(options: ClientOptions) {
requestOptions, requestOptions,
), ),
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
request<SessionCompactOutput>( request<{ readonly data: SessionCompactOutput }>(
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
successStatus: 204, body: { id: input["id"] },
declaredStatuses: [404, 409, 503, 500, 400, 401], successStatus: 200,
empty: true, declaredStatuses: [409, 404, 400, 401],
empty: false,
}, },
requestOptions, requestOptions,
), ).then((value) => value.data),
wait: (input: SessionWaitInput, requestOptions?: RequestOptions) => wait: (input: SessionWaitInput, requestOptions?: RequestOptions) =>
request<SessionWaitOutput>( request<SessionWaitOutput>(
{ {
+61 -10
View File
@@ -74,14 +74,6 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError => export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
readonly message: string
}
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
export type ServiceUnavailableError = { export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError" readonly _tag: "ServiceUnavailableError"
readonly message: string readonly message: string
@@ -90,6 +82,14 @@ export type ServiceUnavailableError = {
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
readonly message: string
}
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
export type UnknownError = { export type UnknownError = {
readonly _tag: "UnknownError" readonly _tag: "UnknownError"
readonly message: string readonly message: string
@@ -879,9 +879,21 @@ export type SessionShellInput = {
export type SessionShellOutput = void export type SessionShellOutput = void
export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionCompactInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: { readonly id?: string | undefined }["id"]
}
export type SessionCompactOutput = void export type SessionCompactOutput = {
readonly data: {
readonly type: "compaction"
readonly admittedSeq: number
readonly id: string
readonly sessionID: string
readonly timeCreated: number
readonly handledSeq?: number
}
}["data"]
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -1095,6 +1107,7 @@ export type SessionContextOutput = {
} }
| { | {
readonly type: "compaction" readonly type: "compaction"
readonly status: "queued" | "running" | "completed" | "failed"
readonly reason: "auto" | "manual" readonly reason: "auto" | "manual"
readonly summary: string readonly summary: string
readonly recent: string readonly recent: string
@@ -1573,6 +1586,15 @@ export type SessionLogOutput =
readonly error: { readonly type: string; readonly message: string } readonly error: { readonly type: string; readonly message: string }
} }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@@ -1596,6 +1618,15 @@ export type SessionLogOutput =
readonly recent: string readonly recent: string
} }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@@ -1830,6 +1861,7 @@ export type SessionMessageOutput = {
} }
| { | {
readonly type: "compaction" readonly type: "compaction"
readonly status: "queued" | "running" | "completed" | "failed"
readonly reason: "auto" | "manual" readonly reason: "auto" | "manual"
readonly summary: string readonly summary: string
readonly recent: string readonly recent: string
@@ -2034,6 +2066,7 @@ export type MessageListOutput = {
} }
| { | {
readonly type: "compaction" readonly type: "compaction"
readonly status: "queued" | "running" | "completed" | "failed"
readonly reason: "auto" | "manual" readonly reason: "auto" | "manual"
readonly summary: string readonly summary: string
readonly recent: string readonly recent: string
@@ -4888,6 +4921,15 @@ export type EventSubscribeOutput =
readonly error: { readonly type: string; readonly message: string } readonly error: { readonly type: string; readonly message: string }
} }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@@ -4919,6 +4961,15 @@ export type EventSubscribeOutput =
readonly recent: string readonly recent: string
} }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
+15 -8
View File
@@ -140,6 +140,9 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
if (url.includes("/prompt")) { if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
} }
if (url.endsWith("/compact")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(compactionAdmission)))
}
if (url.includes("/context")) { if (url.includes("/context")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
} }
@@ -148,10 +151,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
} }
if (url.endsWith("/api/session/active")) { if (url.endsWith("/api/session/active")) {
return Effect.succeed( return Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
request,
Response.json({ data: { ses_test: { type: "running" } } }),
),
) )
} }
if (request.method === "POST" && url.endsWith("/api/session")) { if (request.method === "POST" && url.endsWith("/api/session")) {
@@ -161,10 +161,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
} }
return Effect.succeed( return Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
request,
Response.json({ data: [session.data], cursor: { next: "next" } }),
),
) )
}) })
const result = await Effect.gen(function* () { const result = await Effect.gen(function* () {
@@ -268,6 +265,16 @@ const admission = {
}, },
} }
const compactionAdmission = {
data: {
type: "compaction",
admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
},
}
const modelSwitchedMessage = { const modelSwitchedMessage = {
id: "msg_model", id: "msg_model",
type: "model-switched", type: "model-switched",
+13 -3
View File
@@ -46,7 +46,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
}) })
@@ -240,10 +240,10 @@ test("session methods use the public HTTP contract", async () => {
}) })
} }
if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/prompt")) return Response.json(admission)
if (url.endsWith("/compact")) return Response.json(compactionAdmission)
if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active")) if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
return Response.json({ data: { ses_test: { type: "running" } } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 }) if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } }) return Response.json({ data: [session.data], cursor: { next: "next" } })
@@ -364,6 +364,16 @@ const admission = {
}, },
} }
const compactionAdmission = {
data: {
type: "compaction",
admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
},
}
const modelSwitchedMessage = { const modelSwitchedMessage = {
id: "msg_model", id: "msg_model",
type: "model-switched", type: "model-switched",
+80 -154
View File
@@ -1,10 +1,8 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "95328a41-789d-44de-9643-6ac6ecd6b4ec", "id": "b0355fd9-bf41-42e3-9dca-76107de27ecd",
"prevIds": [ "prevIds": ["95328a41-789d-44de-9643-6ac6ecd6b4ec"],
"992b24b9-f3e9-41f5-87a5-4917d1423169"
],
"ddl": [ "ddl": [
{ {
"name": "workspace", "name": "workspace",
@@ -1012,13 +1010,23 @@
"autoincrement": false, "autoincrement": false,
"default": null, "default": null,
"generated": null, "generated": null,
"name": "type",
"entityType": "columns",
"table": "session_input"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "prompt", "name": "prompt",
"entityType": "columns", "entityType": "columns",
"table": "session_input" "table": "session_input"
}, },
{ {
"type": "text", "type": "text",
"notNull": true, "notNull": false,
"autoincrement": false, "autoincrement": false,
"default": null, "default": null,
"generated": null, "generated": null,
@@ -1567,13 +1575,9 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1582,13 +1586,9 @@
"table": "workspace" "table": "workspace"
}, },
{ {
"columns": [ "columns": ["active_account_id"],
"active_account_id"
],
"tableTo": "account", "tableTo": "account",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "SET NULL", "onDelete": "SET NULL",
"nameExplicit": false, "nameExplicit": false,
@@ -1597,13 +1597,9 @@
"table": "account_state" "table": "account_state"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"tableTo": "event_sequence", "tableTo": "event_sequence",
"columnsTo": [ "columnsTo": ["aggregate_id"],
"aggregate_id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1612,13 +1608,9 @@
"table": "event" "table": "event"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1627,13 +1619,9 @@
"table": "permission" "table": "permission"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1642,13 +1630,9 @@
"table": "project_directory" "table": "project_directory"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1657,13 +1641,9 @@
"table": "instruction_checkpoint" "table": "instruction_checkpoint"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1672,13 +1652,9 @@
"table": "instruction_entry" "table": "instruction_entry"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1687,13 +1663,9 @@
"table": "message" "table": "message"
}, },
{ {
"columns": [ "columns": ["message_id"],
"message_id"
],
"tableTo": "message", "tableTo": "message",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1702,13 +1674,9 @@
"table": "part" "table": "part"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1717,13 +1685,9 @@
"table": "session_input" "table": "session_input"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1732,13 +1696,9 @@
"table": "session_message" "table": "session_message"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1747,13 +1707,9 @@
"table": "session" "table": "session"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1762,13 +1718,9 @@
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@@ -1777,184 +1729,140 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["email", "url"],
"email",
"url"
],
"nameExplicit": false, "nameExplicit": false,
"name": "control_account_pk", "name": "control_account_pk",
"entityType": "pks", "entityType": "pks",
"table": "control_account" "table": "control_account"
}, },
{ {
"columns": [ "columns": ["project_id", "directory"],
"project_id",
"directory"
],
"nameExplicit": false, "nameExplicit": false,
"name": "project_directory_pk", "name": "project_directory_pk",
"entityType": "pks", "entityType": "pks",
"table": "project_directory" "table": "project_directory"
}, },
{ {
"columns": [ "columns": ["session_id", "key"],
"session_id",
"key"
],
"nameExplicit": false, "nameExplicit": false,
"name": "instruction_entry_pk", "name": "instruction_entry_pk",
"entityType": "pks", "entityType": "pks",
"table": "instruction_entry" "table": "instruction_entry"
}, },
{ {
"columns": [ "columns": ["session_id", "position"],
"session_id",
"position"
],
"nameExplicit": false, "nameExplicit": false,
"name": "todo_pk", "name": "todo_pk",
"entityType": "pks", "entityType": "pks",
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "workspace_pk", "name": "workspace_pk",
"table": "workspace", "table": "workspace",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["name"],
"name"
],
"nameExplicit": false, "nameExplicit": false,
"name": "data_migration_pk", "name": "data_migration_pk",
"table": "data_migration", "table": "data_migration",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_state_pk", "name": "account_state_pk",
"table": "account_state", "table": "account_state",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_pk", "name": "account_pk",
"table": "account", "table": "account",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "credential_pk", "name": "credential_pk",
"table": "credential", "table": "credential",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_sequence_pk", "name": "event_sequence_pk",
"table": "event_sequence", "table": "event_sequence",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_pk", "name": "event_pk",
"table": "event", "table": "event",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "permission_pk", "name": "permission_pk",
"table": "permission", "table": "permission",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "project_pk", "name": "project_pk",
"table": "project", "table": "project",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "instruction_checkpoint_pk", "name": "instruction_checkpoint_pk",
"table": "instruction_checkpoint", "table": "instruction_checkpoint",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "message_pk", "name": "message_pk",
"table": "message", "table": "message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "part_pk", "name": "part_pk",
"table": "part", "table": "part",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_input_pk", "name": "session_input_pk",
"table": "session_input", "table": "session_input",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_message_pk", "name": "session_message_pk",
"table": "session_message", "table": "session_message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_pk", "name": "session_pk",
"table": "session", "table": "session",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_share_pk", "name": "session_share_pk",
"table": "session_share", "table": "session_share",
@@ -2086,6 +1994,10 @@
"value": "promoted_seq", "value": "promoted_seq",
"isExpression": false "isExpression": false
}, },
{
"value": "type",
"isExpression": false
},
{ {
"value": "delivery", "value": "delivery",
"isExpression": false "isExpression": false
@@ -2098,7 +2010,21 @@
"isUnique": false, "isUnique": false,
"where": null, "where": null,
"origin": "manual", "origin": "manual",
"name": "session_input_session_pending_delivery_seq_idx", "name": "session_input_session_pending_type_delivery_seq_idx",
"entityType": "indexes",
"table": "session_input"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": true,
"where": "\"session_input\".\"type\" = 'compaction' and \"session_input\".\"promoted_seq\" is null",
"origin": "manual",
"name": "session_input_session_pending_compaction_idx",
"entityType": "indexes", "entityType": "indexes",
"table": "session_input" "table": "session_input"
}, },
+1
View File
@@ -47,5 +47,6 @@ export const migrations = (
import("./migration/20260703200000_reset_v2_session_events"), import("./migration/20260703200000_reset_v2_session_events"),
import("./migration/20260705180000_rename_instructions"), import("./migration/20260705180000_rename_instructions"),
import("./migration/20260706223930_add-session-fork"), import("./migration/20260706223930_add-session-fork"),
import("./migration/20260707010146_durable_session_inbox"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,43 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260707010146_durable_session_inbox",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`prompt\` text,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`INSERT INTO \`__new_session_input\`(\`id\`, \`session_id\`, \`type\`, \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`) SELECT \`id\`, \`session_id\`, 'prompt', \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` FROM \`session_input\`;`,
)
yield* tx.run(`DROP TABLE \`session_input\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_delivery_seq_idx\`;`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
+7 -3
View File
@@ -170,8 +170,9 @@ export default {
CREATE TABLE \`session_input\` ( CREATE TABLE \`session_input\` (
\`id\` text PRIMARY KEY, \`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL, \`session_id\` text NOT NULL,
\`prompt\` text NOT NULL, \`type\` text NOT NULL,
\`delivery\` text NOT NULL, \`prompt\` text,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL, \`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer, \`promoted_seq\` integer,
\`time_created\` integer NOT NULL, \`time_created\` integer NOT NULL,
@@ -261,7 +262,10 @@ export default {
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run( yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, `CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
) )
yield* tx.run( yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
+37 -28
View File
@@ -33,7 +33,6 @@ import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event" import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input" import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot" import { Snapshot } from "./snapshot"
import { SessionCompaction } from "./session/compaction"
import { SessionRevert } from "./session/revert" import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert" import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
@@ -96,6 +95,7 @@ type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never }) ({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
type CompactInput = { type CompactInput = {
id?: SessionMessage.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
} }
@@ -125,6 +125,13 @@ export class AttachmentError extends Schema.TaggedErrorClass<AttachmentError>()(
uri: Schema.String, uri: Schema.String,
message: Schema.String, message: Schema.String,
}) {} }) {}
export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionConflictError>()(
"Session.CompactionConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", { export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
}) {} }) {}
@@ -140,6 +147,7 @@ export type Error =
| OperationUnavailableError | OperationUnavailableError
| PromptConflictError | PromptConflictError
| AttachmentError | AttachmentError
| CompactionConflictError
| BusyError | BusyError
| SkillNotFoundError | SkillNotFoundError
| CommandV2.NotFoundError | CommandV2.NotFoundError
@@ -224,7 +232,7 @@ export interface Interface {
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError> }) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: ( readonly compact: (
input: CompactInput, input: CompactInput,
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError> ) => Effect.Effect<SessionInput.Compaction, NotFoundError | CompactionConflictError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError> readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>> readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError> readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
@@ -626,19 +634,20 @@ const layer = Layer.effect(
}) })
}), }),
compact: Effect.fn("V2Session.compact")(function* (input) { compact: Effect.fn("V2Session.compact")(function* (input) {
const session = yield* result.get(input.sessionID) yield* result.get(input.sessionID)
// TODO: admit manual compaction as durable pending work, like prompt input, instead of rejecting active sessions. const inputID = input.id ?? SessionMessage.ID.create()
if ((yield* execution.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID }) const admitted = yield* SessionInput.admitCompaction(db, events, {
const context = yield* store.context(input.sessionID) id: inputID,
const compacted = yield* Effect.gen(function* () { sessionID: input.sessionID,
const compaction = yield* SessionCompaction.Service
return yield* compaction.compactManual({ session, messages: context })
}).pipe( }).pipe(
Effect.provide(locations.get(session.location)), Effect.catchDefect((defect) =>
Effect.catch(() => Effect.succeed(false)), defect instanceof SessionInput.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
) )
if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" }) yield* execution.wake(input.sessionID)
return undefined return admitted
}), }),
wait: Effect.fn("V2Session.wait")(function* (sessionID) { wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID) yield* result.get(sessionID)
@@ -734,11 +743,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) { const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const files = input.files const files = input.files
? yield* Effect.forEach( ? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
input.files,
(file) => materializeAttachment(fs, file),
{ concurrency: 8 },
)
: undefined : undefined
return Prompt.make({ text: input.text, agents: input.agents, files }) return Prompt.make({ text: input.text, agents: input.agents, files })
}) })
@@ -769,7 +774,11 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
const content = const content =
mime === "text/plain" && resolved.start !== undefined mime === "text/plain" && resolved.start !== undefined
? Buffer.from( ? Buffer.from(
Buffer.from(resolved.bytes).toString("utf8").split("\n").slice(resolved.start - 1, resolved.end).join("\n"), Buffer.from(resolved.bytes)
.toString("utf8")
.split("\n")
.slice(resolved.start - 1, resolved.end)
.join("\n"),
) )
: resolved.bytes : resolved.bytes
return FileAttachment.create({ return FileAttachment.create({
@@ -799,13 +808,13 @@ const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (
}, },
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }), catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
}) })
const info = yield* fs.stat(target).pipe( const info = yield* fs
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), .stat(target)
) .pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
if (info.type === "Directory") { if (info.type === "Directory") {
const entries = yield* fs.readDirectoryEntries(target).pipe( const entries = yield* fs
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), .readDirectoryEntries(target)
) .pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return { return {
bytes: Buffer.from( bytes: Buffer.from(
entries entries
@@ -827,9 +836,9 @@ const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (
uri, uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`, message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
}) })
const bytes = yield* fs.readFile(target).pipe( const bytes = yield* fs
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), .readFile(target)
) .pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined } return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined }
}) })
+3 -1
View File
@@ -260,7 +260,9 @@ const make = (dependencies: Dependencies) => {
if (context === undefined || context <= 0) return false if (context === undefined || context <= 0) return false
const selected = select(input.messages, config.tokens) const selected = select(input.messages, config.tokens)
if (!selected) return false if (!selected) return false
const previousSummary = input.messages.find((message) => message.type === "compaction") const previousSummary = input.messages.find(
(message) => message.type === "compaction" && message.status === "completed",
)
const hasHead = selected.head.length > 0 const hasHead = selected.head.length > 0
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
const forcedShortContext = input.force && !hasHead const forcedShortContext = input.force && !hasHead
+8 -2
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm" import { and, asc, desc, eq, gt, gte, ne, or, sql } from "drizzle-orm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Database } from "../database/database" import { Database } from "../database/database"
import { MessageDecodeError } from "./error" import { MessageDecodeError } from "./error"
@@ -14,7 +14,13 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
return yield* db return yield* db
.select({ seq: SessionMessageTable.seq }) .select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable) .from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
),
)
.orderBy(desc(SessionMessageTable.seq)) .orderBy(desc(SessionMessageTable.seq))
.limit(1) .limit(1)
.get() .get()
+207 -37
View File
@@ -2,9 +2,10 @@ export * as SessionInput from "./input"
import { and, asc, eq, isNull } from "drizzle-orm" import { and, asc, eq, isNull } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect" import { DateTime, Effect, Schema } from "effect"
import { Admitted, Delivery } from "@opencode-ai/schema/session-input" import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database" import type { Database } from "../database/database"
import type { EventV2 } from "../event" import type { EventV2 } from "../event"
import { KeyedMutex } from "../effect/keyed-mutex"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
import { Prompt } from "@opencode-ai/schema/prompt" import { Prompt } from "@opencode-ai/schema/prompt"
@@ -13,30 +14,77 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"] type DatabaseService = Database.Interface["db"]
export { Admitted, Delivery } export { Admitted, Compaction, Delivery, Entry, PromptEntry }
const decodePrompt = Schema.decodeUnknownSync(Prompt) const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted => export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
Admitted.make({ id: SessionMessage.ID,
}) {}
const fromRow = (row: typeof SessionInputTable.$inferSelect): Entry => {
const base = {
admittedSeq: row.admitted_seq, admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id), id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id), sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction")
return Compaction.make({
...base,
type: "compaction",
...(row.promoted_seq === null ? {} : { handledSeq: row.promoted_seq }),
})
if (!row.prompt || !row.delivery) throw new LifecycleConflict({ id: base.id })
return PromptEntry.make({
...base,
type: "prompt",
prompt: decodePrompt(row.prompt), prompt: decodePrompt(row.prompt),
delivery: row.delivery, delivery: row.delivery,
timeCreated: DateTime.makeUnsafe(row.time_created),
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }), ...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
}) })
}
const toAdmitted = (entry: PromptEntry): Admitted =>
Admitted.make({
admittedSeq: entry.admittedSeq,
id: entry.id,
sessionID: entry.sessionID,
prompt: entry.prompt,
delivery: entry.delivery,
timeCreated: entry.timeCreated,
...(entry.promotedSeq === undefined ? {} : { promotedSeq: entry.promotedSeq }),
})
export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) { export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie) const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row) return row === undefined ? undefined : fromRow(row)
}) })
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", { export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(function* (
id: SessionMessage.ID, db: DatabaseService,
}) {} sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "compaction"),
isNull(SessionInputTable.promoted_seq),
),
)
.orderBy(asc(SessionInputTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const entry = fromRow(row)
return entry.type === "compaction" ? entry : undefined
})
export const admit = Effect.fn("SessionInput.admit")(function* ( export const admit = Effect.fn("SessionInput.admit")(function* (
db: DatabaseService, db: DatabaseService,
@@ -49,7 +97,10 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
}, },
) { ) {
const existing = yield* find(db, input.id) const existing = yield* find(db, input.id)
if (existing !== undefined) return existing if (existing !== undefined) {
if (existing.type !== "prompt") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return toAdmitted(existing)
}
return yield* events return yield* events
.publish(SessionEvent.PromptAdmitted, { .publish(SessionEvent.PromptAdmitted, {
inputID: input.id, inputID: input.id,
@@ -73,8 +124,51 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
), ),
), ),
Effect.catchDefect((defect) => Effect.catchDefect((defect) =>
find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))), find(db, input.id).pipe(
Effect.flatMap((stored) =>
stored?.type === "prompt" ? Effect.succeed(toAdmitted(stored)) : Effect.die(defect),
), ),
),
),
)
})
export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(function* (
db: DatabaseService,
events: EventV2.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
const pending = yield* pendingCompaction(db, input.sessionID)
if (pending) return pending
return yield* events
.publish(SessionEvent.Compaction.Admitted, {
inputID: input.id,
sessionID: input.sessionID,
})
.pipe(
Effect.flatMap((event) => {
if (event.durable === undefined)
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
return pendingCompaction(db, input.sessionID).pipe(
Effect.flatMap((stored) =>
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
),
)
}),
Effect.catchDefect((defect) =>
pendingCompaction(db, input.sessionID).pipe(
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
),
),
)
}),
) )
}) })
@@ -101,6 +195,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
.values({ .values({
id: input.id, id: input.id,
session_id: input.sessionID, session_id: input.sessionID,
type: "prompt",
admitted_seq: input.admittedSeq, admitted_seq: input.admittedSeq,
prompt: encodePrompt(input.prompt), prompt: encodePrompt(input.prompt),
delivery: input.delivery, delivery: input.delivery,
@@ -113,6 +208,44 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}) })
export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompactionAdmitted")(function* (
db: DatabaseService,
input: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
type: "compaction",
admitted_seq: input.admittedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
.returning()
.get()
.pipe(Effect.orDie)
if (stored) {
const entry = fromRow(stored)
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
}
const pending = yield* pendingCompaction(db, input.sessionID)
if (pending) return pending
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* ( export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* (
db: DatabaseService, db: DatabaseService,
input: { input: {
@@ -121,6 +254,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
readonly promotedSeq: number readonly promotedSeq: number
}, },
) { ) {
if (yield* pendingCompaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const updated = yield* db const updated = yield* db
.update(SessionInputTable) .update(SessionInputTable)
.set({ promoted_seq: input.promotedSeq }) .set({ promoted_seq: input.promotedSeq })
@@ -128,6 +262,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
and( and(
eq(SessionInputTable.id, input.id), eq(SessionInputTable.id, input.id),
eq(SessionInputTable.session_id, input.sessionID), eq(SessionInputTable.session_id, input.sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
), ),
) )
@@ -136,16 +271,43 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (updated) { if (updated) {
const stored = fromRow(updated) const stored = fromRow(updated)
if (stored.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id })) if (stored.type !== "prompt" || stored.sessionID !== input.sessionID)
return stored
}
// Every PromptPromoted event is published from an admitted inbox row, so a missing or
// divergent row on replay is an invariant violation.
const stored = yield* find(db, input.id)
if (!stored || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq)
return yield* Effect.die(new LifecycleConflict({ id: input.id })) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored return stored
}
const stored = yield* find(db, input.id)
if (
!stored ||
stored.type !== "prompt" ||
stored.sessionID !== input.sessionID ||
stored.promotedSeq !== input.promotedSeq
)
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
})
export const settleCompaction = Effect.fn("SessionInput.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID; readonly handledSeq: number },
) {
const updated = yield* db
.update(SessionInputTable)
.set({ promoted_seq: input.handledSeq })
.where(
and(
eq(SessionInputTable.session_id, input.sessionID),
eq(SessionInputTable.type, "compaction"),
isNull(SessionInputTable.promoted_seq),
),
)
.returning()
.get()
.pipe(Effect.orDie)
if (updated) {
const stored = fromRow(updated)
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
}
return undefined
}) })
export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
@@ -153,12 +315,14 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
delivery: Delivery, delivery: Delivery,
) { ) {
if (yield* pendingCompaction(db, sessionID)) return false
const row = yield* db const row = yield* db
.select({ id: SessionInputTable.id }) .select({ id: SessionInputTable.id })
.from(SessionInputTable) .from(SessionInputTable)
.where( .where(
and( and(
eq(SessionInputTable.session_id, sessionID), eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, delivery), eq(SessionInputTable.delivery, delivery),
), ),
@@ -181,42 +345,44 @@ export const equivalent = (
input.sessionID === expected.sessionID && input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
const matchesProjection = (
input: Admitted,
expected: {
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
readonly timeCreated: DateTime.Utc
},
) =>
equivalent(input, expected) &&
DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated)
const publish = Effect.fn("SessionInput.publish")(function* ( const publish = Effect.fn("SessionInput.publish")(function* (
db: DatabaseService, db: DatabaseService,
events: EventV2.Interface, events: EventV2.Interface,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>, rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
) { ) {
for (const row of rows) { return yield* inboxLocks.withLock(sessionID)(
const id = SessionMessage.ID.make(row.id) Effect.gen(function* () {
yield* events if (yield* pendingCompaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type !== "prompt") return Effect.die(new LifecycleConflict({ id: entry.id }))
return events
.publish(SessionEvent.PromptPromoted, { .publish(SessionEvent.PromptPromoted, {
sessionID, sessionID,
inputID: id, inputID: entry.id,
}) })
.pipe( .pipe(
Effect.catchDefect((defect) => Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict defect instanceof LifecycleConflict
? find(db, id).pipe( ? find(db, entry.id).pipe(
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)), Effect.flatMap((stored) =>
stored?.type === "prompt" && stored.promotedSeq !== undefined
? Effect.void
: Effect.die(defect),
),
) )
: Effect.die(defect), : Effect.die(defect),
), ),
) )
} },
{ discard: true },
)
return rows.length return rows.length
}),
)
}) })
export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
@@ -224,12 +390,14 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
events: EventV2.Interface, events: EventV2.Interface,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
) { ) {
if (yield* pendingCompaction(db, sessionID)) return 0
const rows = yield* db const rows = yield* db
.select() .select()
.from(SessionInputTable) .from(SessionInputTable)
.where( .where(
and( and(
eq(SessionInputTable.session_id, sessionID), eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "steer"), eq(SessionInputTable.delivery, "steer"),
), ),
@@ -245,12 +413,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
events: EventV2.Interface, events: EventV2.Interface,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
) { ) {
if (yield* pendingCompaction(db, sessionID)) return false
const row = yield* db const row = yield* db
.select() .select()
.from(SessionInputTable) .from(SessionInputTable)
.where( .where(
and( and(
eq(SessionInputTable.session_id, sessionID), eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "queue"), eq(SessionInputTable.delivery, "queue"),
), ),
+60 -2
View File
@@ -16,8 +16,10 @@ export interface Adapter {
readonly getShell: ( readonly getShell: (
shellID: SessionMessage.Shell["shell"]["id"], shellID: SessionMessage.Shell["shell"]["id"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never> ) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never> readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never> readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never> readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
} }
@@ -26,6 +28,10 @@ export function memory(state: MemoryState): Adapter {
state.messages.findLastIndex((message) => message.id === messageID) state.messages.findLastIndex((message) => message.id === messageID)
const shellIndex = (messageID: SessionMessage.ID) => const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID) state.messages.findLastIndex((message) => message.id === messageID)
const compactionIndex = () =>
state.messages.findLastIndex(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
// A newer step supersedes stale incomplete rows; never resume an older assistant projection. // A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
@@ -62,6 +68,13 @@ export function memory(state: MemoryState): Adapter {
}) })
}) })
}, },
getCompaction() {
return Effect.sync(() => {
const index = compactionIndex()
const message = state.messages[index]
return message?.type === "compaction" ? message : undefined
})
},
updateAssistant(assistant) { updateAssistant(assistant) {
return Effect.sync(() => { return Effect.sync(() => {
const index = assistantIndex(assistant.id) const index = assistantIndex(assistant.id)
@@ -80,6 +93,12 @@ export function memory(state: MemoryState): Adapter {
state.messages[index] = shell state.messages[index] = shell
}) })
}, },
updateCompaction(compaction) {
return Effect.sync(() => {
const index = state.messages.findLastIndex((message) => message.id === compaction.id)
if (index >= 0) state.messages[index] = compaction
})
},
appendMessage(message) { appendMessage(message) {
return Effect.sync(() => { return Effect.sync(() => {
state.messages.push(message) state.messages.push(message)
@@ -424,13 +443,45 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"session.compaction.started": () => Effect.void, "session.compaction.admitted": (event) =>
adapter.appendMessage(
SessionMessage.Compaction.make({
id: event.data.inputID,
type: "compaction",
status: "queued",
metadata: event.metadata,
reason: "manual",
summary: "",
recent: "",
time: { created: event.created },
}),
),
"session.compaction.started": (event) =>
Effect.gen(function* () {
if (event.data.reason !== "manual") return
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "running" })
}),
"session.compaction.delta": () => Effect.void, "session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => { "session.compaction.ended": (event) => {
return adapter.appendMessage( return Effect.gen(function* () {
const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined
if (current) {
yield* adapter.updateCompaction({
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
})
return
}
yield* adapter.appendMessage(
SessionMessage.Compaction.make({ SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id), id: SessionMessage.ID.fromEvent(event.id),
type: "compaction", type: "compaction",
status: "completed",
metadata: event.metadata, metadata: event.metadata,
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
@@ -438,7 +489,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
time: { created: event.created }, time: { created: event.created },
}), }),
) )
})
}, },
"session.compaction.failed": () =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "failed" })
}),
"session.revert.staged": () => Effect.void, "session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void, "session.revert.cleared": () => Effect.void,
"session.revert.committed": () => Effect.void, "session.revert.committed": () => Effect.void,
+63 -2
View File
@@ -248,6 +248,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor), gt(SessionMessageTable.seq, cursor),
copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1), copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1),
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
), ),
) )
.orderBy(asc(SessionMessageTable.seq)) .orderBy(asc(SessionMessageTable.seq))
@@ -297,11 +298,12 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.values( .values(
inputRows.flatMap((row) => { inputRows.flatMap((row) => {
const id = idMap.get(row.id) const id = idMap.get(row.id)
return id return id && row.type === "prompt"
? [ ? [
{ {
id, id,
session_id: event.data.sessionID, session_id: event.data.sessionID,
type: "prompt" as const,
prompt: row.prompt, prompt: row.prompt,
delivery: row.delivery, delivery: row.delivery,
admitted_seq: row.admitted_seq, admitted_seq: row.admitted_seq,
@@ -431,8 +433,30 @@ function run(db: DatabaseService, event: MessageEvent) {
return message.type === "shell" ? message : undefined return message.type === "shell" ? message : undefined
}) })
}, },
getCompaction() {
return Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const message = decodeRow(row)
return message.type === "compaction" ? message : undefined
})
},
updateAssistant: updateMessage, updateAssistant: updateMessage,
updateShell: updateMessage, updateShell: updateMessage,
updateCompaction: updateMessage,
appendMessage, appendMessage,
} }
yield* SessionMessageUpdater.update(adapter, event) yield* SessionMessageUpdater.update(adapter, event)
@@ -642,6 +666,20 @@ const layer = Layer.effectDiscard(
}) })
}), }),
) )
yield* events.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const admitted = yield* SessionInput.projectCompactionAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
})
if (admitted.id !== event.data.inputID) return
yield* run(db, event)
}),
)
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event)) yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event)) yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event)) yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
@@ -672,7 +710,30 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event)) yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
if (event.data.reason === "manual")
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.Compaction.Failed, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.RevertEvent.Staged, (event) => yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
db db
.update(SessionTable) .update(SessionTable)
+41 -5
View File
@@ -228,7 +228,10 @@ const layer = Layer.effect(
toolChoice: isLastStep ? "none" : undefined, toolChoice: isLastStep ? "none" : undefined,
}) })
// Automatic compaction completed; rebuild the request from compacted history. // Automatic compaction completed; rebuild the request from compacted history.
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request })) if (
!(yield* SessionInput.pendingCompaction(db, session.id)) &&
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
)
return { _tag: "RestartAfterCompaction", step: currentStep } as const return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture() const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, { const publisher = createLLMEventPublisher(events, {
@@ -511,11 +514,36 @@ const layer = Layer.effect(
} }
}) })
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
) {
const pending = yield* SessionInput.pendingCompaction(db, sessionID)
if (!pending) return false
const session = yield* getSession(sessionID)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted) && compacted.value) return true
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause)
return true
}),
)
})
// Execution lifecycle is published per busy period by SessionExecution, not per drain here. // Execution lifecycle is published per busy period by SessionExecution, not per drain here.
const drain = Effect.fn("SessionRunner.drain")(function* (input: { const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
}) { }) {
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (!input.force && !hasSteer && !hasQueue) return if (!input.force && !hasSteer && !hasQueue) return
@@ -539,11 +567,19 @@ const layer = Layer.effect(
} }
needsContinuation = result.needsContinuation needsContinuation = result.needsContinuation
step = result.step + 1 step = result.step + 1
promotion = "steer" if (needsContinuation) {
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
continue
} }
shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue") yield* runPendingCompaction(input.sessionID)
promotion = shouldRun ? "queue" : undefined promotion = "steer"
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
}
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
shouldRun = hasSteer || hasQueue
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
} }
}) })
@@ -209,6 +209,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
case "assistant": case "assistant":
return assistant(message, model) return assistant(message, model)
case "compaction": case "compaction":
if (message.status !== "completed") return []
return [ return [
Message.make({ Message.make({
id: message.id, id: message.id,
+9 -3
View File
@@ -1,4 +1,5 @@
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core" import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
import { sql } from "drizzle-orm"
import { directoryColumn, pathColumn } from "../database/path" import { directoryColumn, pathColumn } from "../database/path"
import { ProjectTable } from "../project/sql" import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message" import type { SessionMessage } from "./message"
@@ -147,8 +148,9 @@ export const SessionInputTable = sqliteTable(
.$type<SessionSchema.ID>() .$type<SessionSchema.ID>()
.notNull() .notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }), .references(() => SessionTable.id, { onDelete: "cascade" }),
prompt: text({ mode: "json" }).notNull().$type<Prompt>(), type: text().$type<SessionInput.Entry["type"]>().notNull(),
delivery: text().$type<SessionInput.Delivery>().notNull(), prompt: text({ mode: "json" }).$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>(),
admitted_seq: integer().notNull(), admitted_seq: integer().notNull(),
promoted_seq: integer(), promoted_seq: integer(),
time_created: integer() time_created: integer()
@@ -156,12 +158,16 @@ export const SessionInputTable = sqliteTable(
.$default(() => Date.now()), .$default(() => Date.now()),
}, },
(table) => [ (table) => [
index("session_input_session_pending_delivery_seq_idx").on( index("session_input_session_pending_type_delivery_seq_idx").on(
table.session_id, table.session_id,
table.promoted_seq, table.promoted_seq,
table.type,
table.delivery, table.delivery,
table.admitted_seq, table.admitted_seq,
), ),
uniqueIndex("session_input_session_pending_compaction_idx")
.on(table.session_id)
.where(sql`${table.type} = 'compaction' and ${table.promoted_seq} is null`),
uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq), uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq), uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
], ],
+36 -3
View File
@@ -16,6 +16,7 @@ import contextEpochAgentMigration from "@opencode-ai/core/database/migration/202
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events" import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork" import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -109,13 +110,14 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect( expect(
yield* db.all( yield* db.all(
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`, sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
), ),
).toEqual([ ).toEqual([
{ name: "event_aggregate_seq_idx" }, { name: "event_aggregate_seq_idx" },
{ name: "event_aggregate_type_seq_idx" }, { name: "event_aggregate_type_seq_idx" },
{ name: "session_input_session_admitted_seq_idx" }, { name: "session_input_session_admitted_seq_idx" },
{ name: "session_input_session_pending_delivery_seq_idx" }, { name: "session_input_session_pending_compaction_idx" },
{ name: "session_input_session_pending_type_delivery_seq_idx" },
{ name: "session_input_session_promoted_seq_idx" }, { name: "session_input_session_promoted_seq_idx" },
{ name: "session_message_session_seq_idx" }, { name: "session_message_session_seq_idx" },
{ name: "session_message_session_time_created_id_idx" }, { name: "session_message_session_time_created_id_idx" },
@@ -353,7 +355,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`, sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
) )
yield* db.run( yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'prompt', '{}', 'steer', 9, 1)`,
) )
yield* db.run( yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
@@ -416,6 +418,37 @@ describe("DatabaseMigration", () => {
) )
}) })
test("preserves admitted prompts while generalizing the durable inbox", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', '{"text":"hello"}', 'steer', 4, NULL, 1)`,
)
yield* DatabaseMigration.applyOnly(db, [durableSessionInboxMigration])
expect(
yield* db.all(
sql`SELECT id, type, prompt, delivery, admitted_seq, promoted_seq FROM session_input ORDER BY admitted_seq`,
),
).toEqual([
{
id: "input",
type: "prompt",
prompt: '{"text":"hello"}',
delivery: "steer",
admitted_seq: 4,
promoted_seq: null,
},
])
}),
)
})
test("resets incompatible projected Session messages before adding sequence order", async () => { test("resets incompatible projected Session messages before adding sequence order", async () => {
await run( await run(
Effect.gen(function* () { Effect.gen(function* () {
+16 -7
View File
@@ -75,7 +75,7 @@ const it = testEffect(
) )
describe("SessionV2.compact", () => { describe("SessionV2.compact", () => {
it.effect("manually compacts the active session context", () => it.effect("durably admits and coalesces manual compaction", () =>
Effect.gen(function* () { Effect.gen(function* () {
requests = [] requests = []
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
@@ -95,13 +95,22 @@ describe("SessionV2.compact", () => {
inputID: messageID, inputID: messageID,
}) })
yield* session.compact({ sessionID: created.id }) expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.CompactionConflictError",
inputID: messageID,
})
const first = yield* session.compact({ sessionID: created.id })
const second = yield* session.compact({ sessionID: created.id })
expect(requests).toHaveLength(1) expect(second.id).toBe(first.id)
expect(JSON.stringify(requests[0]?.messages)).toContain("Please compact this session history.") expect(requests).toHaveLength(0)
expect(yield* session.context(created.id)).toMatchObject([ expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({
{ type: "compaction", reason: "manual", summary: "manual session summary", recent: "" }, type: "compaction",
]) status: "queued",
reason: "manual",
summary: "",
recent: "",
})
}), }),
) )
}) })
+5 -2
View File
@@ -246,7 +246,9 @@ describe("SessionV2.prompt", () => {
mention: { start: 8, end: 17, text: "[Image 1]" }, mention: { start: 8, end: 17, text: "[Image 1]" },
}, },
]) ])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) const stored = yield* admitted(message.id)
expect(stored?.type).toBe("prompt")
if (stored?.type === "prompt") expect(stored.prompt.files).toEqual(message.prompt.files)
}), }),
) )
@@ -336,7 +338,8 @@ describe("SessionV2.prompt", () => {
name: "image.png", name: "image.png",
}, },
]) ])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) const stored = yield* admitted(message.id)
expect(stored?.type === "prompt" ? stored.prompt.files : undefined).toEqual(message.prompt.files)
}), }),
) )
@@ -106,6 +106,7 @@ describe("toLLMMessages", () => {
SessionMessage.Compaction.make({ SessionMessage.Compaction.make({
id: id("compaction"), id: id("compaction"),
type: "compaction", type: "compaction",
status: "completed",
reason: "auto", reason: "auto",
summary: "Earlier work", summary: "Earlier work",
recent: "Recent work", recent: "Recent work",
+95
View File
@@ -1324,6 +1324,101 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("runs one durable compaction barrier before later steer and queued prompts", () =>
Effect.gen(function* () {
yield* setup
requests.length = 0
currentModel = recoveryModel
const session = yield* SessionV2.Service
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
responses = [
fragmentFixture("text", "text-active", ["Active complete"]).completeEvents,
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
fragmentFixture("text", "text-steer", ["Steer complete"]).completeEvents,
fragmentFixture("text", "text-queue", ["Queue complete"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Active work" }), resume: false })
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
const first = yield* session.compact({ sessionID })
const second = yield* session.compact({ sessionID })
expect(second.id).toBe(first.id)
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({
id: first.id,
})
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "queued",
})
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Steer after compaction" }),
resume: false,
})
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Queue after compaction" }),
delivery: "queue",
resume: false,
})
expect(yield* SessionInput.hasPending((yield* Database.Service).db, sessionID, "steer")).toBe(false)
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(active)
expect(requests).toHaveLength(4)
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[2])).toContain("Steer after compaction")
expect(userTexts(requests[3])).toContain("Queue after compaction")
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "completed",
summary: "durable summary",
})
}),
)
it.effect("releases queued prompts when durable compaction fails", () =>
Effect.gen(function* () {
yield* setup
requests.length = 0
currentModel = recoveryModel
const session = yield* SessionV2.Service
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
responses = [
fragmentFixture("text", "text-active-failure", ["Active complete"]).completeEvents,
[],
fragmentFixture("text", "text-after-failure", ["Continued"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Active work" }), resume: false })
const active = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
const compaction = yield* session.compact({ sessionID })
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ text: "Continue after failure" }),
delivery: "queue",
resume: false,
})
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(active)
expect(requests).toHaveLength(3)
expect(userTexts(requests[2])).toContain("Continue after failure")
expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
})
}),
)
it.effect("automatically compacts into a completed summary and retained recent turn", () => it.effect("automatically compacts into a completed summary and retained recent turn", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
+5 -10
View File
@@ -305,13 +305,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
resume: Schema.Boolean.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional),
}), }),
success: Schema.Struct({ data: SessionInput.Admitted }), success: Schema.Struct({ data: SessionInput.Admitted }),
error: [ error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError],
ConflictError,
InvalidRequestError,
SessionNotFoundError,
CommandNotFoundError,
CommandEvaluationError,
],
}) })
.middleware(sessionLocationMiddleware) .middleware(sessionLocationMiddleware)
.annotateMerge( .annotateMerge(
@@ -386,15 +380,16 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add( .add(
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent, payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional) }),
error: [SessionNotFoundError, SessionBusyError, ServiceUnavailableError, UnknownError], success: Schema.Struct({ data: SessionInput.Compaction }),
error: [ConflictError, SessionNotFoundError],
}) })
.middleware(sessionLocationMiddleware) .middleware(sessionLocationMiddleware)
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.compact", identifier: "v2.session.compact",
summary: "Compact session", summary: "Compact session",
description: "Compact a session conversation.", description: "Queue a durable session compaction request.",
}), }),
), ),
) )
+19
View File
@@ -436,6 +436,16 @@ export const RetryScheduled = Event.durable({
export type RetryScheduled = typeof RetryScheduled.Type export type RetryScheduled = typeof RetryScheduled.Type
export namespace Compaction { export namespace Compaction {
export const Admitted = Event.durable({
type: "session.compaction.admitted",
...options,
schema: {
...Base,
inputID: SessionMessage.ID,
},
})
export type Admitted = typeof Admitted.Type
export const Started = Event.durable({ export const Started = Event.durable({
type: "session.compaction.started", type: "session.compaction.started",
...options, ...options,
@@ -466,6 +476,13 @@ export namespace Compaction {
}, },
}) })
export type Ended = typeof Ended.Type export type Ended = typeof Ended.Type
export const Failed = Event.durable({
type: "session.compaction.failed",
...options,
schema: Base,
})
export type Failed = typeof Failed.Type
} }
export namespace RevertEvent { export namespace RevertEvent {
@@ -517,9 +534,11 @@ export const Definitions = Event.inventory(
Tool.Success, Tool.Success,
Tool.Failed, Tool.Failed,
RetryScheduled, RetryScheduled,
Compaction.Admitted,
Compaction.Started, Compaction.Started,
Compaction.Delta, Compaction.Delta,
Compaction.Ended, Compaction.Ended,
Compaction.Failed,
RevertEvent.Staged, RevertEvent.Staged,
RevertEvent.Cleared, RevertEvent.Cleared,
RevertEvent.Committed, RevertEvent.Committed,
+19
View File
@@ -21,3 +21,22 @@ export const Admitted = Schema.Struct({
timeCreated: DateTimeUtcFromMillis, timeCreated: DateTimeUtcFromMillis,
promotedSeq: NonNegativeInt.pipe(optional), promotedSeq: NonNegativeInt.pipe(optional),
}).annotate({ identifier: "SessionInput.Admitted" }) }).annotate({ identifier: "SessionInput.Admitted" })
export interface PromptEntry extends Schema.Schema.Type<typeof PromptEntry> {}
export const PromptEntry = Schema.Struct({
type: Schema.Literal("prompt"),
...Admitted.fields,
}).annotate({ identifier: "SessionInput.PromptEntry" })
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
export const Compaction = Schema.Struct({
type: Schema.Literal("compaction"),
admittedSeq: NonNegativeInt,
id: SessionMessage.ID,
sessionID: SessionID,
timeCreated: DateTimeUtcFromMillis,
handledSeq: NonNegativeInt.pipe(optional),
}).annotate({ identifier: "SessionInput.Compaction" })
export const Entry = Schema.Union([PromptEntry, Compaction]).pipe(Schema.toTaggedUnion("type"))
export type Entry = typeof Entry.Type
+1
View File
@@ -210,6 +210,7 @@ export const Assistant = Schema.Struct({
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {} export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
export const Compaction = Schema.Struct({ export const Compaction = Schema.Struct({
type: Schema.Literal("compaction"), type: Schema.Literal("compaction"),
status: Schema.Literals(["queued", "running", "completed", "failed"]),
reason: Schema.Literals(["auto", "manual"]), reason: Schema.Literals(["auto", "manual"]),
summary: Schema.String, summary: Schema.String,
recent: Schema.String, recent: Schema.String,
@@ -131,8 +131,10 @@ describe("public event manifest", () => {
"session.reasoning.started.1", "session.reasoning.started.1",
"session.reasoning.ended.1", "session.reasoning.ended.1",
"session.retry.scheduled.1", "session.retry.scheduled.1",
"session.compaction.admitted.1",
"session.compaction.started.1", "session.compaction.started.1",
"session.compaction.ended.1", "session.compaction.ended.1",
"session.compaction.failed.1",
"session.revert.staged.1", "session.revert.staged.1",
"session.revert.cleared.1", "session.revert.cleared.1",
"session.revert.committed.1", "session.revert.committed.1",
+60 -2
View File
@@ -448,6 +448,8 @@ import type {
V2ShellOutputResponses, V2ShellOutputResponses,
V2ShellRemoveErrors, V2ShellRemoveErrors,
V2ShellRemoveResponses, V2ShellRemoveResponses,
V2ShellTimeoutErrors,
V2ShellTimeoutResponses,
V2SkillListErrors, V2SkillListErrors,
V2SkillListResponses, V2SkillListResponses,
V2VcsDiffErrors, V2VcsDiffErrors,
@@ -6305,19 +6307,35 @@ export class Session3 extends HeyApiClient {
/** /**
* Compact session * Compact session
* *
* Compact a session conversation. * Queue a durable session compaction request.
*/ */
public compact<ThrowOnError extends boolean = false>( public compact<ThrowOnError extends boolean = false>(
parameters: { parameters: {
sessionID: string sessionID: string
id?: string | null
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "body", key: "id" },
],
},
],
)
return (options?.client ?? this.client).post<V2SessionCompactResponses, V2SessionCompactErrors, ThrowOnError>({ return (options?.client ?? this.client).post<V2SessionCompactResponses, V2SessionCompactErrors, ThrowOnError>({
url: "/api/session/{sessionID}/compact", url: "/api/session/{sessionID}/compact",
...options, ...options,
...params, ...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
}) })
} }
@@ -7787,6 +7805,46 @@ export class Shell extends HeyApiClient {
}) })
} }
/**
* Update shell timeout
*
* Replace a running shell command's timeout from now, or clear it with zero.
*/
public timeout<ThrowOnError extends boolean = false>(
parameters: {
id: string
location?: {
directory?: string | null
workspace?: string | null
} | null
timeout?: number
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "id" },
{ in: "query", key: "location" },
{ in: "body", key: "timeout" },
],
},
],
)
return (options?.client ?? this.client).patch<V2ShellTimeoutResponses, V2ShellTimeoutErrors, ThrowOnError>({
url: "/api/shell/{id}/timeout",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/** /**
* Read shell output * Read shell output
* *
+220 -14
View File
@@ -50,9 +50,11 @@ export type Event =
| EventSessionToolSuccess | EventSessionToolSuccess
| EventSessionToolFailed | EventSessionToolFailed
| EventSessionRetryScheduled | EventSessionRetryScheduled
| EventSessionCompactionAdmitted
| EventSessionCompactionStarted | EventSessionCompactionStarted
| EventSessionCompactionDelta | EventSessionCompactionDelta
| EventSessionCompactionEnded | EventSessionCompactionEnded
| EventSessionCompactionFailed
| EventSessionRevertStaged | EventSessionRevertStaged
| EventSessionRevertCleared | EventSessionRevertCleared
| EventSessionRevertCommitted | EventSessionRevertCommitted
@@ -1200,6 +1202,14 @@ export type GlobalEvent = {
error: SessionStructuredError error: SessionStructuredError
} }
} }
| {
id: string
type: "session.compaction.admitted"
properties: {
sessionID: string
inputID: string
}
}
| { | {
id: string id: string
type: "session.compaction.started" type: "session.compaction.started"
@@ -1226,6 +1236,13 @@ export type GlobalEvent = {
recent: string recent: string
} }
} }
| {
id: string
type: "session.compaction.failed"
properties: {
sessionID: string
}
}
| { | {
id: string id: string
type: "session.revert.staged" type: "session.revert.staged"
@@ -1772,8 +1789,10 @@ export type GlobalEvent = {
| SyncEventSessionToolSuccess | SyncEventSessionToolSuccess
| SyncEventSessionToolFailed | SyncEventSessionToolFailed
| SyncEventSessionRetryScheduled | SyncEventSessionRetryScheduled
| SyncEventSessionCompactionAdmitted
| SyncEventSessionCompactionStarted | SyncEventSessionCompactionStarted
| SyncEventSessionCompactionEnded | SyncEventSessionCompactionEnded
| SyncEventSessionCompactionFailed
| SyncEventSessionRevertStaged | SyncEventSessionRevertStaged
| SyncEventSessionRevertCleared | SyncEventSessionRevertCleared
| SyncEventSessionRevertCommitted | SyncEventSessionRevertCommitted
@@ -2940,8 +2959,10 @@ export type SessionDurableEvent =
| SessionToolSuccess | SessionToolSuccess
| SessionToolFailed | SessionToolFailed
| SessionRetryScheduled | SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted | SessionCompactionStarted
| SessionCompactionEnded | SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged | SessionRevertStaged
| SessionRevertCleared | SessionRevertCleared
| SessionRevertCommitted | SessionRevertCommitted
@@ -3087,9 +3108,11 @@ export type V2Event =
| SessionToolSuccess | SessionToolSuccess
| SessionToolFailed | SessionToolFailed
| SessionRetryScheduled | SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted | SessionCompactionStarted
| SessionCompactionDelta | SessionCompactionDelta
| SessionCompactionEnded | SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged | SessionRevertStaged
| SessionRevertCleared | SessionRevertCleared
| SessionRevertCommitted | SessionRevertCommitted
@@ -4168,6 +4191,21 @@ export type SyncEventSessionRetryScheduled = {
} }
} }
export type SyncEventSessionCompactionAdmitted = {
type: "sync"
id: string
syncEvent: {
type: "session.compaction.admitted.1"
id: string
seq: number
aggregateID: string
data: {
sessionID: string
inputID: string
}
}
}
export type SyncEventSessionCompactionStarted = { export type SyncEventSessionCompactionStarted = {
type: "sync" type: "sync"
id: string id: string
@@ -4200,6 +4238,20 @@ export type SyncEventSessionCompactionEnded = {
} }
} }
export type SyncEventSessionCompactionFailed = {
type: "sync"
id: string
syncEvent: {
type: "session.compaction.failed.1"
id: string
seq: number
aggregateID: string
data: {
sessionID: string
}
}
}
export type SyncEventSessionRevertStaged = { export type SyncEventSessionRevertStaged = {
type: "sync" type: "sync"
id: string id: string
@@ -4374,6 +4426,15 @@ export type SessionInputAdmitted = {
promotedSeq?: number promotedSeq?: number
} }
export type SessionInputCompaction = {
type: "compaction"
admittedSeq: number
id: string
sessionID: string
timeCreated: number
handledSeq?: number
}
export type SessionMessageAgentSelected = { export type SessionMessageAgentSelected = {
id: string id: string
metadata?: { metadata?: {
@@ -4590,6 +4651,7 @@ export type SessionMessageAssistant = {
export type SessionMessageCompaction = { export type SessionMessageCompaction = {
type: "compaction" type: "compaction"
status: "queued" | "running" | "completed" | "failed"
reason: "auto" | "manual" reason: "auto" | "manual"
summary: string summary: string
recent: string recent: string
@@ -5278,6 +5340,25 @@ export type SessionRetryScheduled = {
} }
} }
export type SessionCompactionAdmitted = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.compaction.admitted"
durable: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
sessionID: string
inputID: string
}
}
export type SessionCompactionStarted = { export type SessionCompactionStarted = {
id: string id: string
created: number created: number
@@ -5318,6 +5399,24 @@ export type SessionCompactionEnded = {
} }
} }
export type SessionCompactionFailed = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.compaction.failed"
durable: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
sessionID: string
}
}
export type SessionRevertStaged = { export type SessionRevertStaged = {
id: string id: string
created: number created: number
@@ -7247,6 +7346,15 @@ export type EventSessionRetryScheduled = {
} }
} }
export type EventSessionCompactionAdmitted = {
id: string
type: "session.compaction.admitted"
properties: {
sessionID: string
inputID: string
}
}
export type EventSessionCompactionStarted = { export type EventSessionCompactionStarted = {
id: string id: string
type: "session.compaction.started" type: "session.compaction.started"
@@ -7276,6 +7384,14 @@ export type EventSessionCompactionEnded = {
} }
} }
export type EventSessionCompactionFailed = {
id: string
type: "session.compaction.failed"
properties: {
sessionID: string
}
}
export type EventSessionRevertStaged = { export type EventSessionRevertStaged = {
id: string id: string
type: "session.revert.staged" type: "session.revert.staged"
@@ -8439,9 +8555,11 @@ export type V2EventV2 =
| SessionToolSuccessV2 | SessionToolSuccessV2
| SessionToolFailedV2 | SessionToolFailedV2
| SessionRetryScheduledV2 | SessionRetryScheduledV2
| SessionCompactionAdmittedV2
| SessionCompactionStartedV2 | SessionCompactionStartedV2
| SessionCompactionDeltaV2 | SessionCompactionDeltaV2
| SessionCompactionEndedV2 | SessionCompactionEndedV2
| SessionCompactionFailedV2
| SessionRevertStagedV2 | SessionRevertStagedV2
| SessionRevertClearedV2 | SessionRevertClearedV2
| SessionRevertCommittedV2 | SessionRevertCommittedV2
@@ -8583,6 +8701,15 @@ export type SessionInputAdmittedV2 = {
promotedSeq?: number promotedSeq?: number
} }
export type SessionInputCompactionV2 = {
type: "compaction"
admittedSeq: number
id: string
sessionID: string
timeCreated: number
handledSeq?: number
}
export type SessionMessageAgentSelectedV2 = { export type SessionMessageAgentSelectedV2 = {
id: string id: string
metadata?: { metadata?: {
@@ -8721,6 +8848,7 @@ export type SessionMessageAssistantV2 = {
export type SessionMessageCompactionV2 = { export type SessionMessageCompactionV2 = {
type: "compaction" type: "compaction"
status: "queued" | "running" | "completed" | "failed"
reason: "auto" | "manual" reason: "auto" | "manual"
summary: string summary: string
recent: string recent: string
@@ -9416,6 +9544,25 @@ export type SessionRetryScheduledV2 = {
} }
} }
export type SessionCompactionAdmittedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.compaction.admitted"
durable: {
aggregateID: string
seq: number
version: number
}
location?: LocationRefV2
data: {
sessionID: string
inputID: string
}
}
export type SessionCompactionStartedV2 = { export type SessionCompactionStartedV2 = {
id: string id: string
created: number created: number
@@ -9456,6 +9603,24 @@ export type SessionCompactionEndedV2 = {
} }
} }
export type SessionCompactionFailedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.compaction.failed"
durable: {
aggregateID: string
seq: number
version: number
}
location?: LocationRefV2
data: {
sessionID: string
}
}
export type SessionRevertStagedV2 = { export type SessionRevertStagedV2 = {
id: string id: string
created: number created: number
@@ -15426,7 +15591,9 @@ export type V2SessionShellResponses = {
export type V2SessionShellResponse = V2SessionShellResponses[keyof V2SessionShellResponses] export type V2SessionShellResponse = V2SessionShellResponses[keyof V2SessionShellResponses]
export type V2SessionCompactData = { export type V2SessionCompactData = {
body?: never body: {
id?: string | null
}
path: { path: {
sessionID: string sessionID: string
} }
@@ -15448,26 +15615,20 @@ export type V2SessionCompactErrors = {
*/ */
404: SessionNotFoundError 404: SessionNotFoundError
/** /**
* SessionBusyError * ConflictError
*/ */
409: SessionBusyError 409: ConflictErrorV2
/**
* UnknownError
*/
500: UnknownErrorV2
/**
* ServiceUnavailableError
*/
503: ServiceUnavailableErrorV2
} }
export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors] export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors]
export type V2SessionCompactResponses = { export type V2SessionCompactResponses = {
/** /**
* <No Content> * Success
*/ */
204: void 200: {
data: SessionInputCompactionV2
}
} }
export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses]
@@ -17796,7 +17957,7 @@ export type V2ShellCreateData = {
body: { body: {
command: string command: string
cwd?: string cwd?: string
timeout?: number timeout: number
metadata?: { metadata?: {
[key: string]: unknown [key: string]: unknown
} }
@@ -17919,6 +18080,51 @@ export type V2ShellGetResponses = {
export type V2ShellGetResponse = V2ShellGetResponses[keyof V2ShellGetResponses] export type V2ShellGetResponse = V2ShellGetResponses[keyof V2ShellGetResponses]
export type V2ShellTimeoutData = {
body: {
timeout: number
}
path: {
id: string
}
query?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
}
url: "/api/shell/{id}/timeout"
}
export type V2ShellTimeoutErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ShellNotFoundError
*/
404: ShellNotFoundError
}
export type V2ShellTimeoutError = V2ShellTimeoutErrors[keyof V2ShellTimeoutErrors]
export type V2ShellTimeoutResponses = {
/**
* Success
*/
200: {
location: LocationInfoV2
data: ShellV2
}
}
export type V2ShellTimeoutResponse = V2ShellTimeoutResponses[keyof V2ShellTimeoutResponses]
export type V2ShellOutputData = { export type V2ShellOutputData = {
body?: never body?: never
path: { path: {
+7 -25
View File
@@ -364,7 +364,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle( .handle(
"session.compact", "session.compact",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe( return {
data: yield* session.compact({ sessionID: ctx.params.sessionID, id: ctx.payload.id }).pipe(
Effect.catchTag("Session.NotFoundError", (error) => Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail( Effect.fail(
new SessionNotFoundError({ new SessionNotFoundError({
@@ -373,35 +374,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}), }),
), ),
), ),
Effect.catchTag("Session.OperationUnavailableError", (error) => Effect.catchTag("Session.CompactionConflictError", (error) =>
Effect.fail( Effect.fail(
new ServiceUnavailableError({ new ConflictError({
message: `Session ${error.operation} is not available yet`, message: `Compaction input ID conflicts with an existing durable record: ${error.inputID}`,
service: `session.${error.operation}`, resource: error.inputID,
}), }),
), ),
), ),
Effect.catchTag(
"Session.BusyError",
(error) =>
new SessionBusyError({
sessionID: error.sessionID,
message: `Session is busy: ${error.sessionID}`,
}),
), ),
Effect.catchTag("Session.MessageDecodeError", (error) => { }
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message during compaction").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
),
),
)
}),
)
return HttpApiSchema.NoContent.make()
}), }),
) )
.handle( .handle(
+61
View File
@@ -55,6 +55,7 @@ type Data = {
family: Record<string, string[]> family: Record<string, string[]>
status: Record<string, DataSessionStatus> status: Record<string, DataSessionStatus>
compaction: Partial<Record<string, string>> compaction: Partial<Record<string, string>>
compactionReason: Partial<Record<string, "auto" | "manual">>
message: Record<string, SessionMessage[]> message: Record<string, SessionMessage[]>
input: Record<string, string[]> input: Record<string, string[]>
permission: Record<string, PermissionV2Request[]> permission: Record<string, PermissionV2Request[]>
@@ -92,6 +93,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
family: {}, family: {},
status: {}, status: {},
compaction: {}, compaction: {},
compactionReason: {},
message: {}, message: {},
input: {}, input: {},
permission: {}, permission: {},
@@ -145,6 +147,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID) const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID)
return item?.type === "shell" ? item : undefined return item?.type === "shell" ? item : undefined
}, },
compaction(messages: SessionMessage[]) {
const item = messages.findLast(
(item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"),
)
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
return assistant?.content.findLast( return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool => (item): item is SessionMessageAssistantTool =>
@@ -580,8 +588,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.execution.started": case "session.execution.started":
setSessionStatus(event.data.sessionID, "running") setSessionStatus(event.data.sessionID, "running")
break break
case "session.compaction.admitted":
message.update(event.data.sessionID, (draft, index) => {
if (message.compaction(draft)) return
message.append(draft, index, {
id: event.data.inputID,
type: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
time: { created: event.created },
})
})
break
case "session.compaction.started": case "session.compaction.started":
setStore("session", "compaction", event.data.sessionID, "") setStore("session", "compaction", event.data.sessionID, "")
setStore("session", "compactionReason", event.data.sessionID, event.data.reason)
if (event.data.reason === "manual")
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.status = "running"
})
break break
case "session.execution.succeeded": case "session.execution.succeeded":
case "session.execution.failed": case "session.execution.failed":
@@ -589,6 +617,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setSessionStatus(event.data.sessionID, "idle") setSessionStatus(event.data.sessionID, "idle")
if (store.session.compaction[event.data.sessionID] !== undefined) if (store.session.compaction[event.data.sessionID] !== undefined)
setStore("session", "compaction", event.data.sessionID, undefined) setStore("session", "compaction", event.data.sessionID, undefined)
if (store.session.compactionReason[event.data.sessionID] !== undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.activeAssistant(draft) const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.retry = undefined if (currentAssistant) currentAssistant.retry = undefined
@@ -619,13 +649,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break break
case "session.compaction.delta": case "session.compaction.delta":
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text) setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
if (store.session.compactionReason[event.data.sessionID] === "manual")
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.summary += event.data.text
})
break break
case "session.compaction.ended": case "session.compaction.ended":
setStore("session", "compaction", event.data.sessionID, undefined) setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const current = event.data.reason === "manual" ? message.compaction(draft) : undefined
if (current) {
current.status = "completed"
current.reason = event.data.reason
current.summary = event.data.text
current.recent = event.data.recent
return
}
message.append(draft, index, { message.append(draft, index, {
id: messageIDFromEvent(event.id), id: messageIDFromEvent(event.id),
type: "compaction", type: "compaction",
status: "completed",
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
@@ -633,6 +678,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
}) })
break break
case "session.compaction.failed":
setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.status = "failed"
})
break
case "permission.v2.asked": case "permission.v2.asked":
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) break if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
setStore("session", "permission", event.data.sessionID, [ setStore("session", "permission", event.data.sessionID, [
@@ -785,6 +838,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
].toSorted((a, b) => a.time.created - b.time.created) ].toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages) setStore("session", "message", sessionID, messages)
const running = messages.find((message) => message.type === "compaction" && message.status === "running")
setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined)
setStore(
"session",
"compactionReason",
sessionID,
running?.type === "compaction" ? running.reason : undefined,
)
}, },
}, },
permission: { permission: {
+63 -9
View File
@@ -21,7 +21,7 @@ import { useProject } from "../../context/project"
import { useData } from "../../context/data" import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner } from "../../component/spinner" import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme" import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt" import { Prompt, type PromptRef } from "../../component/prompt"
@@ -172,6 +172,16 @@ export function Session() {
}) })
onCleanup(() => setEpilogue()) onCleanup(() => setEpilogue())
const messages = sessionMessages const messages = sessionMessages
const transientCompaction = createMemo(() => {
if (
messages().some(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
)
return
const text = data.session.compaction(route.sessionID)
return text === undefined ? undefined : { text }
})
const descendantSessionIDs = createMemo(() => { const descendantSessionIDs = createMemo(() => {
if (session()?.parentID) return [] if (session()?.parentID) return []
return data.session.family(route.sessionID).filter((id) => id !== route.sessionID) return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
@@ -926,8 +936,8 @@ export function Session() {
/> />
)} )}
</For> </For>
<Show when={data.session.compaction(route.sessionID)}> <Show when={transientCompaction()}>
{(text) => <CompactionMessage text={text()} />} {(compaction) => <CompactionMessage status="running" text={compaction().text} />}
</Show> </Show>
<BackgroundToolHint messages={messages()} /> <BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}> <Show when={session()?.revert?.messageID}>
@@ -1100,7 +1110,7 @@ function SessionMessageView(props: { message: SessionMessage }) {
</Show> </Show>
</Match> </Match>
<Match when={props.message.type === "compaction"}> <Match when={props.message.type === "compaction"}>
<CompactionMessage /> <CompactionMessage message={props.message as Extract<SessionMessage, { type: "compaction" }>} />
</Match> </Match>
</Switch> </Switch>
) )
@@ -1285,12 +1295,56 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
) )
} }
function CompactionMessage(props: { text?: string }) { function CompactionMessage(props: {
const { theme } = useTheme() message?: Extract<SessionMessage, { type: "compaction" }>
status?: "running"
text?: string
}) {
const ctx = use()
const kv = useKV()
const { theme, syntax } = useTheme()
const status = () => props.message?.status ?? props.status
const text = () => props.message?.summary ?? props.text ?? ""
const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted)
const border = () => (status() === "queued" ? theme.border : color())
return ( return (
<box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive}> <box>
<Show when={props.text}> <box flexDirection="row" alignItems="center">
<text fg={theme.textMuted}>{props.text}</text> <box border={["top"]} borderColor={border()} flexGrow={1} />
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
<Switch>
<Match when={status() === "running"}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}></text>}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
</Show>
</Match>
<Match when={status() === "completed"}>
<text fg={color()}></text>
</Match>
<Match when={status() === "failed"}>
<text fg={color()}></text>
</Match>
<Match when={status() === "queued"}>
<text fg={color()}></text>
</Match>
</Switch>
<text fg={color()}>{status() === "queued" ? "Compaction queued" : "Compaction"}</text>
</box>
<box border={["top"]} borderColor={border()} flexGrow={1} />
</box>
<Show when={text().trim()}>
<box paddingTop={1} paddingLeft={3}>
<markdown
syntaxStyle={syntax()}
streaming={status() === "running"}
internalBlockMode="top-level"
content={text().trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
</box>
</Show> </Show>
</box> </box>
) )
+33 -13
View File
@@ -83,6 +83,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
input: data.session.input.has(sessionID(), message.id), input: data.session.input.has(sessionID(), message.id),
}, },
] ]
: message.type === "compaction"
? [
{
id: message.id,
created: message.time.created,
input: message.status === "queued" || message.status === "running",
},
]
: [], : [],
), ),
() => setRows(reconcile(reduce())), () => setRows(reconcile(reduce())),
@@ -93,9 +101,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
setRows( setRows(
produce((draft) => { produce((draft) => {
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
const queued = isQueued(messageID) const pending = isPending(messageID)
const index = queued ? draft.length : queuedStart(draft) const message = data.session.message.get(sessionID(), messageID)
if (!queued) completePrevious(draft, index) const index =
message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
if (!pending) completePrevious(draft, index)
draft.splice(index, 0, { type: "message", messageID }) draft.splice(index, 0, { type: "message", messageID })
}), }),
) )
@@ -144,12 +154,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
}), }),
) )
const isQueued = (messageID: string) => { const isPending = (messageID: string) => {
return data.session.input.has(sessionID(), messageID) const message = data.session.message.get(sessionID(), messageID)
if (message?.type === "user") return data.session.input.has(sessionID(), messageID)
return message?.type === "compaction" && (message.status === "queued" || message.status === "running")
} }
const queuedStart = (rows: SessionRow[]) => { const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex((row) => row.type === "message" && isQueued(row.messageID)) const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
return index === -1 ? rows.length : index return index === -1 ? rows.length : index
} }
@@ -161,6 +173,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
} }
const subscriptions = [ const subscriptions = [
data.on("session.prompt.admitted", input), data.on("session.prompt.admitted", input),
data.on("session.compaction.admitted", input),
data.on("session.instructions.updated", message), data.on("session.instructions.updated", message),
data.on("session.synthetic", (event) => { data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim()) if (event.data.sessionID === sessionID() && event.data.description?.trim())
@@ -169,7 +182,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.shell.started", message), data.on("session.shell.started", message),
data.on("session.agent.selected", message), data.on("session.agent.selected", message),
data.on("session.model.selected", message), data.on("session.model.selected", message),
data.on("session.compaction.ended", message), data.on("session.compaction.ended", (event) => {
if (event.data.reason !== "manual") message(event)
}),
data.on("session.text.delta", (event) => { data.on("session.text.delta", (event) => {
if (event.data.sessionID === sessionID()) if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
@@ -211,11 +226,18 @@ export function createSessionRows(sessionID: Accessor<string>) {
export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<string>()) { export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<string>()) {
const isInput = (message: SessionMessage) => inputs.has(message.id) const isInput = (message: SessionMessage) => inputs.has(message.id)
return [...messages.filter((message) => !isInput(message)), ...messages.filter(isInput)].reduce<SessionRow[]>( const pendingCompactions = messages.filter(
(rows, message) => { (message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
return [
...messages.filter((message) => !pending.has(message.id)),
...pendingCompactions,
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") { if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows if (message.type === "synthetic" && !message.description?.trim()) return rows
if (!isInput(message)) completePrevious(rows) if (!pending.has(message.id)) completePrevious(rows)
rows.push({ type: "message", messageID: message.id }) rows.push({ type: "message", messageID: message.id })
return rows return rows
} }
@@ -230,9 +252,7 @@ export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<s
rows.push({ type: "assistant-footer", messageID: message.id }) rows.push({ type: "assistant-footer", messageID: message.id })
} }
return rows return rows
}, }, [])
[],
)
} }
export function resolvePart(message: SessionMessageAssistant, partID: string) { export function resolvePart(message: SessionMessageAssistant, partID: string) {
+103
View File
@@ -108,6 +108,64 @@ test("refreshes resources into reactive getters", async () => {
} }
}) })
test("restores running manual compaction before applying live deltas", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-compaction/message")
return json({
data: [
{
id: "message-compaction",
type: "compaction",
status: "running",
reason: "manual",
summary: "Existing ",
recent: "",
time: { created: 1 },
},
],
cursor: {},
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await data.session.message.refresh("session-compaction")
expect(data.session.compaction("session-compaction")).toBe("Existing ")
emitEvent(events, {
id: "evt_compaction_delta",
created: 2,
type: "session.compaction.delta",
data: { sessionID: "session-compaction", text: "summary" },
})
await wait(() => {
const message = data.session.message.get("session-compaction", "message-compaction")
return message?.type === "compaction" && message.summary === "Existing summary"
})
} finally {
app.renderer.destroy()
}
})
test("reconnects the event stream and bootstraps fresh data", async () => { test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventStream() const events = createEventStream()
const requests = { active: 0, event: 0, model: 0 } const requests = { active: 0, event: 0, model: 0 }
@@ -402,10 +460,12 @@ test("tracks session status from active sessions and execution events", async ()
}, events) }, events)
let data!: ReturnType<typeof useData> let data!: ReturnType<typeof useData>
let rows!: SessionRow[] let rows!: SessionRow[]
let manualRows!: SessionRow[]
function Probe() { function Probe() {
data = useData() data = useData()
rows = createSessionRows(() => "session-retry") rows = createSessionRows(() => "session-retry")
manualRows = createSessionRows(() => "session-manual")
return <box /> return <box />
} }
@@ -610,6 +670,49 @@ test("tracks session status from active sessions and execution events", async ()
await wait(() => data.session.status("session-retry") === "idle") await wait(() => data.session.status("session-retry") === "idle")
expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry") expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry")
emitEvent(events, {
id: "evt_compaction_admitted",
created: 0,
type: "session.compaction.admitted",
durable: durable("session-manual", 1),
data: { sessionID: "session-manual", inputID: "message-compaction" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "queued"
})
emitEvent(events, {
id: "evt_manual_compaction_started",
created: 1,
type: "session.compaction.started",
durable: durable("session-manual", 2),
data: { sessionID: "session-manual", reason: "manual" },
})
emitEvent(events, {
id: "evt_manual_compaction_delta",
created: 2,
type: "session.compaction.delta",
data: { sessionID: "session-manual", text: "Streamed summary" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.summary === "Streamed summary"
})
emitEvent(events, {
id: "evt_manual_compaction_ended",
created: 3,
type: "session.compaction.ended",
durable: durable("session-manual", 3),
data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" },
})
await wait(() => {
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "completed"
})
expect(manualRows.filter((row) => row.type === "message")).toEqual([
{ type: "message", messageID: "message-compaction" },
])
emitEvent(events, { emitEvent(events, {
id: "evt_compaction_started", id: "evt_compaction_started",
created: 0, created: 0,
@@ -209,6 +209,34 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }]) expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
}) })
test("places a pending compaction barrier before every queued user message", () => {
const queued = (id: string, text: string, created: number): SessionMessage => ({
type: "user",
id,
text,
time: { created },
})
const messages: SessionMessage[] = [
queued("user-before", "Before", 1),
{
type: "compaction",
id: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
time: { created: 2 },
},
queued("user-after", "After", 3),
]
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
{ type: "message", messageID: "compaction" },
{ type: "message", messageID: "user-before" },
{ type: "message", messageID: "user-after" },
])
})
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant { function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return { return {
type: "assistant", type: "assistant",
+2 -3
View File
@@ -32,7 +32,7 @@ sessions.active()
-> absence means inactive; activity is not durable across process restarts -> absence means inactive; activity is not durable across process restarts
``` ```
`session_input` is the durable admission inbox. `PromptAdmitted` records and projects accepted input so pending queue state can be replayed, replicated, and observed by clients. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts. `session_input` is the typed durable admission inbox for prompts and Session control operations. `PromptAdmitted` records accepted user input; `Compaction.Admitted` records one coalesced manual compaction barrier. Admitted prompts remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. A pending compaction blocks all unpromoted prompts, runs before the Session would otherwise become idle, and releases the backlog only after its durable ended or failed event settles the barrier. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts.
`admittedSeq` is the durable Session event sequence of `PromptAdmitted`. Clients may use the admission event to represent queued input before `Prompted` makes it part of visible conversation history. `admittedSeq` is the durable Session event sequence of `PromptAdmitted`. Clients may use the admission event to represent queued input before `Prompted` makes it part of visible conversation history.
@@ -120,7 +120,6 @@ Current instruction follow-ups:
- Add configured and remote instruction sources with explicit precedence and removal semantics. - Add configured and remote instruction sources with explicit precedence and removal semantics.
- Add durable post-crash continuation recovery for promoted or provider-dispatched work. - Add durable post-crash continuation recovery for promoted or provider-dispatched work.
- Add explicit manual compaction on top of automatic request-budget compaction.
- Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth. - Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth.
- Consider watcher-backed per-file caching only if measurements show direct step-boundary observation is too expensive. - Consider watcher-backed per-file caching only if measurements show direct step-boundary observation is too expensive.
- Design any plugin-defined instruction contribution as an explicit runner composition boundary; do not reintroduce a registry implicitly. - Design any plugin-defined instruction contribution as an explicit runner composition boundary; do not reintroduce a registry implicitly.
@@ -134,7 +133,7 @@ Compaction keeps the full transcript durable while replacing its active model re
The rolling summary is a continuation checkpoint with this complete heading order: `Objective`, `Important Details`, `Work State`, and `Next Move`. `Work State` records completed, active, and blocked work, while `Next Move` records the immediate and following actions. Every heading remains present even when its value is `(none)`. The rolling summary is a continuation checkpoint with this complete heading order: `Objective`, `Important Details`, `Work State`, and `Next Move`. `Work State` records completed, active, and blocked work, while `Next Move` records the immediate and following actions. Every heading remains present even when its value is `(none)`.
`session.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next physical attempt, the runner observes that completed compaction and directly renders a fresh instruction baseline through `InstructionCheckpoint`. A failed or interrupted attempt therefore leaves the previous history boundary active. `session.compaction.admitted.1` durably records a manual request and projects its queued transcript row. `session.compaction.started.1` identifies the attempt and transforms that row into a running divider. Compaction deltas are live-only progress rendered beneath it. `session.compaction.ended.1` durably stores the final summary and serialized recent context, completes the same row, and settles the manual barrier. `session.compaction.failed.1` settles an unsuccessful manual barrier without changing the previous history boundary. On the next physical attempt, the runner observes a completed compaction and directly renders a fresh instruction baseline through `InstructionCheckpoint`.
Assistant text and reasoning follow a strict `started` / live-only `delta` / durable full-value `ended` lifecycle. A publisher permits at most one open fragment of each kind in a step and fails on a second start before the matching end. Provider block IDs remain internal to LLM adapters; each fragment event carries a Session-assigned kind-specific ordinal, matching the ordinal derived from projected content. UI identity is therefore the assistant message ID plus content kind and ordinal. Tool calls retain step-scoped `callID` because settlements and provider replay correlate through it. Assistant text and reasoning follow a strict `started` / live-only `delta` / durable full-value `ended` lifecycle. A publisher permits at most one open fragment of each kind in a step and fails on a second start before the matching end. Provider block IDs remain internal to LLM adapters; each fragment event carries a Session-assigned kind-specific ordinal, matching the ordinal derived from projected content. UI identity is therefore the assistant message ID plus content kind and ordinal. Tool calls retain step-scoped `callID` because settlements and provider replay correlate through it.