@opengeni/db 0.22.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/{chunk-CYGFLLMN.js → chunk-7WTDI7Y3.js} +764 -283
  2. package/dist/chunk-7WTDI7Y3.js.map +1 -0
  3. package/dist/{chunk-BNGEN5QZ.js → chunk-KW6U54V2.js} +26 -2
  4. package/dist/chunk-KW6U54V2.js.map +1 -0
  5. package/dist/connection-token-resolver.d.ts +24 -2
  6. package/dist/index.d.ts +107 -5
  7. package/dist/index.js +7857 -3898
  8. package/dist/index.js.map +1 -1
  9. package/dist/preference-registry.d.ts +14 -0
  10. package/dist/provision-roles.js +1 -1
  11. package/dist/runtime-posture.d.ts +3 -3
  12. package/dist/schema.d.ts +1659 -77
  13. package/dist/schema.js +13 -1
  14. package/dist/session-control.d.ts +7 -1
  15. package/dist/session-queue-commands.d.ts +8 -0
  16. package/dist/session-realtime-context.d.ts +56 -0
  17. package/dist/session-realtime-ledger.d.ts +188 -0
  18. package/dist/session-realtime-mirror.d.ts +51 -0
  19. package/dist/session-realtime-state.d.ts +2 -0
  20. package/dist/session-realtime-terminal.d.ts +40 -0
  21. package/dist/session-realtime.d.ts +59 -0
  22. package/dist/workspace-instruction-policies-schema.d.ts +307 -0
  23. package/dist/workspace-instruction-policies.d.ts +97 -7
  24. package/drizzle/0156_slack_reaction_trigger.sql +49 -0
  25. package/drizzle/0157_session_policy_role_snapshots.sql +1146 -0
  26. package/drizzle/0158_session_realtime_mode.sql +88 -0
  27. package/drizzle/0159_session_realtime_ledger.sql +198 -0
  28. package/drizzle/0160_session_realtime_delegation_terminal.sql +38 -0
  29. package/drizzle/0161_session_realtime_context_projection.sql +82 -0
  30. package/drizzle/0162_session_realtime_connection_promotion.sql +53 -0
  31. package/drizzle/0163_session_realtime_delegation_progress.sql +35 -0
  32. package/drizzle/0164_session_realtime_models.sql +28 -0
  33. package/drizzle/0165_document_authority_foundation.sql +259 -0
  34. package/drizzle/0166_connection_disconnect_idempotency.sql +49 -0
  35. package/drizzle/0167_document_index_replay_authority.sql +61 -0
  36. package/drizzle/0168_workspace_instruction_policy_operation_receipts.sql +44 -0
  37. package/package.json +4 -4
  38. package/src/connection-token-resolver.ts +79 -16
  39. package/src/index.ts +1033 -101
  40. package/src/preference-registry.ts +114 -6
  41. package/src/provision-roles.ts +12 -0
  42. package/src/runtime-posture.ts +12 -0
  43. package/src/schema.ts +486 -43
  44. package/src/session-control.ts +643 -21
  45. package/src/session-queue-commands.ts +121 -18
  46. package/src/session-realtime-context.ts +393 -0
  47. package/src/session-realtime-ledger.ts +1790 -0
  48. package/src/session-realtime-mirror.ts +276 -0
  49. package/src/session-realtime-state.ts +25 -0
  50. package/src/session-realtime-terminal.ts +306 -0
  51. package/src/session-realtime.ts +659 -0
  52. package/src/workspace-instruction-policies-schema.ts +71 -0
  53. package/src/workspace-instruction-policies.ts +568 -25
  54. package/dist/chunk-BNGEN5QZ.js.map +0 -1
  55. package/dist/chunk-CYGFLLMN.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import {
3
3
  boundWorkspaceControlEvent,
4
+ McpPersonalConnectionDelegations,
4
5
  workspaceControlUtf8Bytes,
5
6
  type SessionMcpApprovalPolicy,
6
7
  type TurnInitiatorContext,
@@ -8,6 +9,11 @@ import {
8
9
  import { and, eq, inArray, sql } from "drizzle-orm";
9
10
  import type { Database } from "./index";
10
11
  import * as schema from "./schema";
12
+ import { closePendingSessionToolCallsInTransaction } from "./session-tool-call-settlement";
13
+ import {
14
+ mirrorSessionRealtimeContextInTransaction,
15
+ renderRealtimeHumanInputResponseContext,
16
+ } from "./session-realtime-mirror";
11
17
 
12
18
  export const SESSION_ANCESTRY_LIMIT = 10_000;
13
19
 
@@ -1575,6 +1581,55 @@ async function registerInterruptionWakes(
1575
1581
  return Number(rows[0]?.wakeCount ?? 0);
1576
1582
  }
1577
1583
 
1584
+ /**
1585
+ * Terminal cancellation must wake every affected workflow, including sessions
1586
+ * parked at an approval, capacity, or other no-active-attempt boundary. Those
1587
+ * sessions have no new interruption row, so the ordinary Pause wake selector
1588
+ * cannot discover them. Signal-with-start is intentional here: a closed
1589
+ * workflow cheaply re-reads the terminal row and exits, while an open workflow
1590
+ * is released from an otherwise unbounded durable wait.
1591
+ */
1592
+ async function registerCancellationWakes(
1593
+ db: Database,
1594
+ input: { workspaceId: string; sessionIds: string[] },
1595
+ ): Promise<number> {
1596
+ if (input.sessionIds.length === 0) return 0;
1597
+ const sessionIds = sql.join(
1598
+ input.sessionIds.map((id) => sql`${id}::uuid`),
1599
+ sql`, `,
1600
+ );
1601
+ const rows = await db.execute<{ wakeCount: number | string }>(sql`
1602
+ with eligible as (
1603
+ select
1604
+ session.id as session_id,
1605
+ session.account_id,
1606
+ session.workspace_id,
1607
+ coalesce(session.temporal_workflow_id, 'session-' || session.id::text)
1608
+ as temporal_workflow_id
1609
+ from ${schema.sessions} session
1610
+ where session.workspace_id = ${input.workspaceId}
1611
+ and session.id in (${sessionIds})
1612
+ ), upserted as (
1613
+ insert into ${schema.sessionWorkflowWakeOutbox} (
1614
+ session_id, account_id, workspace_id, temporal_workflow_id, reason
1615
+ )
1616
+ select session_id, account_id, workspace_id, temporal_workflow_id, 'session_cancelled'
1617
+ from eligible
1618
+ on conflict (session_id) do update set
1619
+ wake_revision = ${schema.sessionWorkflowWakeOutbox}.wake_revision + 1,
1620
+ temporal_workflow_id = excluded.temporal_workflow_id,
1621
+ reason = excluded.reason,
1622
+ attempts = 0,
1623
+ next_attempt_at = now(),
1624
+ last_error = null,
1625
+ updated_at = now()
1626
+ returning session_id
1627
+ )
1628
+ select count(*)::bigint as "wakeCount" from upserted
1629
+ `);
1630
+ return Number(rows[0]?.wakeCount ?? 0);
1631
+ }
1632
+
1578
1633
  /** Register one exact post-commit Temporal nudge without encoding eligibility in
1579
1634
  * the transport. The workflow re-reads canonical Postgres state; coalescing is
1580
1635
  * revisioned so a lost or stale delivery cannot hide a later mutation. */
@@ -1786,9 +1841,495 @@ export type SessionControlMutationResult = {
1786
1841
  workspaceControlEventId: string;
1787
1842
  interruptionCount: number;
1788
1843
  wakeCount: number;
1844
+ cancelledSessionCount: number;
1845
+ cancelledTurnCount: number;
1846
+ affectedSessionEvents: Array<{ sessionId: string; eventIds: string[] }>;
1789
1847
  replay: boolean;
1790
1848
  };
1791
1849
 
1850
+ async function loadSessionSubtreeIds(
1851
+ db: Database,
1852
+ workspaceId: string,
1853
+ rootSessionId: string,
1854
+ ): Promise<{ sessionIds: string[]; rootParentSessionId: string | null }> {
1855
+ const rows: Array<{
1856
+ id: string;
1857
+ rootParentSessionId: string | null;
1858
+ depth: number | string;
1859
+ cycle: boolean;
1860
+ }> = await db.execute(sql`
1861
+ with recursive subtree(id, root_parent_session_id, depth, path, cycle) as (
1862
+ select session.id, session.parent_session_id, 0::integer, array[session.id]::uuid[], false
1863
+ from ${schema.sessions} session
1864
+ where session.workspace_id = ${workspaceId} and session.id = ${rootSessionId}
1865
+ union all
1866
+ select child.id, parent.root_parent_session_id, parent.depth + 1,
1867
+ parent.path || child.id, child.id = any(parent.path)
1868
+ from subtree parent
1869
+ join ${schema.sessions} child
1870
+ on child.workspace_id = ${workspaceId} and child.parent_session_id = parent.id
1871
+ where parent.depth < ${SESSION_ANCESTRY_LIMIT} and not parent.cycle
1872
+ )
1873
+ select id, root_parent_session_id as "rootParentSessionId", depth, cycle
1874
+ from subtree
1875
+ order by id
1876
+ `);
1877
+ if (rows.length === 0) {
1878
+ throw new SessionControlInvariantError(`Session ${rootSessionId} does not exist`);
1879
+ }
1880
+ if (rows.some((row) => row.cycle || Number(row.depth) >= SESSION_ANCESTRY_LIMIT)) {
1881
+ throw new SessionControlInvariantError(`Session ${rootSessionId} subtree is invalid`);
1882
+ }
1883
+ return {
1884
+ sessionIds: rows.map((row) => row.id),
1885
+ rootParentSessionId: rows[0]?.rootParentSessionId ?? null,
1886
+ };
1887
+ }
1888
+
1889
+ const TERMINAL_CANCELLATION_LIVE_TURN_STATUSES = [
1890
+ "queued",
1891
+ "running",
1892
+ "requires_action",
1893
+ "recovering",
1894
+ "waiting_capacity",
1895
+ ];
1896
+
1897
+ async function loadCancellationTurnIds(
1898
+ db: Database,
1899
+ workspaceId: string,
1900
+ sessionIds: string[],
1901
+ ): Promise<string[]> {
1902
+ const rows = await db
1903
+ .select({ id: schema.sessionTurns.id })
1904
+ .from(schema.sessionTurns)
1905
+ .where(
1906
+ and(
1907
+ eq(schema.sessionTurns.workspaceId, workspaceId),
1908
+ inArray(schema.sessionTurns.sessionId, sessionIds),
1909
+ inArray(schema.sessionTurns.status, TERMINAL_CANCELLATION_LIVE_TURN_STATUSES),
1910
+ ),
1911
+ )
1912
+ .orderBy(schema.sessionTurns.id);
1913
+ return rows.map((row) => row.id);
1914
+ }
1915
+
1916
+ async function enqueueCancelledChildOutboxInTransaction(
1917
+ db: Database,
1918
+ input: {
1919
+ workspaceId: string;
1920
+ rootSession: typeof schema.sessions.$inferSelect;
1921
+ parentSession: typeof schema.sessions.$inferSelect | null;
1922
+ },
1923
+ ): Promise<void> {
1924
+ const parentSessionId = input.rootSession.parentSessionId;
1925
+ if (!parentSessionId) return;
1926
+ if (!input.parentSession || input.parentSession.id !== parentSessionId) {
1927
+ throw new SessionControlInvariantError(
1928
+ `Parent session ${parentSessionId} was not locked with cancellation root ${input.rootSession.id}`,
1929
+ );
1930
+ }
1931
+ if (input.parentSession.status === "cancelled") return;
1932
+ let personalConnectionDelegations: (typeof schema.sessionTurns.$inferSelect)["personalConnectionDelegations"] =
1933
+ [];
1934
+ if (input.rootSession.parentTurnId) {
1935
+ const [parentTurn] = await db
1936
+ .select({ delegations: schema.sessionTurns.personalConnectionDelegations })
1937
+ .from(schema.sessionTurns)
1938
+ .where(
1939
+ and(
1940
+ eq(schema.sessionTurns.workspaceId, input.workspaceId),
1941
+ eq(schema.sessionTurns.sessionId, parentSessionId),
1942
+ eq(schema.sessionTurns.id, input.rootSession.parentTurnId),
1943
+ ),
1944
+ )
1945
+ .limit(1);
1946
+ if (parentTurn) {
1947
+ const parsed = McpPersonalConnectionDelegations.safeParse(parentTurn.delegations);
1948
+ if (!parsed.success) {
1949
+ throw new SessionControlInvariantError(
1950
+ `Invalid personal MCP delegation snapshot at session_turns:${input.workspaceId}:${parentSessionId}:${input.rootSession.parentTurnId}`,
1951
+ );
1952
+ }
1953
+ personalConnectionDelegations = parsed.data.map((delegation) => ({ ...delegation }));
1954
+ }
1955
+ }
1956
+ await db
1957
+ .insert(schema.sessionSystemUpdateOutbox)
1958
+ .values({
1959
+ accountId: input.rootSession.accountId,
1960
+ workspaceId: input.workspaceId,
1961
+ sourceSessionId: input.rootSession.id,
1962
+ targetSessionId: parentSessionId,
1963
+ dedupeKey: `child-completion:${input.rootSession.id}:cancelled`,
1964
+ kind: "child_terminal_result",
1965
+ classification: "info",
1966
+ sourceId: input.rootSession.id,
1967
+ summary: "Child session was terminally cancelled.",
1968
+ payload: {
1969
+ type: "child_terminal_result",
1970
+ childSessionId: input.rootSession.id,
1971
+ status: "cancelled",
1972
+ },
1973
+ lineage: {
1974
+ childSessionId: input.rootSession.id,
1975
+ parentSessionId,
1976
+ ...(input.rootSession.parentTurnId ? { parentTurnId: input.rootSession.parentTurnId } : {}),
1977
+ },
1978
+ personalConnectionDelegations,
1979
+ })
1980
+ .onConflictDoNothing({
1981
+ target: [
1982
+ schema.sessionSystemUpdateOutbox.workspaceId,
1983
+ schema.sessionSystemUpdateOutbox.dedupeKey,
1984
+ ],
1985
+ });
1986
+ }
1987
+
1988
+ async function assertSessionBranchIsNotCancelled(
1989
+ db: Database,
1990
+ workspaceId: string,
1991
+ sessionId: string,
1992
+ ): Promise<void> {
1993
+ const rows = await db.execute<{
1994
+ cancelled: boolean;
1995
+ invalid: boolean;
1996
+ }>(sql`
1997
+ with recursive ancestry(id, parent_id, status, depth, path, cycle) as (
1998
+ select session.id, session.parent_session_id, session.status, 0::integer,
1999
+ array[session.id]::uuid[], false
2000
+ from ${schema.sessions} session
2001
+ where session.workspace_id = ${workspaceId} and session.id = ${sessionId}
2002
+ union all
2003
+ select parent.id, parent.parent_session_id, parent.status, child.depth + 1,
2004
+ child.path || parent.id, parent.id = any(child.path)
2005
+ from ancestry child
2006
+ join ${schema.sessions} parent
2007
+ on parent.workspace_id = ${workspaceId} and parent.id = child.parent_id
2008
+ where child.depth < ${SESSION_ANCESTRY_LIMIT} and not child.cycle
2009
+ )
2010
+ select
2011
+ coalesce(bool_or(status = 'cancelled'), false) as cancelled,
2012
+ count(*) = 0 or coalesce(bool_or(cycle), false)
2013
+ or coalesce(max(depth), 0) >= ${SESSION_ANCESTRY_LIMIT} as invalid
2014
+ from ancestry
2015
+ `);
2016
+ if (rows[0]?.invalid) {
2017
+ throw new SessionControlInvariantError(`Session ${sessionId} ancestry is invalid`);
2018
+ }
2019
+ if (rows[0]?.cancelled) {
2020
+ throw new SessionControlConflictError("Cancelled session subtree cannot accept work");
2021
+ }
2022
+ }
2023
+
2024
+ async function cancelSessionSubtreeInTransaction(
2025
+ db: Database,
2026
+ input: {
2027
+ accountId: string;
2028
+ workspaceId: string;
2029
+ rootSessionId: string;
2030
+ sessionIds: string[];
2031
+ lockedSessions: Array<typeof schema.sessions.$inferSelect>;
2032
+ candidateTurnIds: string[];
2033
+ lockedTurns: Array<typeof schema.sessionTurns.$inferSelect>;
2034
+ actor: string;
2035
+ reason: string | null;
2036
+ operationId: string;
2037
+ rootControlEventId: string;
2038
+ },
2039
+ ): Promise<{
2040
+ cancelledSessionCount: number;
2041
+ cancelledTurnCount: number;
2042
+ affectedSessionEvents: Array<{ sessionId: string; eventIds: string[] }>;
2043
+ }> {
2044
+ const candidateTurnIdSet = new Set(input.candidateTurnIds);
2045
+ const candidateTurns = input.lockedTurns.filter((turn) => candidateTurnIdSet.has(turn.id));
2046
+ if (candidateTurns.length !== candidateTurnIdSet.size) {
2047
+ throw new SessionControlInvariantError("Cancellation candidate turns changed while locking");
2048
+ }
2049
+ // The outer command acquired every session and candidate turn in one UUID-
2050
+ // sorted call before the actor attempt. Repeat only those already-held locks
2051
+ // so this event-writing suffix retains a directly auditable lock contract;
2052
+ // this call must never discover or acquire a new row.
2053
+ const suffixLocks = await lockSessionEventWriteRows(db, {
2054
+ workspaceId: input.workspaceId,
2055
+ controlLock: "already_locked",
2056
+ workspaceLock: "already_locked",
2057
+ sessionIds: input.lockedSessions.map((session) => session.id),
2058
+ turnIds: input.candidateTurnIds,
2059
+ });
2060
+ if (
2061
+ suffixLocks.sessions.length !== input.lockedSessions.length ||
2062
+ suffixLocks.turns.length !== candidateTurnIdSet.size
2063
+ ) {
2064
+ throw new SessionControlInvariantError("Cancellation rows changed under canonical locks");
2065
+ }
2066
+ const sessionIdSet = new Set(input.sessionIds);
2067
+ const rootSession = input.lockedSessions.find((session) => session.id === input.rootSessionId);
2068
+ if (!rootSession) {
2069
+ throw new SessionControlInvariantError(
2070
+ `Cancellation root ${input.rootSessionId} was not locked with its subtree`,
2071
+ );
2072
+ }
2073
+ const rootParentSession = rootSession.parentSessionId
2074
+ ? (input.lockedSessions.find((session) => session.id === rootSession.parentSessionId) ?? null)
2075
+ : null;
2076
+ const candidateTurnById = new Map(candidateTurns.map((turn) => [turn.id, turn]));
2077
+ const liveTurnIds = new Set(
2078
+ input.lockedSessions
2079
+ .filter((session) => session.activeTurnId !== null)
2080
+ .flatMap((session) => {
2081
+ const turn = session.activeTurnId ? candidateTurnById.get(session.activeTurnId) : undefined;
2082
+ if (turn?.activeAttemptId === null) return [];
2083
+ return turn ? [turn.id] : [];
2084
+ }),
2085
+ );
2086
+ const immediatelyCancelledTurns = candidateTurns.filter((turn) => !liveTurnIds.has(turn.id));
2087
+ const cancelledTurnsBySession = new Map<string, typeof immediatelyCancelledTurns>();
2088
+ for (const turn of immediatelyCancelledTurns) {
2089
+ const turns = cancelledTurnsBySession.get(turn.sessionId);
2090
+ if (turns) turns.push(turn);
2091
+ else cancelledTurnsBySession.set(turn.sessionId, [turn]);
2092
+ }
2093
+ const immediatelyCancelledTurnIds = immediatelyCancelledTurns.map((turn) => turn.id);
2094
+ const now = new Date();
2095
+ let cancelledHumanInputs: Array<{
2096
+ id: string;
2097
+ sessionId: string;
2098
+ turnId: string;
2099
+ questions: (typeof schema.sessionHumanInputRequests.$inferSelect)["questions"];
2100
+ }> = [];
2101
+ if (immediatelyCancelledTurnIds.length > 0) {
2102
+ await db
2103
+ .update(schema.sessionTurns)
2104
+ .set({
2105
+ status: "cancelled",
2106
+ activeAttemptId: null,
2107
+ cancelledBy: input.actor,
2108
+ cancelReason: input.reason ?? "session_cancelled",
2109
+ version: sql`${schema.sessionTurns.version} + 1`,
2110
+ finishedAt: now,
2111
+ updatedAt: now,
2112
+ })
2113
+ .where(inArray(schema.sessionTurns.id, immediatelyCancelledTurnIds));
2114
+ cancelledHumanInputs = await db
2115
+ .update(schema.sessionHumanInputRequests)
2116
+ .set({
2117
+ status: "cancelled",
2118
+ response: { outcome: "cancelled" },
2119
+ respondedBy: input.actor,
2120
+ respondedAt: now,
2121
+ updatedAt: now,
2122
+ })
2123
+ .where(
2124
+ and(
2125
+ eq(schema.sessionHumanInputRequests.workspaceId, input.workspaceId),
2126
+ inArray(schema.sessionHumanInputRequests.turnId, immediatelyCancelledTurnIds),
2127
+ eq(schema.sessionHumanInputRequests.status, "pending"),
2128
+ ),
2129
+ )
2130
+ .returning({
2131
+ id: schema.sessionHumanInputRequests.id,
2132
+ sessionId: schema.sessionHumanInputRequests.sessionId,
2133
+ turnId: schema.sessionHumanInputRequests.turnId,
2134
+ questions: schema.sessionHumanInputRequests.questions,
2135
+ });
2136
+ await db
2137
+ .update(schema.codexCapacityWaiters)
2138
+ .set({ status: "superseded", lastWakeReason: "session_cancelled", updatedAt: now })
2139
+ .where(
2140
+ and(
2141
+ eq(schema.codexCapacityWaiters.workspaceId, input.workspaceId),
2142
+ inArray(schema.codexCapacityWaiters.blockedTurnId, immediatelyCancelledTurnIds),
2143
+ eq(schema.codexCapacityWaiters.status, "waiting"),
2144
+ ),
2145
+ );
2146
+ }
2147
+ const cancelledSystemUpdates = await db
2148
+ .update(schema.sessionSystemUpdates)
2149
+ .set({ state: "cancelled" })
2150
+ .where(
2151
+ and(
2152
+ eq(schema.sessionSystemUpdates.workspaceId, input.workspaceId),
2153
+ inArray(schema.sessionSystemUpdates.sessionId, input.sessionIds),
2154
+ eq(schema.sessionSystemUpdates.state, "pending"),
2155
+ ),
2156
+ )
2157
+ .returning({
2158
+ id: schema.sessionSystemUpdates.id,
2159
+ sessionId: schema.sessionSystemUpdates.sessionId,
2160
+ });
2161
+ const humanInputsByTurn = new Map<string, typeof cancelledHumanInputs>();
2162
+ for (const humanInput of cancelledHumanInputs) {
2163
+ const inputs = humanInputsByTurn.get(humanInput.turnId);
2164
+ if (inputs) inputs.push(humanInput);
2165
+ else humanInputsByTurn.set(humanInput.turnId, [humanInput]);
2166
+ }
2167
+ const systemUpdatesBySession = new Map<string, typeof cancelledSystemUpdates>();
2168
+ for (const update of cancelledSystemUpdates) {
2169
+ const updates = systemUpdatesBySession.get(update.sessionId);
2170
+ if (updates) updates.push(update);
2171
+ else systemUpdatesBySession.set(update.sessionId, [update]);
2172
+ }
2173
+
2174
+ const affectedSessionEvents: Array<{ sessionId: string; eventIds: string[] }> = [];
2175
+ for (const session of input.lockedSessions) {
2176
+ if (!sessionIdSet.has(session.id)) continue;
2177
+ let sequence = session.lastSequence + (session.id === input.rootSessionId ? 1 : 0);
2178
+ const cancelledTurns = cancelledTurnsBySession.get(session.id) ?? [];
2179
+ const preinsertedEvents: Array<{ id: string; sequence: number }> = [];
2180
+ const eventValues: Array<typeof schema.sessionEvents.$inferInsert> = [];
2181
+ for (const update of (systemUpdatesBySession.get(session.id) ?? []).sort((left, right) =>
2182
+ left.id.localeCompare(right.id),
2183
+ )) {
2184
+ eventValues.push({
2185
+ accountId: input.accountId,
2186
+ workspaceId: input.workspaceId,
2187
+ sessionId: session.id,
2188
+ sequence: ++sequence,
2189
+ type: "system.update.cancelled",
2190
+ payload: { updateId: update.id, reason: "session_cancelled" },
2191
+ occurredAt: now,
2192
+ });
2193
+ }
2194
+ for (const turn of cancelledTurns) {
2195
+ const closedTools = await closePendingSessionToolCallsInTransaction(db, {
2196
+ accountId: input.accountId,
2197
+ workspaceId: input.workspaceId,
2198
+ sessionId: session.id,
2199
+ turnId: turn.id,
2200
+ reason: "session_cancelled",
2201
+ sequence,
2202
+ now,
2203
+ });
2204
+ sequence = closedTools.sequence;
2205
+ preinsertedEvents.push(
2206
+ ...closedTools.events.map((event) => ({ id: event.id, sequence: event.sequence })),
2207
+ );
2208
+ for (const humanInput of (humanInputsByTurn.get(turn.id) ?? []).sort((left, right) =>
2209
+ left.id.localeCompare(right.id),
2210
+ )) {
2211
+ eventValues.push({
2212
+ accountId: input.accountId,
2213
+ workspaceId: input.workspaceId,
2214
+ sessionId: session.id,
2215
+ sequence: ++sequence,
2216
+ type: "user.humanInputResponse",
2217
+ turnId: turn.id,
2218
+ turnGeneration: turn.executionGeneration,
2219
+ ...(turn.id === session.activeTurnId ? { turnAssociation: "current" as const } : {}),
2220
+ payload: { requestId: humanInput.id, response: { outcome: "cancelled" } },
2221
+ occurredAt: now,
2222
+ });
2223
+ }
2224
+ eventValues.push({
2225
+ accountId: input.accountId,
2226
+ workspaceId: input.workspaceId,
2227
+ sessionId: session.id,
2228
+ sequence: ++sequence,
2229
+ type: "turn.cancelled",
2230
+ turnId: turn.id,
2231
+ turnGeneration: turn.executionGeneration,
2232
+ ...(turn.id === session.activeTurnId ? { turnAssociation: "current" as const } : {}),
2233
+ payload: {
2234
+ reason: input.reason ?? "session_cancelled",
2235
+ operationId: input.operationId,
2236
+ },
2237
+ occurredAt: now,
2238
+ });
2239
+ }
2240
+ if (session.status !== "cancelled") {
2241
+ eventValues.push({
2242
+ accountId: input.accountId,
2243
+ workspaceId: input.workspaceId,
2244
+ sessionId: session.id,
2245
+ sequence: ++sequence,
2246
+ type: "session.status.changed",
2247
+ payload: {
2248
+ status: "cancelled",
2249
+ reason: input.reason ?? "session_cancelled",
2250
+ operationId: input.operationId,
2251
+ },
2252
+ occurredAt: now,
2253
+ });
2254
+ }
2255
+ const inserted =
2256
+ eventValues.length > 0
2257
+ ? await db.insert(schema.sessionEvents).values(eventValues).returning({
2258
+ id: schema.sessionEvents.id,
2259
+ sequence: schema.sessionEvents.sequence,
2260
+ type: schema.sessionEvents.type,
2261
+ turnId: schema.sessionEvents.turnId,
2262
+ payload: schema.sessionEvents.payload,
2263
+ })
2264
+ : [];
2265
+ const cancelledHumanInputsById = new Map(
2266
+ cancelledHumanInputs
2267
+ .filter((request) => request.sessionId === session.id)
2268
+ .map((request) => [request.id, request]),
2269
+ );
2270
+ for (const event of inserted) {
2271
+ if (event.type !== "user.humanInputResponse") continue;
2272
+ const payload = event.payload as { requestId?: unknown };
2273
+ const request =
2274
+ typeof payload.requestId === "string"
2275
+ ? cancelledHumanInputsById.get(payload.requestId)
2276
+ : null;
2277
+ if (!request) continue;
2278
+ await mirrorSessionRealtimeContextInTransaction(db, {
2279
+ accountId: input.accountId,
2280
+ workspaceId: input.workspaceId,
2281
+ sessionId: session.id,
2282
+ sourceKind: "human_input_response",
2283
+ sourceId: event.id,
2284
+ turnId: event.turnId,
2285
+ channel: null,
2286
+ text: renderRealtimeHumanInputResponseContext({
2287
+ requestId: request.id,
2288
+ questions: request.questions,
2289
+ response: { outcome: "cancelled" },
2290
+ }),
2291
+ payload: {
2292
+ requestId: request.id,
2293
+ outcome: "cancelled",
2294
+ sourceEventId: event.id,
2295
+ },
2296
+ now,
2297
+ });
2298
+ }
2299
+ const liveTurnId =
2300
+ session.activeTurnId && liveTurnIds.has(session.activeTurnId) ? session.activeTurnId : null;
2301
+ await db
2302
+ .update(schema.sessions)
2303
+ .set({
2304
+ status: "cancelled",
2305
+ activeTurnId: liveTurnId,
2306
+ queueVersion: sql`${schema.sessions.queueVersion} + 1`,
2307
+ queueHeadPosition: 0,
2308
+ queueTailPosition: 0,
2309
+ lastSequence: sequence,
2310
+ updatedAt: now,
2311
+ })
2312
+ .where(
2313
+ and(eq(schema.sessions.workspaceId, input.workspaceId), eq(schema.sessions.id, session.id)),
2314
+ );
2315
+ const eventIds = [...preinsertedEvents, ...inserted]
2316
+ .sort((left, right) => left.sequence - right.sequence)
2317
+ .map((event) => event.id);
2318
+ if (session.id === input.rootSessionId) eventIds.unshift(input.rootControlEventId);
2319
+ if (eventIds.length > 0) affectedSessionEvents.push({ sessionId: session.id, eventIds });
2320
+ }
2321
+ await enqueueCancelledChildOutboxInTransaction(db, {
2322
+ workspaceId: input.workspaceId,
2323
+ rootSession,
2324
+ parentSession: rootParentSession,
2325
+ });
2326
+ return {
2327
+ cancelledSessionCount: input.sessionIds.length,
2328
+ cancelledTurnCount: candidateTurns.length,
2329
+ affectedSessionEvents,
2330
+ };
2331
+ }
2332
+
1792
2333
  export async function mutateSessionControlInTransaction(
1793
2334
  db: Database,
1794
2335
  input: {
@@ -1797,22 +2338,53 @@ export async function mutateSessionControlInTransaction(
1797
2338
  sessionId: string;
1798
2339
  actor: SessionCommandActor;
1799
2340
  operationKey: string;
1800
- action: "pause" | "resume";
2341
+ action: "pause" | "resume" | "cancel";
1801
2342
  reason?: string | null;
1802
2343
  expectedControlEtag?: string | null;
1803
2344
  },
1804
2345
  ): Promise<SessionControlMutationResult> {
1805
2346
  const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
1806
- await lockSessionEventWriteRows(db, {
2347
+ const cancellationSubtree =
2348
+ input.action === "cancel"
2349
+ ? await loadSessionSubtreeIds(db, input.workspaceId, input.sessionId)
2350
+ : { sessionIds: [input.sessionId], rootParentSessionId: null };
2351
+ const cancellationSessionIds = cancellationSubtree.sessionIds;
2352
+ const cancellationTurnIds =
2353
+ input.action === "cancel"
2354
+ ? await loadCancellationTurnIds(db, input.workspaceId, cancellationSessionIds)
2355
+ : [];
2356
+ const locks = await lockSessionEventWriteRows(db, {
1807
2357
  workspaceId: input.workspaceId,
1808
2358
  controlLock: "already_locked",
1809
2359
  sessionIds:
1810
2360
  input.actor.type === "agent_attempt"
1811
- ? [input.actor.sessionId, input.sessionId]
1812
- : [input.sessionId],
1813
- turnIds: input.actor.type === "agent_attempt" ? [input.actor.turnId] : [],
2361
+ ? [
2362
+ input.actor.sessionId,
2363
+ ...cancellationSessionIds,
2364
+ ...(cancellationSubtree.rootParentSessionId
2365
+ ? [cancellationSubtree.rootParentSessionId]
2366
+ : []),
2367
+ ]
2368
+ : [
2369
+ ...cancellationSessionIds,
2370
+ ...(cancellationSubtree.rootParentSessionId
2371
+ ? [cancellationSubtree.rootParentSessionId]
2372
+ : []),
2373
+ ],
2374
+ turnIds:
2375
+ input.actor.type === "agent_attempt"
2376
+ ? [input.actor.turnId, ...cancellationTurnIds]
2377
+ : cancellationTurnIds,
1814
2378
  attemptIds: input.actor.type === "agent_attempt" ? [input.actor.attemptId] : [],
1815
2379
  });
2380
+ if (input.action === "cancel") {
2381
+ const rootSession = locks.sessions.find((session) => session.id === input.sessionId);
2382
+ if (!rootSession || rootSession.parentSessionId !== cancellationSubtree.rootParentSessionId) {
2383
+ throw new SessionControlInvariantError(
2384
+ `Session ${input.sessionId} parent changed while establishing cancellation locks`,
2385
+ );
2386
+ }
2387
+ }
1816
2388
  const hash = canonicalSessionCommandHash({
1817
2389
  action: input.action,
1818
2390
  reason: input.reason ?? null,
@@ -1848,6 +2420,14 @@ export async function mutateSessionControlInTransaction(
1848
2420
  workspaceControlEventId,
1849
2421
  interruptionCount: Number(reserved.receipt.result.interruptionCount ?? 0),
1850
2422
  wakeCount: Number(reserved.receipt.result.wakeCount ?? 0),
2423
+ cancelledSessionCount: Number(reserved.receipt.result.cancelledSessionCount ?? 0),
2424
+ cancelledTurnCount: Number(reserved.receipt.result.cancelledTurnCount ?? 0),
2425
+ affectedSessionEvents: Array.isArray(reserved.receipt.result.affectedSessionEvents)
2426
+ ? (reserved.receipt.result.affectedSessionEvents as Array<{
2427
+ sessionId: string;
2428
+ eventIds: string[];
2429
+ }>)
2430
+ : [{ sessionId: input.sessionId, eventIds: [sessionControlEventId] }],
1851
2431
  replay: true,
1852
2432
  };
1853
2433
  }
@@ -1856,9 +2436,12 @@ export async function mutateSessionControlInTransaction(
1856
2436
  workspaceId: input.workspaceId,
1857
2437
  actor: input.actor,
1858
2438
  targetSessionId: input.sessionId,
1859
- action: input.action,
2439
+ action: input.action === "cancel" ? "pause" : input.action,
1860
2440
  });
1861
2441
  }
2442
+ if (input.action === "resume") {
2443
+ await assertSessionBranchIsNotCancelled(db, input.workspaceId, input.sessionId);
2444
+ }
1862
2445
  const before = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
1863
2446
  workspaceControl: workspace,
1864
2447
  });
@@ -1868,10 +2451,10 @@ export async function mutateSessionControlInTransaction(
1868
2451
 
1869
2452
  const revision = nextRevision(workspace);
1870
2453
  await advanceWorkspaceRevision(db, input.workspaceId, revision);
1871
- const [updated] = await db
2454
+ const updatedRows = await db
1872
2455
  .update(schema.sessions)
1873
2456
  .set(
1874
- input.action === "pause"
2457
+ input.action === "pause" || input.action === "cancel"
1875
2458
  ? {
1876
2459
  directControlState: "paused",
1877
2460
  directPauseRevision: revision,
@@ -1901,13 +2484,16 @@ export async function mutateSessionControlInTransaction(
1901
2484
  .where(
1902
2485
  and(
1903
2486
  eq(schema.sessions.workspaceId, input.workspaceId),
1904
- eq(schema.sessions.id, input.sessionId),
2487
+ input.action === "cancel"
2488
+ ? inArray(schema.sessions.id, cancellationSessionIds)
2489
+ : eq(schema.sessions.id, input.sessionId),
1905
2490
  ),
1906
2491
  )
1907
2492
  .returning({
1908
2493
  id: schema.sessions.id,
1909
2494
  lastSequence: schema.sessions.lastSequence,
1910
2495
  });
2496
+ const updated = updatedRows.find((row) => row.id === input.sessionId);
1911
2497
  if (!updated) throw new SessionControlInvariantError(`Session ${input.sessionId} disappeared`);
1912
2498
 
1913
2499
  const actor =
@@ -1920,14 +2506,14 @@ export async function mutateSessionControlInTransaction(
1920
2506
  revision,
1921
2507
  scope: "session",
1922
2508
  rootSessionId: input.sessionId,
1923
- action: input.action,
2509
+ action: input.action === "cancel" ? "pause" : input.action,
1924
2510
  automatic: false,
1925
2511
  reason: input.reason ?? null,
1926
2512
  actor,
1927
2513
  });
1928
2514
 
1929
2515
  const interruptionCount =
1930
- input.action === "pause"
2516
+ input.action === "pause" || input.action === "cancel"
1931
2517
  ? await interruptDescendantAttempts(db, {
1932
2518
  accountId: input.accountId,
1933
2519
  workspaceId: input.workspaceId,
@@ -1938,16 +2524,21 @@ export async function mutateSessionControlInTransaction(
1938
2524
  })
1939
2525
  : 0;
1940
2526
  const wakeCount =
1941
- input.action === "pause"
1942
- ? await registerInterruptionWakes(db, {
1943
- operationId: reserved.receipt.id,
1944
- reason: "session_pause_interruption",
1945
- })
1946
- : await registerDescendantWakes(db, {
2527
+ input.action === "cancel"
2528
+ ? await registerCancellationWakes(db, {
1947
2529
  workspaceId: input.workspaceId,
1948
- sessionId: input.sessionId,
1949
- reason: "session_resume",
1950
- });
2530
+ sessionIds: cancellationSessionIds,
2531
+ })
2532
+ : input.action === "pause"
2533
+ ? await registerInterruptionWakes(db, {
2534
+ operationId: reserved.receipt.id,
2535
+ reason: "session_pause_interruption",
2536
+ })
2537
+ : await registerDescendantWakes(db, {
2538
+ workspaceId: input.workspaceId,
2539
+ sessionId: input.sessionId,
2540
+ reason: "session_resume",
2541
+ });
1951
2542
  const [controlEvent] = await db
1952
2543
  .insert(schema.sessionEvents)
1953
2544
  .values({
@@ -1955,12 +2546,16 @@ export async function mutateSessionControlInTransaction(
1955
2546
  workspaceId: input.workspaceId,
1956
2547
  sessionId: input.sessionId,
1957
2548
  sequence: updated.lastSequence + 1,
1958
- type: input.action === "pause" ? "session.control.paused" : "session.control.resumed",
2549
+ type:
2550
+ input.action === "pause" || input.action === "cancel"
2551
+ ? "session.control.paused"
2552
+ : "session.control.resumed",
1959
2553
  payload: {
1960
2554
  operationId: reserved.receipt.id,
1961
2555
  revision,
1962
2556
  actor,
1963
2557
  ...(input.reason ? { reason: input.reason } : {}),
2558
+ ...(input.action === "cancel" ? { terminal: true } : {}),
1964
2559
  interruptionCount,
1965
2560
  },
1966
2561
  occurredAt: new Date(),
@@ -1993,6 +2588,26 @@ export async function mutateSessionControlInTransaction(
1993
2588
  : {}),
1994
2589
  },
1995
2590
  });
2591
+ const cancellation =
2592
+ input.action === "cancel"
2593
+ ? await cancelSessionSubtreeInTransaction(db, {
2594
+ accountId: input.accountId,
2595
+ workspaceId: input.workspaceId,
2596
+ rootSessionId: input.sessionId,
2597
+ sessionIds: cancellationSessionIds,
2598
+ lockedSessions: locks.sessions,
2599
+ candidateTurnIds: cancellationTurnIds,
2600
+ lockedTurns: locks.turns,
2601
+ actor,
2602
+ reason: input.reason ?? null,
2603
+ operationId: reserved.receipt.id,
2604
+ rootControlEventId: controlEvent.id,
2605
+ })
2606
+ : {
2607
+ cancelledSessionCount: 0,
2608
+ cancelledTurnCount: 0,
2609
+ affectedSessionEvents: [{ sessionId: input.sessionId, eventIds: [controlEvent.id] }],
2610
+ };
1996
2611
  const receipt = await updateSessionCommandReceiptResult(db, reserved.receipt.id, {
1997
2612
  controlRevision: revision,
1998
2613
  result: {
@@ -2000,6 +2615,9 @@ export async function mutateSessionControlInTransaction(
2000
2615
  wakeCount,
2001
2616
  eventId: controlEvent.id,
2002
2617
  workspaceControlEventId,
2618
+ cancelledSessionCount: cancellation.cancelledSessionCount,
2619
+ cancelledTurnCount: cancellation.cancelledTurnCount,
2620
+ affectedSessionEvents: cancellation.affectedSessionEvents,
2003
2621
  },
2004
2622
  });
2005
2623
  const control = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
@@ -2012,6 +2630,9 @@ export async function mutateSessionControlInTransaction(
2012
2630
  workspaceControlEventId,
2013
2631
  interruptionCount,
2014
2632
  wakeCount,
2633
+ cancelledSessionCount: cancellation.cancelledSessionCount,
2634
+ cancelledTurnCount: cancellation.cancelledTurnCount,
2635
+ affectedSessionEvents: cancellation.affectedSessionEvents,
2015
2636
  replay: false,
2016
2637
  };
2017
2638
  }
@@ -2032,6 +2653,7 @@ export async function autoResumeSessionBranchInTransaction(
2032
2653
  workspaceControlEventId: string | null;
2033
2654
  }> {
2034
2655
  const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
2656
+ await assertSessionBranchIsNotCancelled(db, input.workspaceId, input.sessionId);
2035
2657
  const before = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
2036
2658
  lock: "share",
2037
2659
  });