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