open-managed-agents 0.1.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 (197) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +248 -0
  3. package/bin/oma.mjs +499 -0
  4. package/dist/scripts/alpha-openai-compatible-fixture.mjs +134 -0
  5. package/dist/scripts/alpha-smoke-models.mjs +92 -0
  6. package/dist/scripts/alpha-smoke.mjs +581 -0
  7. package/dist/scripts/oma-doctor.js +230 -0
  8. package/dist/scripts/oma-models.js +299 -0
  9. package/dist/scripts/oma-workspaces.js +133 -0
  10. package/dist/src/control-plane/admin/auth.js +43 -0
  11. package/dist/src/control-plane/admin/routes.js +76 -0
  12. package/dist/src/control-plane/admin/service.js +102 -0
  13. package/dist/src/control-plane/admission.js +90 -0
  14. package/dist/src/control-plane/agents/routes.js +63 -0
  15. package/dist/src/control-plane/agents/service.js +687 -0
  16. package/dist/src/control-plane/agents/store.js +391 -0
  17. package/dist/src/control-plane/agents/types.js +1 -0
  18. package/dist/src/control-plane/api-constants.js +3 -0
  19. package/dist/src/control-plane/app.js +907 -0
  20. package/dist/src/control-plane/console/auth.js +68 -0
  21. package/dist/src/control-plane/console/static.js +112 -0
  22. package/dist/src/control-plane/deployment-runtime-config.js +328 -0
  23. package/dist/src/control-plane/deployment-runtime-event-coordinator.js +25 -0
  24. package/dist/src/control-plane/deployment-runtime-turn-guard.js +14 -0
  25. package/dist/src/control-plane/deployment-session-coordinator.js +25 -0
  26. package/dist/src/control-plane/deployment-session-output-coordinator.js +27 -0
  27. package/dist/src/control-plane/deployment-storage.js +354 -0
  28. package/dist/src/control-plane/egress/image.js +48 -0
  29. package/dist/src/control-plane/egress/policy.js +571 -0
  30. package/dist/src/control-plane/egress/presets.js +77 -0
  31. package/dist/src/control-plane/egress/proxy.js +72 -0
  32. package/dist/src/control-plane/egress/ssrf.js +106 -0
  33. package/dist/src/control-plane/egress/utils/debug.js +14 -0
  34. package/dist/src/control-plane/egress/vendor/http-proxy.js +335 -0
  35. package/dist/src/control-plane/egress/vendor/mitm-ca.js +226 -0
  36. package/dist/src/control-plane/egress/vendor/mitm-leaf.js +124 -0
  37. package/dist/src/control-plane/egress/vendor/parent-proxy.js +438 -0
  38. package/dist/src/control-plane/egress/vendor/request-filter.js +105 -0
  39. package/dist/src/control-plane/egress/vendor/sandbox-config.js +1 -0
  40. package/dist/src/control-plane/egress/vendor/tls-terminate-proxy.js +269 -0
  41. package/dist/src/control-plane/environments/routes.js +54 -0
  42. package/dist/src/control-plane/environments/service.js +112 -0
  43. package/dist/src/control-plane/environments/store.js +132 -0
  44. package/dist/src/control-plane/environments/types.js +1 -0
  45. package/dist/src/control-plane/errors.js +81 -0
  46. package/dist/src/control-plane/events/broadcaster.js +217 -0
  47. package/dist/src/control-plane/events/constants.js +2 -0
  48. package/dist/src/control-plane/events/persist.js +39 -0
  49. package/dist/src/control-plane/events/request.js +190 -0
  50. package/dist/src/control-plane/events/routes.js +159 -0
  51. package/dist/src/control-plane/events/runtime-helpers.js +134 -0
  52. package/dist/src/control-plane/events/service.js +1985 -0
  53. package/dist/src/control-plane/events/session-guards.js +29 -0
  54. package/dist/src/control-plane/events/sse.js +6 -0
  55. package/dist/src/control-plane/events/store.js +772 -0
  56. package/dist/src/control-plane/events/tool-persistence.js +102 -0
  57. package/dist/src/control-plane/events/types.js +21 -0
  58. package/dist/src/control-plane/files/routes.js +121 -0
  59. package/dist/src/control-plane/files/service.js +65 -0
  60. package/dist/src/control-plane/files/store-common.js +87 -0
  61. package/dist/src/control-plane/files/store-local.js +488 -0
  62. package/dist/src/control-plane/files/store-memory.js +270 -0
  63. package/dist/src/control-plane/files/store.js +2 -0
  64. package/dist/src/control-plane/files/types.js +7 -0
  65. package/dist/src/control-plane/http.js +25 -0
  66. package/dist/src/control-plane/ids.js +43 -0
  67. package/dist/src/control-plane/logging.js +263 -0
  68. package/dist/src/control-plane/models/auth-storage-backend.js +238 -0
  69. package/dist/src/control-plane/models/catalog.js +78 -0
  70. package/dist/src/control-plane/models/config-security.js +376 -0
  71. package/dist/src/control-plane/models/deployment-config.js +93 -0
  72. package/dist/src/control-plane/models/routes.js +32 -0
  73. package/dist/src/control-plane/models/service.js +138 -0
  74. package/dist/src/control-plane/observability/instruments.js +65 -0
  75. package/dist/src/control-plane/observability/metrics.js +125 -0
  76. package/dist/src/control-plane/observability/routes.js +95 -0
  77. package/dist/src/control-plane/openapi/document.js +282 -0
  78. package/dist/src/control-plane/openapi/routes.js +78 -0
  79. package/dist/src/control-plane/request-idempotency.js +85 -0
  80. package/dist/src/control-plane/secrets/envelope.js +132 -0
  81. package/dist/src/control-plane/secrets/master-key.js +44 -0
  82. package/dist/src/control-plane/secrets/routes.js +20 -0
  83. package/dist/src/control-plane/secrets/service.js +71 -0
  84. package/dist/src/control-plane/secrets/store.js +212 -0
  85. package/dist/src/control-plane/secrets/types.js +3 -0
  86. package/dist/src/control-plane/sessions/pi/custom-tools.js +146 -0
  87. package/dist/src/control-plane/sessions/pi/mcp/bridge.js +527 -0
  88. package/dist/src/control-plane/sessions/pi/mcp/client.js +157 -0
  89. package/dist/src/control-plane/sessions/pi/mcp/credential.js +28 -0
  90. package/dist/src/control-plane/sessions/pi/mcp/fetch.js +69 -0
  91. package/dist/src/control-plane/sessions/pi/mcp/probe.js +202 -0
  92. package/dist/src/control-plane/sessions/pi/mcp/runtime.js +13 -0
  93. package/dist/src/control-plane/sessions/pi/runner.js +1104 -0
  94. package/dist/src/control-plane/sessions/pi/sandbox/cma-glob.js +388 -0
  95. package/dist/src/control-plane/sessions/pi/sandbox/cma-grep.js +124 -0
  96. package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +347 -0
  97. package/dist/src/control-plane/sessions/pi/sandbox/docker.js +1589 -0
  98. package/dist/src/control-plane/sessions/pi/sandbox/glob.js +48 -0
  99. package/dist/src/control-plane/sessions/pi/sandbox/image.js +7 -0
  100. package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +1677 -0
  101. package/dist/src/control-plane/sessions/pi/sandbox/provider.js +561 -0
  102. package/dist/src/control-plane/sessions/pi/sandbox/selection.js +168 -0
  103. package/dist/src/control-plane/sessions/pi/span-normalizer.js +82 -0
  104. package/dist/src/control-plane/sessions/pi/tool-permissions.js +392 -0
  105. package/dist/src/control-plane/sessions/pi/translator.js +100 -0
  106. package/dist/src/control-plane/sessions/request.js +164 -0
  107. package/dist/src/control-plane/sessions/resources.js +63 -0
  108. package/dist/src/control-plane/sessions/routes.js +88 -0
  109. package/dist/src/control-plane/sessions/serialize.js +24 -0
  110. package/dist/src/control-plane/sessions/service.js +733 -0
  111. package/dist/src/control-plane/sessions/store.js +538 -0
  112. package/dist/src/control-plane/sessions/types.js +1 -0
  113. package/dist/src/control-plane/skills/archive.js +169 -0
  114. package/dist/src/control-plane/skills/routes.js +41 -0
  115. package/dist/src/control-plane/skills/service.js +29 -0
  116. package/dist/src/control-plane/skills/store.js +233 -0
  117. package/dist/src/control-plane/skills/types.js +5 -0
  118. package/dist/src/control-plane/sqlite-transaction.js +15 -0
  119. package/dist/src/control-plane/vaults/mcp-oauth-validate.js +165 -0
  120. package/dist/src/control-plane/vaults/oauth-refresh-ticker.js +43 -0
  121. package/dist/src/control-plane/vaults/oauth-refresh.js +448 -0
  122. package/dist/src/control-plane/vaults/routes.js +66 -0
  123. package/dist/src/control-plane/vaults/service.js +553 -0
  124. package/dist/src/control-plane/vaults/store.js +691 -0
  125. package/dist/src/control-plane/vaults/types.js +1 -0
  126. package/dist/src/control-plane/wake-loop.js +65 -0
  127. package/dist/src/control-plane/wiring.js +92 -0
  128. package/dist/src/control-plane/workspace.js +4 -0
  129. package/dist/src/control-plane/workspaces/store.js +158 -0
  130. package/dist/src/egress-proxy-main.js +103 -0
  131. package/dist/src/main.js +124 -0
  132. package/dist/src/types/agents.js +1 -0
  133. package/dist/src/types/common.js +1 -0
  134. package/dist/src/types/environments.js +1 -0
  135. package/dist/src/types/events.js +121 -0
  136. package/dist/src/types/files.js +1 -0
  137. package/dist/src/types/json.js +19 -0
  138. package/dist/src/types/sessions.js +1 -0
  139. package/dist/ui/managed-agents-console/README.md +52 -0
  140. package/dist/ui/managed-agents-console/docs/agents.md +23 -0
  141. package/dist/ui/managed-agents-console/docs/console.md +23 -0
  142. package/dist/ui/managed-agents-console/docs/dreams.md +5 -0
  143. package/dist/ui/managed-agents-console/docs/environments.md +27 -0
  144. package/dist/ui/managed-agents-console/docs/events.md +19 -0
  145. package/dist/ui/managed-agents-console/docs/files.md +17 -0
  146. package/dist/ui/managed-agents-console/docs/github.md +11 -0
  147. package/dist/ui/managed-agents-console/docs/integrations.md +19 -0
  148. package/dist/ui/managed-agents-console/docs/memory.md +11 -0
  149. package/dist/ui/managed-agents-console/docs/migration.md +17 -0
  150. package/dist/ui/managed-agents-console/docs/multiagent.md +11 -0
  151. package/dist/ui/managed-agents-console/docs/outcomes.md +11 -0
  152. package/dist/ui/managed-agents-console/docs/overview.md +41 -0
  153. package/dist/ui/managed-agents-console/docs/permissions.md +24 -0
  154. package/dist/ui/managed-agents-console/docs/quickstart.md +51 -0
  155. package/dist/ui/managed-agents-console/docs/reference.md +27 -0
  156. package/dist/ui/managed-agents-console/docs/sandbox-reference.md +19 -0
  157. package/dist/ui/managed-agents-console/docs/sandbox-security.md +15 -0
  158. package/dist/ui/managed-agents-console/docs/scheduled-deployments.md +5 -0
  159. package/dist/ui/managed-agents-console/docs/self-hosted-sandboxes.md +15 -0
  160. package/dist/ui/managed-agents-console/docs/session-operations.md +21 -0
  161. package/dist/ui/managed-agents-console/docs/sessions.md +21 -0
  162. package/dist/ui/managed-agents-console/docs/skills.md +15 -0
  163. package/dist/ui/managed-agents-console/docs/tools.md +23 -0
  164. package/dist/ui/managed-agents-console/docs/vaults.md +19 -0
  165. package/dist/ui/managed-agents-console/docs/webhooks.md +11 -0
  166. package/dist/ui/managed-agents-console/index.html +47 -0
  167. package/dist/ui/managed-agents-console/serve.mjs +116 -0
  168. package/dist/ui/managed-agents-console/src/agents-files.jsx +273 -0
  169. package/dist/ui/managed-agents-console/src/api.js +1043 -0
  170. package/dist/ui/managed-agents-console/src/app.jsx +633 -0
  171. package/dist/ui/managed-agents-console/src/auth.jsx +215 -0
  172. package/dist/ui/managed-agents-console/src/console.css +733 -0
  173. package/dist/ui/managed-agents-console/src/data.js +188 -0
  174. package/dist/ui/managed-agents-console/src/detail.jsx +719 -0
  175. package/dist/ui/managed-agents-console/src/docs.jsx +200 -0
  176. package/dist/ui/managed-agents-console/src/environments.jsx +365 -0
  177. package/dist/ui/managed-agents-console/src/forms.jsx +494 -0
  178. package/dist/ui/managed-agents-console/src/icons.jsx +48 -0
  179. package/dist/ui/managed-agents-console/src/skills.jsx +21 -0
  180. package/dist/ui/managed-agents-console/src/sse.js +185 -0
  181. package/dist/ui/managed-agents-console/src/states.jsx +90 -0
  182. package/dist/ui/managed-agents-console/src/tweaks-panel.jsx +541 -0
  183. package/dist/ui/managed-agents-console/src/ui.jsx +190 -0
  184. package/dist/ui/managed-agents-console/src/vaults-data.js +89 -0
  185. package/dist/ui/managed-agents-console/src/vaults.jsx +171 -0
  186. package/dist/ui/managed-agents-console/vendor/babel.min.js +4 -0
  187. package/dist/ui/managed-agents-console/vendor/react-dom.production.min.js +267 -0
  188. package/dist/ui/managed-agents-console/vendor/react.production.min.js +31 -0
  189. package/dist/ui/openapi-docs/VENDOR.md +27 -0
  190. package/dist/ui/openapi-docs/index.html +16 -0
  191. package/dist/ui/openapi-docs/swagger-initializer.js +14 -0
  192. package/dist/ui/openapi-docs/vendor/LICENSE +202 -0
  193. package/dist/ui/openapi-docs/vendor/NOTICE +2 -0
  194. package/dist/ui/openapi-docs/vendor/swagger-ui-bundle.js +2 -0
  195. package/dist/ui/openapi-docs/vendor/swagger-ui-standalone-preset.js +2 -0
  196. package/dist/ui/openapi-docs/vendor/swagger-ui.css +3 -0
  197. package/package.json +53 -0
@@ -0,0 +1,1985 @@
1
+ import { ApiError, invalidRequest, notFound, rateLimited, toApiErrorBody } from "../errors.js";
2
+ import { log } from "../logging.js";
3
+ import { idempotencyCompletion, idempotencyConflictResponse, idempotencyMismatchError, reserveWindow, } from "../request-idempotency.js";
4
+ import { newRuntimeTurnId, newRequestId } from "../ids.js";
5
+ import { spanModelRequestEndDraft, spanModelRequestStartDraft, syntheticSpanModelRequestEndDrafts, } from "../sessions/pi/span-normalizer.js";
6
+ import { materializePersistedEvents, persistAndPublish, persistRuntimeChangesAndPublish, persistRuntimeChangesCompleteIdempotencyAndPublish, } from "./persist.js";
7
+ import { RuntimeTurnOwnershipLostError, toManagedAgentsEvent, } from "./types.js";
8
+ import { archiveGuardKey, requireActiveSession, requireExistingSession, sessionNotArchivable, sessionNotDeletable, sessionScopeKey, } from "./session-guards.js";
9
+ import { eventPayload, hasToolResultForToolUseId, lostMcpToolConfirmationPayload, lostToolConfirmationPayload, parseSendRequest, sameCustomToolResult, sameCustomToolResultPayload, sameToolConfirmation, toSendResponseEvent, } from "./request.js";
10
+ import { actionClosedWithoutResult, hasTerminalIdleDraft, isAcknowledgedInFlightAction, isRuntimeCustomToolUseEvent, isRuntimeLeaseExpired, isRuntimeMcpConnectionFailedEvent, isRuntimeMcpToolResultEvent, isRuntimeMcpToolUseEvent, isRuntimeMcpToolWithModelEndEvent, isRuntimeToolPermissionUseEvent, isRuntimeToolPermissionWithModelEndEvent, isRuntimeTurnClosed, leaseExpiresAt, runtimeErrorDraft, runtimeLeaseRetryDelayMs, runtimeTurnStillOwned, textFromContent, toError, unique, } from "./runtime-helpers.js";
11
+ import { materializeMcpConnectionFailedRows, materializeMcpToolResultRows, materializeMcpToolUseRows, materializeToolPermissionUseRows, toolPermissionRuntimeChanges, } from "./tool-persistence.js";
12
+ export class DefaultSessionEventsService {
13
+ events;
14
+ sessions;
15
+ broadcaster;
16
+ maxPendingRuntimeTurnsPerWorkspace;
17
+ onAdmissionRejected;
18
+ ownerId;
19
+ ownerGeneration = 1;
20
+ leaseTtlMs;
21
+ runtimeRunner;
22
+ runtimeTranslator;
23
+ sessionOutputCoordinator;
24
+ runtimeEventCoordinator;
25
+ pendingCustomToolActions = new Map();
26
+ closedSessions = new Set();
27
+ deletedSessions = new Set();
28
+ activeRuntimeTasks = new Map();
29
+ interruptedCustomToolActions = new Map();
30
+ pendingToolConfirmations = new Map();
31
+ interruptedToolConfirmations = new Map();
32
+ completedToolConfirmations = new Map();
33
+ archivingSessions = new Map();
34
+ recoveryTimers = new Map();
35
+ constructor(events, sessions, broadcaster, runtime, opts = {}) {
36
+ this.events = events;
37
+ this.sessions = sessions;
38
+ this.broadcaster = broadcaster;
39
+ this.maxPendingRuntimeTurnsPerWorkspace =
40
+ opts.maxPendingRuntimeTurnsPerWorkspace;
41
+ this.onAdmissionRejected = opts.onAdmissionRejected;
42
+ this.runtimeRunner = runtime?.runner;
43
+ this.runtimeTranslator = runtime?.translate;
44
+ this.sessionOutputCoordinator = runtime?.sessionOutputCoordinator;
45
+ this.runtimeEventCoordinator = runtime?.runtimeEventCoordinator;
46
+ this.ownerId = runtime?.ownerId ?? `owner_${newRequestId()}`;
47
+ this.leaseTtlMs = runtime?.leaseTtlMs ?? 120_000;
48
+ }
49
+ send(workspaceId, sessionId, input, opts = {}) {
50
+ return this.sendInternal(workspaceId, sessionId, input, opts);
51
+ }
52
+ sendIdempotent(workspaceId, sessionId, input, idempotency, opts = {}) {
53
+ // Keep reservation and domain execution in one synchronous call path after
54
+ // the route has read the raw body. Adding an await here would reopen the
55
+ // same-key interleaving ADR 0015 is designed to avoid.
56
+ const reservation = this.events.reserveIdempotencyKey({
57
+ ...idempotency,
58
+ workspaceId,
59
+ ...reserveWindow(),
60
+ });
61
+ if (reservation.kind === "replay") {
62
+ return {
63
+ status: reservation.responseStatus,
64
+ body: reservation.responseBody,
65
+ };
66
+ }
67
+ if (reservation.kind === "fingerprint_mismatch") {
68
+ throw idempotencyMismatchError();
69
+ }
70
+ if (reservation.kind === "in_progress") {
71
+ return idempotencyConflictResponse(opts.requestId);
72
+ }
73
+ try {
74
+ const events = this.sendInternal(workspaceId, sessionId, input, {
75
+ signal: opts.signal,
76
+ idempotency,
77
+ });
78
+ return { status: 200, body: { data: events } };
79
+ }
80
+ catch (error) {
81
+ if (error instanceof ApiError && error.status === 429) {
82
+ // Transient admission rejection: completing the key would replay the
83
+ // 429 forever. Release so the same-key retry re-executes (0113 D9).
84
+ this.events.releaseIdempotencyReservation({ ...idempotency, workspaceId });
85
+ throw error;
86
+ }
87
+ if (error instanceof ApiError && error.status < 500) {
88
+ // Replay returns the original error envelope, including request_id.
89
+ // The fresh HTTP header still carries the retry attempt's request id.
90
+ const body = toApiErrorBody(error, opts.requestId);
91
+ this.events.completeIdempotency(idempotencyCompletion(workspaceId, idempotency, {
92
+ status: error.status,
93
+ body,
94
+ }));
95
+ return { status: error.status, body };
96
+ }
97
+ throw error;
98
+ }
99
+ }
100
+ sendInternal(workspaceId, sessionId, input, opts = {}) {
101
+ if (this.isArchivingSession(workspaceId, sessionId)) {
102
+ throw notFound(`Session ${sessionId} not found`);
103
+ }
104
+ requireActiveSession(this.sessions, workspaceId, sessionId);
105
+ const req = parseSendRequest(input);
106
+ this.enforceRuntimeTurnAdmission(workspaceId, req.events);
107
+ const customToolResultClaims = this.claimCustomToolResults(workspaceId, sessionId, req.events);
108
+ const toolConfirmationClaims = this.claimToolConfirmations(workspaceId, sessionId, req.events);
109
+ const existingToolConfirmations = new Map(toolConfirmationClaims
110
+ .filter((claim) => "row" in claim && claim.row !== undefined)
111
+ .map((claim) => [claim.event, claim.row]));
112
+ const existingCustomToolResults = new Map(customToolResultClaims
113
+ .filter((claim) => claim.kind === "duplicate" && claim.row !== undefined)
114
+ .map((claim) => [claim.event, claim.row]));
115
+ const persistableEvents = req.events.filter((event) => !existingToolConfirmations.has(event) &&
116
+ !existingCustomToolResults.has(event));
117
+ const now = new Date().toISOString();
118
+ const drafts = persistableEvents.map((event) => ({
119
+ type: event.type,
120
+ payload: eventPayload(event),
121
+ }));
122
+ const rows = materializePersistedEvents(workspaceId, sessionId, drafts, now);
123
+ // TODO(idempotency): events.send is non-idempotent in B.2. Add request-level
124
+ // dedupe before B.4 runtime consumers process irreversible actions
125
+ // (notably user.tool_confirmation and user.custom_tool_result).
126
+ const rowsByInput = new Map();
127
+ for (let i = 0; i < persistableEvents.length; i += 1) {
128
+ rowsByInput.set(persistableEvents[i], rows[i]);
129
+ }
130
+ const runtimeChanges = {};
131
+ const runtimePrompts = this.runtimeRunner && this.runtimeTranslator
132
+ ? this.runtimePromptsForRows(workspaceId, sessionId, persistableEvents, rowsByInput, now, runtimeChanges)
133
+ : [];
134
+ const terminalRows = this.customToolTerminalizationRows(workspaceId, sessionId, customToolResultClaims, now, runtimeChanges);
135
+ const toolConfirmationTerminalRows = this.toolConfirmationTerminalizationRows(workspaceId, sessionId, toolConfirmationClaims, now, runtimeChanges);
136
+ for (const claim of customToolResultClaims) {
137
+ if (claim.kind !== "live" || !claim.action)
138
+ continue;
139
+ (runtimeChanges.acknowledgedActions ??= []).push({
140
+ workspaceId,
141
+ sessionId,
142
+ actionId: claim.customToolUseId,
143
+ now,
144
+ });
145
+ }
146
+ const responseEvents = req.events.map((event) => toSendResponseEvent(existingToolConfirmations.get(event) ??
147
+ existingCustomToolResults.get(event) ??
148
+ rowsByInput.get(event)));
149
+ const persistedRows = [...rows, ...terminalRows, ...toolConfirmationTerminalRows];
150
+ if (opts.idempotency) {
151
+ persistRuntimeChangesCompleteIdempotencyAndPublish(this.events, this.broadcaster, persistedRows, runtimeChanges, idempotencyCompletion(workspaceId, opts.idempotency, {
152
+ status: 200,
153
+ body: { data: responseEvents },
154
+ }));
155
+ }
156
+ else {
157
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, persistedRows, runtimeChanges);
158
+ }
159
+ this.scheduleAcceptedTurnRecovery(runtimeChanges.acceptedTurns);
160
+ const committedToolConfirmations = toolConfirmationClaims.filter((claim) => "commit" in claim);
161
+ const liveCustomToolResultClaims = customToolResultClaims.filter((claim) => claim.kind === "live");
162
+ const hasCommittedRuntimeInputs = liveCustomToolResultClaims.length > 0 ||
163
+ committedToolConfirmations.length > 0;
164
+ if (hasCommittedRuntimeInputs) {
165
+ for (const { customToolUseId } of liveCustomToolResultClaims) {
166
+ this.removePendingCustomToolAction(workspaceId, sessionId, customToolUseId);
167
+ }
168
+ for (const { toolUseId } of committedToolConfirmations) {
169
+ this.removePendingToolConfirmation(workspaceId, sessionId, toolUseId);
170
+ }
171
+ this.persistRuntimeDrafts(workspaceId, sessionId, [
172
+ { type: "session.status_running", payload: {} },
173
+ ]);
174
+ for (const { commit, customToolUseId } of liveCustomToolResultClaims) {
175
+ try {
176
+ commit();
177
+ }
178
+ catch (error) {
179
+ log.error("custom_tool_result_callback_failed", {
180
+ sessionId,
181
+ customToolUseId,
182
+ error,
183
+ });
184
+ }
185
+ }
186
+ for (const claim of committedToolConfirmations) {
187
+ const row = claim.row ?? rowsByInput.get(claim.event);
188
+ if (!row) {
189
+ throw new Error("Persisted tool confirmation row missing");
190
+ }
191
+ try {
192
+ claim.commit();
193
+ }
194
+ catch (error) {
195
+ log.error("tool_confirmation_callback_failed", {
196
+ sessionId,
197
+ toolUseId: claim.toolUseId,
198
+ error,
199
+ });
200
+ }
201
+ this.completedToolConfirmations.set(claim.event.tool_use_id, {
202
+ workspaceId,
203
+ sessionId,
204
+ result: claim.event.result,
205
+ denyMessage: claim.event.deny_message,
206
+ row,
207
+ });
208
+ }
209
+ this.flushPendingActions(workspaceId, sessionId);
210
+ }
211
+ this.maybeRunRuntimeFromUserMessages(workspaceId, sessionId, runtimePrompts, opts.signal);
212
+ this.maybeInterruptRuntime(workspaceId, sessionId, req.events);
213
+ return responseEvents;
214
+ }
215
+ maybeInterruptRuntime(workspaceId, sessionId, events) {
216
+ if (!events.some((event) => event.type === "user.interrupt"))
217
+ return;
218
+ this.blockInterruptedCustomToolActions(workspaceId, sessionId, this.closeInterruptedRuntimeActions(workspaceId, sessionId, unique([
219
+ ...this.clearPendingCustomToolActions(workspaceId, sessionId),
220
+ ...this.pendingRuntimeActionIds(workspaceId, sessionId, "custom_tool"),
221
+ ])));
222
+ this.blockInterruptedToolConfirmations(workspaceId, sessionId, this.closeInterruptedRuntimeActions(workspaceId, sessionId, unique([
223
+ ...this.clearPendingToolConfirmations(workspaceId, sessionId),
224
+ ...this.pendingRuntimeActionIds(workspaceId, sessionId, "tool_confirmation"),
225
+ ])));
226
+ void Promise.resolve(this.runtimeRunner?.interruptSession?.(workspaceId, sessionId)).catch((error) => {
227
+ log.error("runtime_interrupt_failed", { sessionId, error });
228
+ });
229
+ }
230
+ pendingRuntimeActionIds(workspaceId, sessionId, actionType) {
231
+ return this.events
232
+ .listPendingRuntimeTurns(workspaceId)
233
+ .filter((turn) => turn.session_id === sessionId)
234
+ .flatMap((turn) => this.events.listRuntimeActionsForTurn(workspaceId, sessionId, turn.turn_id))
235
+ .filter((action) => action.action_type === actionType && action.state === "pending")
236
+ .map((action) => action.action_id);
237
+ }
238
+ closeInterruptedRuntimeActions(workspaceId, sessionId, actionIds) {
239
+ if (actionIds.length === 0)
240
+ return [];
241
+ const now = new Date().toISOString();
242
+ const turns = new Map();
243
+ for (const actionId of actionIds) {
244
+ const action = this.events.findRuntimeAction(workspaceId, sessionId, actionId);
245
+ if (action)
246
+ turns.set(action.turn_id, action.turn);
247
+ }
248
+ if (turns.size > 0) {
249
+ const drafts = [...turns.values()].flatMap((turn) => syntheticSpanModelRequestEndDrafts(turn.open_model_request_start_ids));
250
+ const rows = materializePersistedEvents(workspaceId, sessionId, drafts, now);
251
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, rows, {
252
+ closedTurns: [...turns.values()].map((turn) => ({
253
+ workspaceId,
254
+ sessionId,
255
+ turnId: turn.turn_id,
256
+ ownerId: turn.owner_id,
257
+ ownerGeneration: turn.owner_generation,
258
+ reason: "interrupted",
259
+ state: "terminalized",
260
+ now,
261
+ })),
262
+ });
263
+ }
264
+ return [...actionIds];
265
+ }
266
+ runtimePromptsForRows(workspaceId, sessionId, events, rowsByInput, now, runtimeChanges) {
267
+ const prompts = [];
268
+ for (const event of events) {
269
+ if (event.type !== "user.message")
270
+ continue;
271
+ const text = textFromContent(event.content);
272
+ if (text === undefined)
273
+ continue;
274
+ const row = rowsByInput.get(event);
275
+ if (!row)
276
+ throw new Error("Persisted user.message row missing");
277
+ const turnId = newRuntimeTurnId();
278
+ prompts.push({
279
+ text,
280
+ eventId: row.id,
281
+ turnId,
282
+ ownerId: this.ownerId,
283
+ ownerGeneration: this.ownerGeneration,
284
+ });
285
+ (runtimeChanges.acceptedTurns ??= []).push({
286
+ workspaceId,
287
+ sessionId,
288
+ turnId,
289
+ ownerId: this.ownerId,
290
+ ownerGeneration: this.ownerGeneration,
291
+ leaseExpiresAt: leaseExpiresAt(now, this.leaseTtlMs),
292
+ triggerEventIds: [row.id],
293
+ now,
294
+ });
295
+ }
296
+ return prompts;
297
+ }
298
+ customToolTerminalizationRows(workspaceId, sessionId, claims, now, runtimeChanges) {
299
+ const terminalizedTurnIds = new Set();
300
+ const drafts = [];
301
+ for (const claim of claims) {
302
+ if (claim.kind !== "terminalize")
303
+ continue;
304
+ const turn = claim.action.turn;
305
+ (runtimeChanges.acknowledgedActions ??= []).push({
306
+ workspaceId,
307
+ sessionId,
308
+ actionId: claim.customToolUseId,
309
+ now,
310
+ });
311
+ if (terminalizedTurnIds.has(claim.action.turn_id))
312
+ continue;
313
+ terminalizedTurnIds.add(claim.action.turn_id);
314
+ (runtimeChanges.closedTurns ??= []).push({
315
+ workspaceId,
316
+ sessionId,
317
+ turnId: claim.action.turn_id,
318
+ ownerId: turn.owner_id,
319
+ ownerGeneration: turn.owner_generation,
320
+ reason: "terminalized",
321
+ state: "terminalized",
322
+ now,
323
+ });
324
+ drafts.push(...syntheticSpanModelRequestEndDrafts(turn.open_model_request_start_ids), {
325
+ type: "session.error",
326
+ payload: {
327
+ message: `Custom tool result ${claim.customToolUseId} was accepted, but runtime state is no longer available and the custom tool execution outcome is unknown.`,
328
+ },
329
+ }, {
330
+ type: "session.status_idle",
331
+ payload: { stop_reason: { type: "end_turn" } },
332
+ });
333
+ }
334
+ if (drafts.length === 0)
335
+ return [];
336
+ return materializePersistedEvents(workspaceId, sessionId, drafts, now);
337
+ }
338
+ toolConfirmationTerminalizationRows(workspaceId, sessionId, claims, now, runtimeChanges) {
339
+ const terminalizedTurnIds = new Set();
340
+ const drafts = [];
341
+ for (const claim of claims) {
342
+ if (!("action" in claim))
343
+ continue;
344
+ const turn = claim.action.turn;
345
+ (runtimeChanges.acknowledgedActions ??= []).push({
346
+ workspaceId,
347
+ sessionId,
348
+ actionId: claim.toolUseId,
349
+ now,
350
+ });
351
+ if (terminalizedTurnIds.has(claim.action.turn_id))
352
+ continue;
353
+ terminalizedTurnIds.add(claim.action.turn_id);
354
+ (runtimeChanges.closedTurns ??= []).push({
355
+ workspaceId,
356
+ sessionId,
357
+ turnId: claim.action.turn_id,
358
+ ownerId: turn.owner_id,
359
+ ownerGeneration: turn.owner_generation,
360
+ reason: "terminalized",
361
+ state: "terminalized",
362
+ now,
363
+ });
364
+ drafts.push(...syntheticSpanModelRequestEndDrafts(turn.open_model_request_start_ids), this.lostToolConfirmationResultDraft(workspaceId, sessionId, claim.toolUseId), {
365
+ type: "session.status_idle",
366
+ payload: { stop_reason: { type: "end_turn" } },
367
+ });
368
+ }
369
+ if (drafts.length === 0)
370
+ return [];
371
+ return materializePersistedEvents(workspaceId, sessionId, drafts, now);
372
+ }
373
+ archiveSessionRowAfterPreflight(workspaceId, sessionId) {
374
+ this.assertSessionArchivable(workspaceId, sessionId);
375
+ this.incrementArchivingSession(workspaceId, sessionId);
376
+ try {
377
+ const archivedAt = new Date().toISOString();
378
+ const row = this.sessions.archive(workspaceId, sessionId, archivedAt);
379
+ if (!row) {
380
+ throw notFound(`Session ${sessionId} not found`);
381
+ }
382
+ return row;
383
+ }
384
+ finally {
385
+ this.decrementArchivingSession(workspaceId, sessionId);
386
+ }
387
+ }
388
+ assertSessionArchivable(workspaceId, sessionId) {
389
+ const session = requireExistingSession(this.sessions, workspaceId, sessionId);
390
+ if (session.archived_at !== null || session.status === "terminated")
391
+ return;
392
+ if (this.activeRuntimeTaskCount(workspaceId, sessionId) > 0 &&
393
+ !this.hasPendingRuntimeActions(workspaceId, sessionId)) {
394
+ throw sessionNotArchivable(sessionId, "running");
395
+ }
396
+ if (session.status === "running" || session.status === "rescheduling") {
397
+ throw sessionNotArchivable(sessionId, session.status);
398
+ }
399
+ }
400
+ // Preflight for DELETE /v1/sessions/:id. Hosted CMA rejects a delete while the
401
+ // session is running with a 400 (probe 38); we reuse the same running-detection
402
+ // as archive so a session whose runtime task is live but whose row status lags
403
+ // is still blocked. Must run before any store mutation in the route.
404
+ assertSessionDeletable(workspaceId, sessionId) {
405
+ const session = requireExistingSession(this.sessions, workspaceId, sessionId);
406
+ if (session.archived_at !== null || session.status === "terminated")
407
+ return;
408
+ if (this.activeRuntimeTaskCount(workspaceId, sessionId) > 0 &&
409
+ !this.hasPendingRuntimeActions(workspaceId, sessionId)) {
410
+ throw sessionNotDeletable();
411
+ }
412
+ if (session.status === "running" || session.status === "rescheduling") {
413
+ throw sessionNotDeletable();
414
+ }
415
+ }
416
+ isArchivingSession(workspaceId, sessionId) {
417
+ return ((this.archivingSessions.get(archiveGuardKey(workspaceId, sessionId)) ?? 0) >
418
+ 0);
419
+ }
420
+ incrementArchivingSession(workspaceId, sessionId) {
421
+ const key = archiveGuardKey(workspaceId, sessionId);
422
+ this.archivingSessions.set(key, (this.archivingSessions.get(key) ?? 0) + 1);
423
+ }
424
+ decrementArchivingSession(workspaceId, sessionId) {
425
+ const key = archiveGuardKey(workspaceId, sessionId);
426
+ const remaining = (this.archivingSessions.get(key) ?? 1) - 1;
427
+ if (remaining > 0) {
428
+ this.archivingSessions.set(key, remaining);
429
+ return;
430
+ }
431
+ this.archivingSessions.delete(key);
432
+ }
433
+ async archiveSession(workspaceId, sessionId) {
434
+ requireExistingSession(this.sessions, workspaceId, sessionId);
435
+ this.closedSessions.add(sessionScopeKey(workspaceId, sessionId));
436
+ this.clearPendingCustomToolActions(workspaceId, sessionId);
437
+ this.clearPendingToolConfirmations(workspaceId, sessionId);
438
+ this.clearCompletedToolConfirmations(workspaceId, sessionId);
439
+ this.interruptedCustomToolActions.delete(sessionScopeKey(workspaceId, sessionId));
440
+ this.interruptedToolConfirmations.delete(sessionScopeKey(workspaceId, sessionId));
441
+ this.closePendingRuntimeTurnsForSession(workspaceId, sessionId, "archived", {
442
+ includeTerminatedStatus: !this.hasSessionEvent(workspaceId, sessionId, "session.status_terminated"),
443
+ });
444
+ await this.closeRuntimeBestEffort(workspaceId, sessionId);
445
+ this.retireLifecycleGuardsIfIdle(workspaceId, sessionId);
446
+ }
447
+ async deleteSession(workspaceId, sessionId) {
448
+ this.closedSessions.add(sessionScopeKey(workspaceId, sessionId));
449
+ this.clearPendingCustomToolActions(workspaceId, sessionId);
450
+ this.clearPendingToolConfirmations(workspaceId, sessionId);
451
+ this.clearCompletedToolConfirmations(workspaceId, sessionId);
452
+ this.interruptedCustomToolActions.delete(sessionScopeKey(workspaceId, sessionId));
453
+ this.interruptedToolConfirmations.delete(sessionScopeKey(workspaceId, sessionId));
454
+ this.closePendingRuntimeTurnsForSession(workspaceId, sessionId, "deleted");
455
+ this.persistLifecycleDrafts(workspaceId, sessionId, [
456
+ { type: "session.deleted", payload: {} },
457
+ ]);
458
+ this.broadcaster.closeSession(workspaceId, sessionId);
459
+ this.deletedSessions.add(sessionScopeKey(workspaceId, sessionId));
460
+ await this.closeRuntimeBestEffort(workspaceId, sessionId);
461
+ this.events.deleteForSession(workspaceId, sessionId);
462
+ this.retireLifecycleGuardsIfIdle(workspaceId, sessionId);
463
+ }
464
+ closePendingRuntimeTurnsForSession(workspaceId, sessionId, reason, opts = {}) {
465
+ const now = new Date().toISOString();
466
+ // Keep list-and-close synchronous. These archive closes intentionally do
467
+ // not owner-fence individual turns; introducing an await here would allow a
468
+ // runtime owner to close the same turn between the list and the batch.
469
+ const turns = this.events
470
+ .listPendingRuntimeTurns(workspaceId)
471
+ .filter((turn) => turn.session_id === sessionId);
472
+ const drafts = reason === "archived"
473
+ ? turns.flatMap((turn) => syntheticSpanModelRequestEndDrafts(turn.open_model_request_start_ids))
474
+ : [];
475
+ if (opts.includeTerminatedStatus === true) {
476
+ drafts.push({ type: "session.status_terminated", payload: {} });
477
+ }
478
+ if (turns.length === 0 && drafts.length === 0)
479
+ return;
480
+ const rows = materializePersistedEvents(workspaceId, sessionId, drafts, now);
481
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, rows, {
482
+ closedTurns: turns.map((turn) => ({
483
+ workspaceId,
484
+ sessionId,
485
+ turnId: turn.turn_id,
486
+ reason,
487
+ state: "terminalized",
488
+ now,
489
+ })),
490
+ });
491
+ }
492
+ // 0113 D9: each user.message in a send accepts a runtime turn, so pending
493
+ // turns are unbounded per workspace without this gate. Checked before any
494
+ // event persists; count-then-accept is not atomic, but the deployment app
495
+ // is single-process, so an overshoot of one batch is the worst case.
496
+ enforceRuntimeTurnAdmission(workspaceId, events) {
497
+ const cap = this.maxPendingRuntimeTurnsPerWorkspace;
498
+ if (cap === undefined)
499
+ return;
500
+ // Same predicate as runtimePromptsForRows: only user.message events that
501
+ // yield prompt text accept a runtime turn, so only those count against
502
+ // the cap. A non-text message must not be rejected for capacity it would
503
+ // never consume.
504
+ const newTurns = events.filter((event) => event.type === "user.message" &&
505
+ textFromContent(event.content) !== undefined).length;
506
+ if (newTurns === 0)
507
+ return;
508
+ if (this.events.countPendingRuntimeTurns(workspaceId) + newTurns > cap) {
509
+ this.onAdmissionRejected?.();
510
+ throw rateLimited("Concurrent pending runtime turn limit reached for this workspace; retry later");
511
+ }
512
+ }
513
+ // 0113 D7: restart recovery must cover every workspace with pending turns,
514
+ // not just wrk_default — otherwise a restart silently abandons
515
+ // non-default-workspace turns.
516
+ recoverAllAbandonedRuntimeTurns() {
517
+ for (const workspaceId of this.events.listWorkspaceIdsWithPendingRuntimeTurns()) {
518
+ this.recoverAbandonedRuntimeTurns(workspaceId);
519
+ }
520
+ }
521
+ recoverAbandonedRuntimeTurns(workspaceId) {
522
+ const turns = this.events.listPendingRuntimeTurns(workspaceId);
523
+ let nextRetryAt;
524
+ for (const turn of turns) {
525
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, turn.session_id)))
526
+ continue;
527
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, turn.session_id)))
528
+ continue;
529
+ if (!this.isRecoverableSession(workspaceId, turn.session_id))
530
+ continue;
531
+ if (this.activeRuntimeTaskCount(workspaceId, turn.session_id) > 0)
532
+ continue;
533
+ const retryDelayMs = runtimeLeaseRetryDelayMs(turn.lease_expires_at);
534
+ if (retryDelayMs > 0) {
535
+ const retryAt = Date.now() + retryDelayMs;
536
+ nextRetryAt =
537
+ nextRetryAt === undefined ? retryAt : Math.min(nextRetryAt, retryAt);
538
+ continue;
539
+ }
540
+ if (turn.state === "accepted") {
541
+ const claimed = this.claimAcceptedTurn(turn);
542
+ if (!claimed)
543
+ continue;
544
+ const prompts = this.promptsFromAcceptedTurn(claimed);
545
+ if (prompts.length === 0) {
546
+ this.terminalizeAbandonedRuntimeTurn(claimed, "Accepted runtime turn has no recoverable trigger event.");
547
+ continue;
548
+ }
549
+ this.beginRuntimeTask(workspaceId, claimed.session_id);
550
+ void this.runRuntimePrompts(workspaceId, claimed.session_id, prompts, undefined).finally(() => {
551
+ this.finishRuntimeTask(workspaceId, claimed.session_id);
552
+ });
553
+ continue;
554
+ }
555
+ if (turn.state === "paused" &&
556
+ this.events.listRuntimeActionsForTurn(workspaceId, turn.session_id, turn.turn_id).some((action) => action.state === "pending")) {
557
+ continue;
558
+ }
559
+ const claimed = this.claimTurnForTerminalization(turn);
560
+ if (!claimed)
561
+ continue;
562
+ this.terminalizeAbandonedRuntimeTurn(claimed, "Runtime state is no longer available and the turn outcome is unknown.");
563
+ }
564
+ this.scheduleAbandonedRuntimeRecovery(workspaceId, nextRetryAt);
565
+ }
566
+ isRecoverableSession(workspaceId, sessionId) {
567
+ const session = this.sessions.retrieveAny(workspaceId, sessionId);
568
+ if (!session)
569
+ return false;
570
+ return session.archived_at === null && session.status !== "terminated";
571
+ }
572
+ scheduleAcceptedTurnRecovery(acceptedTurns) {
573
+ if (!acceptedTurns || acceptedTurns.length === 0)
574
+ return;
575
+ const earliestRetryAtByWorkspace = new Map();
576
+ for (const turn of acceptedTurns) {
577
+ const retryAt = Date.now() + runtimeLeaseRetryDelayMs(turn.leaseExpiresAt);
578
+ const earliest = earliestRetryAtByWorkspace.get(turn.workspaceId);
579
+ if (earliest === undefined || retryAt < earliest) {
580
+ earliestRetryAtByWorkspace.set(turn.workspaceId, retryAt);
581
+ }
582
+ }
583
+ for (const [workspaceId, retryAt] of earliestRetryAtByWorkspace) {
584
+ this.scheduleAbandonedRuntimeRecovery(workspaceId, retryAt);
585
+ }
586
+ }
587
+ scheduleAbandonedRuntimeRecovery(workspaceId, retryAt) {
588
+ if (retryAt === undefined)
589
+ return;
590
+ const existing = this.recoveryTimers.get(workspaceId);
591
+ if (existing && existing.dueAt <= retryAt)
592
+ return;
593
+ if (existing)
594
+ clearTimeout(existing.timer);
595
+ const delayMs = Math.max(1, retryAt - Date.now() + 1);
596
+ const timer = setTimeout(() => {
597
+ this.recoveryTimers.delete(workspaceId);
598
+ this.recoverAbandonedRuntimeTurns(workspaceId);
599
+ }, delayMs);
600
+ timer.unref?.();
601
+ this.recoveryTimers.set(workspaceId, { dueAt: retryAt, timer });
602
+ }
603
+ claimAcceptedTurn(turn) {
604
+ const now = new Date().toISOString();
605
+ return this.events.claimAcceptedRuntimeTurnForRecovery({
606
+ workspaceId: turn.workspace_id,
607
+ sessionId: turn.session_id,
608
+ turnId: turn.turn_id,
609
+ ownerId: this.ownerId,
610
+ leaseExpiresAt: leaseExpiresAt(now, this.leaseTtlMs),
611
+ now,
612
+ });
613
+ }
614
+ claimTurnForTerminalization(turn) {
615
+ const now = new Date().toISOString();
616
+ return this.events.claimRuntimeTurnForTerminalization({
617
+ workspaceId: turn.workspace_id,
618
+ sessionId: turn.session_id,
619
+ turnId: turn.turn_id,
620
+ ownerId: this.ownerId,
621
+ leaseExpiresAt: leaseExpiresAt(now, this.leaseTtlMs),
622
+ now,
623
+ });
624
+ }
625
+ promptsFromAcceptedTurn(turn) {
626
+ const prompts = [];
627
+ for (const eventId of turn.trigger_event_ids) {
628
+ const row = this.events.retrieve(turn.workspace_id, eventId);
629
+ if (!row || row.type !== "user.message")
630
+ continue;
631
+ const content = row.payload.content;
632
+ if (!Array.isArray(content))
633
+ continue;
634
+ const text = textFromContent(content);
635
+ if (text === undefined)
636
+ continue;
637
+ prompts.push({
638
+ text,
639
+ eventId: row.id,
640
+ turnId: turn.turn_id,
641
+ ownerId: turn.owner_id,
642
+ ownerGeneration: turn.owner_generation,
643
+ });
644
+ }
645
+ return prompts;
646
+ }
647
+ terminalizeAbandonedRuntimeTurn(turn, message) {
648
+ const now = new Date().toISOString();
649
+ const rows = materializePersistedEvents(turn.workspace_id, turn.session_id, [
650
+ ...syntheticSpanModelRequestEndDrafts(turn.open_model_request_start_ids),
651
+ { type: "session.error", payload: { message } },
652
+ {
653
+ type: "session.status_idle",
654
+ payload: { stop_reason: { type: "end_turn" } },
655
+ },
656
+ ], now);
657
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, rows, {
658
+ closedTurns: [
659
+ {
660
+ workspaceId: turn.workspace_id,
661
+ sessionId: turn.session_id,
662
+ turnId: turn.turn_id,
663
+ ownerId: turn.owner_id,
664
+ ownerGeneration: turn.owner_generation,
665
+ reason: "terminalized",
666
+ state: "terminalized",
667
+ now,
668
+ },
669
+ ],
670
+ });
671
+ }
672
+ async closeRuntimeBestEffort(workspaceId, sessionId) {
673
+ try {
674
+ await this.runtimeRunner?.closeSession?.(workspaceId, sessionId);
675
+ }
676
+ catch (error) {
677
+ log.error("runtime_cleanup_failed", { sessionId, error });
678
+ }
679
+ }
680
+ hasSessionEvent(workspaceId, sessionId, type) {
681
+ return this.events.list(workspaceId, sessionId, { limit: 1, types: [type] }).length > 0;
682
+ }
683
+ list(workspaceId, sessionId, opts = {}) {
684
+ requireExistingSession(this.sessions, workspaceId, sessionId);
685
+ const page = this.events.listPage(workspaceId, sessionId, opts);
686
+ return {
687
+ data: page.data.map(toManagedAgentsEvent),
688
+ next_page: page.next_page,
689
+ };
690
+ }
691
+ stream(workspaceId, sessionId, opts = {}) {
692
+ const session = requireExistingSession(this.sessions, workspaceId, sessionId);
693
+ const lastSeenId = this.resolveResumeCursor(workspaceId, sessionId, opts.lastEventId);
694
+ if (session.archived_at !== null || session.status === "terminated") {
695
+ return this.replayClosedSession(workspaceId, sessionId, lastSeenId, opts.signal);
696
+ }
697
+ const source = this.broadcaster.subscribe(workspaceId, sessionId, {
698
+ lastSeenId,
699
+ signal: opts.signal,
700
+ });
701
+ return (async function* () {
702
+ for await (const event of source) {
703
+ yield toManagedAgentsEvent(event);
704
+ }
705
+ })();
706
+ }
707
+ replayClosedSession(workspaceId, sessionId, lastSeenId, signal) {
708
+ const events = this.events;
709
+ return (async function* () {
710
+ let cursor = lastSeenId;
711
+ while (!(signal?.aborted ?? false)) {
712
+ const rows = events.list(workspaceId, sessionId, {
713
+ afterId: cursor,
714
+ limit: 500,
715
+ });
716
+ if (rows.length === 0)
717
+ return;
718
+ for (const row of rows) {
719
+ if (signal?.aborted ?? false)
720
+ return;
721
+ cursor = row.id;
722
+ yield toManagedAgentsEvent(row);
723
+ }
724
+ if (rows.length < 500)
725
+ return;
726
+ }
727
+ })();
728
+ }
729
+ resolveResumeCursor(workspaceId, sessionId, lastEventId) {
730
+ if (lastEventId === undefined || !lastEventId.startsWith("sevt_")) {
731
+ return undefined;
732
+ }
733
+ const cursor = this.events.retrieve(workspaceId, lastEventId);
734
+ if (!cursor || cursor.session_id !== sessionId) {
735
+ return undefined;
736
+ }
737
+ return lastEventId;
738
+ }
739
+ maybeRunRuntimeFromUserMessages(workspaceId, sessionId, prompts, signal) {
740
+ if (!this.runtimeRunner || !this.runtimeTranslator)
741
+ return;
742
+ if (prompts.length === 0)
743
+ return;
744
+ this.beginRuntimeTask(workspaceId, sessionId);
745
+ void this.runRuntimePrompts(workspaceId, sessionId, prompts, signal)
746
+ .finally(() => {
747
+ this.finishRuntimeTask(workspaceId, sessionId);
748
+ });
749
+ }
750
+ beginRuntimeTask(workspaceId, sessionId) {
751
+ const key = sessionScopeKey(workspaceId, sessionId);
752
+ this.activeRuntimeTasks.set(key, (this.activeRuntimeTasks.get(key) ?? 0) + 1);
753
+ }
754
+ finishRuntimeTask(workspaceId, sessionId) {
755
+ const key = sessionScopeKey(workspaceId, sessionId);
756
+ const remaining = (this.activeRuntimeTasks.get(key) ?? 1) - 1;
757
+ if (remaining > 0) {
758
+ this.activeRuntimeTasks.set(key, remaining);
759
+ return;
760
+ }
761
+ this.activeRuntimeTasks.delete(key);
762
+ this.interruptedCustomToolActions.delete(key);
763
+ this.interruptedToolConfirmations.delete(key);
764
+ this.retireLifecycleGuardsIfIdle(workspaceId, sessionId);
765
+ }
766
+ retireLifecycleGuardsIfIdle(workspaceId, sessionId) {
767
+ const key = sessionScopeKey(workspaceId, sessionId);
768
+ if ((this.activeRuntimeTasks.get(key) ?? 0) > 0)
769
+ return;
770
+ this.closedSessions.delete(key);
771
+ this.deletedSessions.delete(key);
772
+ }
773
+ activeRuntimeTaskCount(workspaceId, sessionId) {
774
+ return this.activeRuntimeTasks.get(sessionScopeKey(workspaceId, sessionId)) ?? 0;
775
+ }
776
+ async runRuntimePrompts(workspaceId, sessionId, prompts, signal) {
777
+ if (!this.runtimeRunner || !this.runtimeTranslator)
778
+ return;
779
+ let activePrompt;
780
+ let activeOpenModelRequestStartIds = [];
781
+ try {
782
+ for (const prompt of prompts) {
783
+ activePrompt = prompt;
784
+ activeOpenModelRequestStartIds = [];
785
+ const stopRenewing = this.startRuntimeLeaseRenewal(workspaceId, sessionId, prompt);
786
+ try {
787
+ this.markRuntimeTurnState(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, "dispatching");
788
+ const source = this.runtimeRunner.runUserMessage(workspaceId, sessionId, prompt.text, { signal });
789
+ for await (const piEvent of source) {
790
+ if (isRuntimeCustomToolUseEvent(piEvent)) {
791
+ this.persistCustomToolUse(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, piEvent);
792
+ continue;
793
+ }
794
+ if (isRuntimeToolPermissionUseEvent(piEvent)) {
795
+ this.persistToolPermissionUse(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, piEvent);
796
+ continue;
797
+ }
798
+ if (isRuntimeToolPermissionWithModelEndEvent(piEvent)) {
799
+ const closingModelRequestStartId = activeOpenModelRequestStartIds[activeOpenModelRequestStartIds.length - 1];
800
+ const closedModelRequestStartId = this.persistToolPermissionUseWithModelEnd(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, piEvent, closingModelRequestStartId);
801
+ if (closedModelRequestStartId !== undefined) {
802
+ activeOpenModelRequestStartIds.pop();
803
+ }
804
+ continue;
805
+ }
806
+ if (isRuntimeMcpToolUseEvent(piEvent)) {
807
+ this.persistMcpToolUse(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, piEvent);
808
+ continue;
809
+ }
810
+ if (isRuntimeMcpToolWithModelEndEvent(piEvent)) {
811
+ const closingModelRequestStartId = activeOpenModelRequestStartIds[activeOpenModelRequestStartIds.length - 1];
812
+ const closedModelRequestStartId = this.persistMcpToolUseWithModelEnd(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, piEvent, closingModelRequestStartId);
813
+ if (closedModelRequestStartId !== undefined) {
814
+ activeOpenModelRequestStartIds.pop();
815
+ }
816
+ continue;
817
+ }
818
+ if (isRuntimeMcpToolResultEvent(piEvent)) {
819
+ this.persistMcpToolResult(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, piEvent);
820
+ continue;
821
+ }
822
+ if (isRuntimeMcpConnectionFailedEvent(piEvent)) {
823
+ this.persistMcpConnectionFailed(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, piEvent);
824
+ continue;
825
+ }
826
+ const spanStartDrafts = spanModelRequestStartDraft(piEvent);
827
+ const transcriptDrafts = this.runtimeTranslator(piEvent, {
828
+ customToolNames: this.runtimeRunner.customToolNames?.(workspaceId, sessionId),
829
+ publicToolUseIdForPiToolCallId: (piToolCallId) => this.runtimeRunner?.publicToolUseIdForPiToolCallId?.(workspaceId, sessionId, piToolCallId),
830
+ suppressPiToolUse: (piToolCallId) => this.runtimeRunner?.suppressPiToolUse?.(workspaceId, sessionId, piToolCallId) === true,
831
+ });
832
+ const closingModelRequestStartId = activeOpenModelRequestStartIds[activeOpenModelRequestStartIds.length - 1];
833
+ const spanEndDrafts = spanModelRequestEndDraft(piEvent, closingModelRequestStartId);
834
+ const drafts = [
835
+ ...spanStartDrafts,
836
+ ...transcriptDrafts,
837
+ ...spanEndDrafts,
838
+ ];
839
+ if (drafts.length === 0)
840
+ continue;
841
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId)))
842
+ return;
843
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId)))
844
+ return;
845
+ const now = new Date().toISOString();
846
+ const rows = materializePersistedEvents(workspaceId, sessionId, drafts, now);
847
+ if (spanStartDrafts.length > 1) {
848
+ throw new Error("Expected at most one model request start draft");
849
+ }
850
+ const openedModelRequestStartId = spanStartDrafts.length > 0 ? rows[0]?.id : undefined;
851
+ // First coordinator slice: fence the general translated runtime
852
+ // transcript path. Custom tool, tool-permission, renewal, and
853
+ // error-cleanup paths still use their existing store-level owner
854
+ // checks; track the remaining seam audit in GitHub issue #113.
855
+ this.runtimeEventCoordinator.commitRuntimeEventsForTurn({
856
+ workspaceId,
857
+ sessionId,
858
+ turnId: prompt.turnId,
859
+ ownerId: prompt.ownerId,
860
+ ownerGeneration: prompt.ownerGeneration,
861
+ events: rows,
862
+ changes: {
863
+ openedModelRequestStarts: openedModelRequestStartId === undefined
864
+ ? []
865
+ : [
866
+ {
867
+ workspaceId,
868
+ sessionId,
869
+ turnId: prompt.turnId,
870
+ ownerId: prompt.ownerId,
871
+ ownerGeneration: prompt.ownerGeneration,
872
+ startEventId: openedModelRequestStartId,
873
+ now,
874
+ },
875
+ ],
876
+ closedModelRequestStarts: spanEndDrafts.length === 0 ||
877
+ closingModelRequestStartId === undefined
878
+ ? []
879
+ : [
880
+ {
881
+ workspaceId,
882
+ sessionId,
883
+ turnId: prompt.turnId,
884
+ ownerId: prompt.ownerId,
885
+ ownerGeneration: prompt.ownerGeneration,
886
+ startEventId: closingModelRequestStartId,
887
+ now,
888
+ },
889
+ ],
890
+ turnStates: [
891
+ {
892
+ workspaceId,
893
+ sessionId,
894
+ turnId: prompt.turnId,
895
+ ownerId: prompt.ownerId,
896
+ ownerGeneration: prompt.ownerGeneration,
897
+ leaseExpiresAt: leaseExpiresAt(now, this.leaseTtlMs),
898
+ state: "running",
899
+ now,
900
+ },
901
+ ],
902
+ },
903
+ });
904
+ this.broadcaster.publishPersisted(rows);
905
+ if (openedModelRequestStartId !== undefined) {
906
+ activeOpenModelRequestStartIds.push(openedModelRequestStartId);
907
+ }
908
+ if (spanEndDrafts.length > 0 &&
909
+ closingModelRequestStartId !== undefined) {
910
+ activeOpenModelRequestStartIds.pop();
911
+ }
912
+ if (hasTerminalIdleDraft(drafts)) {
913
+ await this.indexSessionOutputsFromLiveRuntime(workspaceId, sessionId, prompt);
914
+ }
915
+ }
916
+ this.closeRuntimeTurnWithSyntheticSpanEnds(workspaceId, sessionId, prompt, activeOpenModelRequestStartIds, "completed", "completed");
917
+ }
918
+ finally {
919
+ stopRenewing();
920
+ }
921
+ activePrompt = undefined;
922
+ activeOpenModelRequestStartIds = [];
923
+ }
924
+ }
925
+ catch (error) {
926
+ if (error instanceof RuntimeTurnOwnershipLostError) {
927
+ log.debug("runtime_turn_ownership_lost", {
928
+ workspaceId,
929
+ sessionId,
930
+ turnId: error.turnId,
931
+ });
932
+ void Promise.resolve(this.runtimeRunner?.interruptSession?.(workspaceId, sessionId)).catch((interruptError) => {
933
+ log.error("runtime_ownership_loss_interrupt_failed", {
934
+ sessionId,
935
+ error: interruptError,
936
+ });
937
+ });
938
+ return;
939
+ }
940
+ log.error("runtime_ingestion_failed", { sessionId, error });
941
+ if (activePrompt) {
942
+ const now = new Date().toISOString();
943
+ const runtimeChanges = {
944
+ closedTurns: [
945
+ {
946
+ workspaceId,
947
+ sessionId,
948
+ turnId: activePrompt.turnId,
949
+ reason: "terminalized",
950
+ state: "terminalized",
951
+ now,
952
+ },
953
+ ],
954
+ };
955
+ if (!this.closedSessions.has(sessionScopeKey(workspaceId, sessionId)) &&
956
+ !this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId))) {
957
+ runtimeChanges.closedTurns = [
958
+ {
959
+ workspaceId,
960
+ sessionId,
961
+ turnId: activePrompt.turnId,
962
+ ownerId: activePrompt.ownerId,
963
+ ownerGeneration: activePrompt.ownerGeneration,
964
+ reason: "terminalized",
965
+ state: "terminalized",
966
+ now,
967
+ },
968
+ ];
969
+ }
970
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId)) ||
971
+ this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId))) {
972
+ this.events.appendBatchWithRuntimeChanges([], runtimeChanges);
973
+ return;
974
+ }
975
+ const rows = materializePersistedEvents(workspaceId, sessionId, [
976
+ ...syntheticSpanModelRequestEndDrafts(activeOpenModelRequestStartIds),
977
+ runtimeErrorDraft(error),
978
+ {
979
+ type: "session.status_idle",
980
+ payload: { stop_reason: { type: "end_turn" } },
981
+ },
982
+ ], now);
983
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, rows, runtimeChanges);
984
+ return;
985
+ }
986
+ this.persistRuntimeDrafts(workspaceId, sessionId, [runtimeErrorDraft(error)]);
987
+ }
988
+ }
989
+ persistRuntimeDrafts(workspaceId, sessionId, drafts) {
990
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId)))
991
+ return;
992
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId)))
993
+ return;
994
+ this.persistLifecycleDrafts(workspaceId, sessionId, drafts);
995
+ }
996
+ async indexSessionOutputsFromLiveRuntime(workspaceId, sessionId, prompt) {
997
+ if (!this.sessionOutputCoordinator ||
998
+ !this.runtimeRunner?.collectSessionOutputs) {
999
+ return;
1000
+ }
1001
+ try {
1002
+ const collection = await this.runtimeRunner.collectSessionOutputs(workspaceId, sessionId);
1003
+ if (collection.kind !== "collected")
1004
+ return;
1005
+ if (collection.files.length === 0)
1006
+ return;
1007
+ if (
1008
+ // Cheap process-local early-out. The coordinator is still the
1009
+ // authoritative check at metadata commit time.
1010
+ !this.canCommitSessionOutputsFromRuntimeTurn(workspaceId, sessionId, prompt)) {
1011
+ return;
1012
+ }
1013
+ const files = collection.files.map((file) => ({
1014
+ relativePath: file.relativePath,
1015
+ filename: file.filename,
1016
+ mimeType: file.mimeType,
1017
+ sizeBytes: file.sizeBytes,
1018
+ sha256: file.sha256,
1019
+ body: file.bytes,
1020
+ }));
1021
+ await this.sessionOutputCoordinator.replaceSessionOutputsForRuntimeTurn({
1022
+ workspaceId,
1023
+ sessionId,
1024
+ turnId: prompt.turnId,
1025
+ ownerId: prompt.ownerId,
1026
+ ownerGeneration: prompt.ownerGeneration,
1027
+ files,
1028
+ });
1029
+ }
1030
+ catch (error) {
1031
+ log.warn("session_output_indexing_failed", {
1032
+ workspaceId,
1033
+ sessionId,
1034
+ error,
1035
+ });
1036
+ }
1037
+ }
1038
+ canCommitSessionOutputsFromRuntimeTurn(workspaceId, sessionId, prompt) {
1039
+ const key = sessionScopeKey(workspaceId, sessionId);
1040
+ if (this.closedSessions.has(key) || this.deletedSessions.has(key))
1041
+ return false;
1042
+ const turn = this.events
1043
+ .listPendingRuntimeTurns(workspaceId)
1044
+ .find((candidate) => candidate.session_id === sessionId &&
1045
+ candidate.turn_id === prompt.turnId);
1046
+ return (turn !== undefined &&
1047
+ turn.owner_id === prompt.ownerId &&
1048
+ turn.owner_generation === prompt.ownerGeneration);
1049
+ }
1050
+ startRuntimeLeaseRenewal(workspaceId, sessionId, prompt) {
1051
+ const intervalMs = Math.max(10, Math.min(30_000, Math.floor(this.leaseTtlMs / 2)));
1052
+ const timer = setInterval(() => {
1053
+ const now = new Date().toISOString();
1054
+ try {
1055
+ this.events.appendBatchWithRuntimeChanges([], {
1056
+ leaseRenewals: [
1057
+ {
1058
+ workspaceId,
1059
+ sessionId,
1060
+ turnId: prompt.turnId,
1061
+ ownerId: prompt.ownerId,
1062
+ ownerGeneration: prompt.ownerGeneration,
1063
+ leaseExpiresAt: leaseExpiresAt(now, this.leaseTtlMs),
1064
+ now,
1065
+ },
1066
+ ],
1067
+ });
1068
+ }
1069
+ catch (error) {
1070
+ if (error instanceof RuntimeTurnOwnershipLostError) {
1071
+ log.debug("runtime_lease_renewal_ownership_lost", {
1072
+ workspaceId,
1073
+ sessionId,
1074
+ turnId: error.turnId,
1075
+ });
1076
+ }
1077
+ else {
1078
+ log.error("runtime_lease_renewal_failed", { sessionId, error });
1079
+ }
1080
+ clearInterval(timer);
1081
+ }
1082
+ }, intervalMs);
1083
+ timer.unref?.();
1084
+ return () => clearInterval(timer);
1085
+ }
1086
+ markRuntimeTurnState(workspaceId, sessionId, turnId, ownerId, ownerGeneration, state) {
1087
+ const now = new Date().toISOString();
1088
+ this.events.appendBatchWithRuntimeChanges([], {
1089
+ turnStates: [
1090
+ {
1091
+ workspaceId,
1092
+ sessionId,
1093
+ turnId,
1094
+ ownerId,
1095
+ ownerGeneration,
1096
+ leaseExpiresAt: leaseExpiresAt(now, this.leaseTtlMs),
1097
+ state,
1098
+ now,
1099
+ },
1100
+ ],
1101
+ });
1102
+ }
1103
+ closeRuntimeTurn(workspaceId, sessionId, turnId, ownerId, ownerGeneration, state, reason) {
1104
+ this.events.appendBatchWithRuntimeChanges([], {
1105
+ closedTurns: [
1106
+ {
1107
+ workspaceId,
1108
+ sessionId,
1109
+ turnId,
1110
+ ownerId,
1111
+ ownerGeneration,
1112
+ reason,
1113
+ state,
1114
+ now: new Date().toISOString(),
1115
+ },
1116
+ ],
1117
+ });
1118
+ }
1119
+ closeRuntimeTurnWithSyntheticSpanEnds(workspaceId, sessionId, prompt, openModelRequestStartIds, state, reason) {
1120
+ if (openModelRequestStartIds.length === 0) {
1121
+ this.closeRuntimeTurn(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, state, reason);
1122
+ return;
1123
+ }
1124
+ const now = new Date().toISOString();
1125
+ const rows = materializePersistedEvents(workspaceId, sessionId, syntheticSpanModelRequestEndDrafts(openModelRequestStartIds), now);
1126
+ // This is a defensive cleanup path for an impossible or provider-buggy
1127
+ // stream shape. If it runs on clean completion, the synthetic end may land
1128
+ // after status_idle; preserving closure is more important than timeline
1129
+ // aesthetics for this fallback.
1130
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, rows, {
1131
+ closedTurns: [
1132
+ {
1133
+ workspaceId,
1134
+ sessionId,
1135
+ turnId: prompt.turnId,
1136
+ ownerId: prompt.ownerId,
1137
+ ownerGeneration: prompt.ownerGeneration,
1138
+ reason,
1139
+ state,
1140
+ now,
1141
+ },
1142
+ ],
1143
+ });
1144
+ }
1145
+ closeReleasedRuntimeAction(workspaceId, sessionId, actionId, reason) {
1146
+ if (reason === null)
1147
+ return;
1148
+ this.events.appendBatchWithRuntimeChanges([], {
1149
+ closedActions: [
1150
+ {
1151
+ workspaceId,
1152
+ sessionId,
1153
+ actionId,
1154
+ reason,
1155
+ now: new Date().toISOString(),
1156
+ },
1157
+ ],
1158
+ });
1159
+ }
1160
+ persistLifecycleDrafts(workspaceId, sessionId, drafts) {
1161
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1162
+ return;
1163
+ const now = new Date().toISOString();
1164
+ const rows = materializePersistedEvents(workspaceId, sessionId, drafts, now);
1165
+ persistAndPublish(this.events, this.broadcaster, rows);
1166
+ }
1167
+ persistCustomToolUse(workspaceId, sessionId, turnId, ownerId, ownerGeneration, event) {
1168
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1169
+ return;
1170
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1171
+ return;
1172
+ try {
1173
+ const now = new Date().toISOString();
1174
+ const useRows = materializePersistedEvents(workspaceId, sessionId, [
1175
+ {
1176
+ type: "agent.custom_tool_use",
1177
+ payload: {
1178
+ name: event.name,
1179
+ input: event.input,
1180
+ },
1181
+ },
1182
+ ], now);
1183
+ event.bindCustomToolUseId(useRows[0].id, (reason) => {
1184
+ if (reason !== undefined) {
1185
+ this.closeReleasedRuntimeAction(workspaceId, sessionId, useRows[0].id, reason);
1186
+ }
1187
+ this.removePendingCustomToolAction(workspaceId, sessionId, useRows[0].id);
1188
+ });
1189
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, useRows, {
1190
+ openedActions: [
1191
+ {
1192
+ workspaceId,
1193
+ sessionId,
1194
+ turnId,
1195
+ actionId: useRows[0].id,
1196
+ actionType: "custom_tool",
1197
+ now,
1198
+ },
1199
+ ],
1200
+ turnStates: [
1201
+ {
1202
+ workspaceId,
1203
+ sessionId,
1204
+ turnId,
1205
+ ownerId,
1206
+ ownerGeneration,
1207
+ state: "paused",
1208
+ now,
1209
+ },
1210
+ ],
1211
+ });
1212
+ this.addPendingCustomToolAction(workspaceId, sessionId, useRows[0].id);
1213
+ }
1214
+ catch (error) {
1215
+ event.rejectCustomToolUse(toError(error));
1216
+ throw error;
1217
+ }
1218
+ }
1219
+ persistToolPermissionUse(workspaceId, sessionId, turnId, ownerId, ownerGeneration, event) {
1220
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1221
+ return;
1222
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1223
+ return;
1224
+ try {
1225
+ const now = new Date().toISOString();
1226
+ const useRows = materializeToolPermissionUseRows({
1227
+ workspaceId,
1228
+ sessionId,
1229
+ event,
1230
+ now,
1231
+ onReleased: (toolUseId, reason) => {
1232
+ if (reason !== undefined) {
1233
+ this.closeReleasedRuntimeAction(workspaceId, sessionId, toolUseId, reason);
1234
+ }
1235
+ this.removePendingToolConfirmation(workspaceId, sessionId, toolUseId);
1236
+ },
1237
+ });
1238
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, useRows, {
1239
+ ...toolPermissionRuntimeChanges({
1240
+ workspaceId,
1241
+ sessionId,
1242
+ turnId,
1243
+ ownerId,
1244
+ ownerGeneration,
1245
+ evaluatedPermission: event.evaluatedPermission,
1246
+ toolUseId: useRows[0].id,
1247
+ now,
1248
+ }),
1249
+ });
1250
+ if (event.evaluatedPermission === "ask") {
1251
+ this.addPendingToolConfirmation(workspaceId, sessionId, useRows[0].id);
1252
+ }
1253
+ }
1254
+ catch (error) {
1255
+ event.rejectToolUse(toError(error));
1256
+ throw error;
1257
+ }
1258
+ }
1259
+ persistToolPermissionUseWithModelEnd(workspaceId, sessionId, turnId, ownerId, ownerGeneration, event, closingModelRequestStartId) {
1260
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId))) {
1261
+ return undefined;
1262
+ }
1263
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId))) {
1264
+ return undefined;
1265
+ }
1266
+ const permission = event.permissionUse;
1267
+ try {
1268
+ const now = new Date().toISOString();
1269
+ const useRows = materializeToolPermissionUseRows({
1270
+ workspaceId,
1271
+ sessionId,
1272
+ event: permission,
1273
+ now,
1274
+ onReleased: (toolUseId, reason) => {
1275
+ if (reason !== undefined) {
1276
+ this.closeReleasedRuntimeAction(workspaceId, sessionId, toolUseId, reason);
1277
+ }
1278
+ this.removePendingToolConfirmation(workspaceId, sessionId, toolUseId);
1279
+ },
1280
+ });
1281
+ const suppressedPiToolCallIds = new Set([
1282
+ permission.piToolCallId,
1283
+ ...event.suppressedPiToolCallIds,
1284
+ ]);
1285
+ const transcriptDrafts = this.runtimeTranslator?.(event.messageEnd, {
1286
+ customToolNames: this.runtimeRunner?.customToolNames?.(workspaceId, sessionId),
1287
+ publicToolUseIdForPiToolCallId: (piToolCallId) => this.runtimeRunner?.publicToolUseIdForPiToolCallId?.(workspaceId, sessionId, piToolCallId),
1288
+ suppressPiToolUse: (piToolCallId) => suppressedPiToolCallIds.has(piToolCallId) ||
1289
+ this.runtimeRunner?.suppressPiToolUse?.(workspaceId, sessionId, piToolCallId) === true,
1290
+ }) ?? [];
1291
+ const spanEndDrafts = spanModelRequestEndDraft(event.messageEnd, closingModelRequestStartId);
1292
+ const remainingRows = materializePersistedEvents(workspaceId, sessionId, [...transcriptDrafts, ...spanEndDrafts], now);
1293
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, [...useRows, ...remainingRows], {
1294
+ ...toolPermissionRuntimeChanges({
1295
+ workspaceId,
1296
+ sessionId,
1297
+ turnId,
1298
+ ownerId,
1299
+ ownerGeneration,
1300
+ evaluatedPermission: permission.evaluatedPermission,
1301
+ toolUseId: useRows[0].id,
1302
+ now,
1303
+ }),
1304
+ closedModelRequestStarts: spanEndDrafts.length === 0 ||
1305
+ closingModelRequestStartId === undefined
1306
+ ? []
1307
+ : [
1308
+ {
1309
+ workspaceId,
1310
+ sessionId,
1311
+ turnId,
1312
+ ownerId,
1313
+ ownerGeneration,
1314
+ startEventId: closingModelRequestStartId,
1315
+ now,
1316
+ },
1317
+ ],
1318
+ });
1319
+ if (permission.evaluatedPermission === "ask") {
1320
+ this.addPendingToolConfirmation(workspaceId, sessionId, useRows[0].id);
1321
+ }
1322
+ return spanEndDrafts.length > 0 ? closingModelRequestStartId : undefined;
1323
+ }
1324
+ catch (error) {
1325
+ permission.rejectToolUse(toError(error));
1326
+ throw error;
1327
+ }
1328
+ }
1329
+ // ── MCP persistence (plan 0122 §4.4/§4.5) — mirrors the tool-permission
1330
+ // pair: sevt_* id bound on persist (before the tool executes), ask-path
1331
+ // opens a tool_confirmation action, allow-path continues running.
1332
+ persistMcpToolUse(workspaceId, sessionId, turnId, ownerId, ownerGeneration, event) {
1333
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1334
+ return;
1335
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1336
+ return;
1337
+ try {
1338
+ const now = new Date().toISOString();
1339
+ const useRows = materializeMcpToolUseRows({
1340
+ workspaceId,
1341
+ sessionId,
1342
+ event,
1343
+ now,
1344
+ onReleased: (toolUseId, reason) => {
1345
+ if (reason !== undefined) {
1346
+ this.closeReleasedRuntimeAction(workspaceId, sessionId, toolUseId, reason);
1347
+ }
1348
+ this.removePendingToolConfirmation(workspaceId, sessionId, toolUseId);
1349
+ },
1350
+ });
1351
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, useRows, {
1352
+ ...toolPermissionRuntimeChanges({
1353
+ workspaceId,
1354
+ sessionId,
1355
+ turnId,
1356
+ ownerId,
1357
+ ownerGeneration,
1358
+ evaluatedPermission: event.evaluatedPermission,
1359
+ toolUseId: useRows[0].id,
1360
+ now,
1361
+ }),
1362
+ });
1363
+ if (event.evaluatedPermission === "ask") {
1364
+ this.addPendingToolConfirmation(workspaceId, sessionId, useRows[0].id);
1365
+ }
1366
+ }
1367
+ catch (error) {
1368
+ event.rejectToolUse(toError(error));
1369
+ throw error;
1370
+ }
1371
+ }
1372
+ persistMcpToolUseWithModelEnd(workspaceId, sessionId, turnId, ownerId, ownerGeneration, event, closingModelRequestStartId) {
1373
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId))) {
1374
+ return undefined;
1375
+ }
1376
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId))) {
1377
+ return undefined;
1378
+ }
1379
+ const mcpToolUse = event.mcpToolUse;
1380
+ try {
1381
+ const now = new Date().toISOString();
1382
+ const useRows = materializeMcpToolUseRows({
1383
+ workspaceId,
1384
+ sessionId,
1385
+ event: mcpToolUse,
1386
+ now,
1387
+ onReleased: (toolUseId, reason) => {
1388
+ if (reason !== undefined) {
1389
+ this.closeReleasedRuntimeAction(workspaceId, sessionId, toolUseId, reason);
1390
+ }
1391
+ this.removePendingToolConfirmation(workspaceId, sessionId, toolUseId);
1392
+ },
1393
+ });
1394
+ const suppressedPiToolCallIds = new Set([
1395
+ mcpToolUse.piToolCallId,
1396
+ ...event.suppressedPiToolCallIds,
1397
+ ]);
1398
+ const transcriptDrafts = this.runtimeTranslator?.(event.messageEnd, {
1399
+ customToolNames: this.runtimeRunner?.customToolNames?.(workspaceId, sessionId),
1400
+ publicToolUseIdForPiToolCallId: (piToolCallId) => this.runtimeRunner?.publicToolUseIdForPiToolCallId?.(workspaceId, sessionId, piToolCallId),
1401
+ suppressPiToolUse: (piToolCallId) => suppressedPiToolCallIds.has(piToolCallId) ||
1402
+ this.runtimeRunner?.suppressPiToolUse?.(workspaceId, sessionId, piToolCallId) === true,
1403
+ }) ?? [];
1404
+ const spanEndDrafts = spanModelRequestEndDraft(event.messageEnd, closingModelRequestStartId);
1405
+ const remainingRows = materializePersistedEvents(workspaceId, sessionId, [...transcriptDrafts, ...spanEndDrafts], now);
1406
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, [...useRows, ...remainingRows], {
1407
+ ...toolPermissionRuntimeChanges({
1408
+ workspaceId,
1409
+ sessionId,
1410
+ turnId,
1411
+ ownerId,
1412
+ ownerGeneration,
1413
+ evaluatedPermission: mcpToolUse.evaluatedPermission,
1414
+ toolUseId: useRows[0].id,
1415
+ now,
1416
+ }),
1417
+ closedModelRequestStarts: spanEndDrafts.length === 0 ||
1418
+ closingModelRequestStartId === undefined
1419
+ ? []
1420
+ : [
1421
+ {
1422
+ workspaceId,
1423
+ sessionId,
1424
+ turnId,
1425
+ ownerId,
1426
+ ownerGeneration,
1427
+ startEventId: closingModelRequestStartId,
1428
+ now,
1429
+ },
1430
+ ],
1431
+ });
1432
+ if (mcpToolUse.evaluatedPermission === "ask") {
1433
+ this.addPendingToolConfirmation(workspaceId, sessionId, useRows[0].id);
1434
+ }
1435
+ return spanEndDrafts.length > 0 ? closingModelRequestStartId : undefined;
1436
+ }
1437
+ catch (error) {
1438
+ mcpToolUse.rejectToolUse(toError(error));
1439
+ throw error;
1440
+ }
1441
+ }
1442
+ /** Terminal result for a persisted agent.mcp_tool_use — no action state. */
1443
+ persistMcpToolResult(workspaceId, sessionId, turnId, ownerId, ownerGeneration, event) {
1444
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1445
+ return;
1446
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1447
+ return;
1448
+ const now = new Date().toISOString();
1449
+ const rows = materializeMcpToolResultRows({
1450
+ workspaceId,
1451
+ sessionId,
1452
+ now,
1453
+ event,
1454
+ });
1455
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, rows, {
1456
+ turnStates: [
1457
+ {
1458
+ workspaceId,
1459
+ sessionId,
1460
+ turnId,
1461
+ ownerId,
1462
+ ownerGeneration,
1463
+ state: "running",
1464
+ now,
1465
+ },
1466
+ ],
1467
+ });
1468
+ }
1469
+ persistMcpConnectionFailed(workspaceId, sessionId, turnId, ownerId, ownerGeneration, event) {
1470
+ if (this.closedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1471
+ return;
1472
+ if (this.deletedSessions.has(sessionScopeKey(workspaceId, sessionId)))
1473
+ return;
1474
+ const now = new Date().toISOString();
1475
+ const rows = materializeMcpConnectionFailedRows({
1476
+ workspaceId,
1477
+ sessionId,
1478
+ now,
1479
+ event,
1480
+ });
1481
+ persistRuntimeChangesAndPublish(this.events, this.broadcaster, rows, {
1482
+ turnStates: [
1483
+ {
1484
+ workspaceId,
1485
+ sessionId,
1486
+ turnId,
1487
+ ownerId,
1488
+ ownerGeneration,
1489
+ state: "running",
1490
+ now,
1491
+ },
1492
+ ],
1493
+ });
1494
+ }
1495
+ claimCustomToolResults(workspaceId, sessionId, events) {
1496
+ this.rejectAmbiguousCustomToolResultDuplicates(events);
1497
+ const claims = [];
1498
+ let interruptedInBatch = false;
1499
+ const runtimeResolvedInBatch = new Set();
1500
+ for (const event of events) {
1501
+ if (event.type === "user.interrupt") {
1502
+ interruptedInBatch = true;
1503
+ continue;
1504
+ }
1505
+ if (event.type !== "user.custom_tool_result")
1506
+ continue;
1507
+ if (interruptedInBatch ||
1508
+ this.interruptedCustomToolActions
1509
+ .get(sessionScopeKey(workspaceId, sessionId))
1510
+ ?.has(event.custom_tool_use_id) === true) {
1511
+ throw notFound(`No pending custom tool call: ${event.custom_tool_use_id}`);
1512
+ }
1513
+ if (runtimeResolvedInBatch.has(event.custom_tool_use_id)) {
1514
+ claims.push({
1515
+ kind: "duplicate",
1516
+ event,
1517
+ customToolUseId: event.custom_tool_use_id,
1518
+ });
1519
+ continue;
1520
+ }
1521
+ const action = this.events.findRuntimeAction(workspaceId, sessionId, event.custom_tool_use_id);
1522
+ const prior = this.findPersistedCustomToolResult(workspaceId, sessionId, event);
1523
+ if (actionClosedWithoutResult(action)) {
1524
+ throw notFound(`No pending custom tool call: ${event.custom_tool_use_id}`);
1525
+ }
1526
+ if (prior && action && isAcknowledgedInFlightAction(action)) {
1527
+ claims.push({
1528
+ kind: "duplicate",
1529
+ event,
1530
+ customToolUseId: event.custom_tool_use_id,
1531
+ row: prior,
1532
+ });
1533
+ continue;
1534
+ }
1535
+ if (prior && action && !isRuntimeTurnClosed(action.turn.state)) {
1536
+ if (action.turn.owner_id !== this.ownerId &&
1537
+ !isRuntimeLeaseExpired(action.turn.lease_expires_at)) {
1538
+ throw runtimeTurnStillOwned(action.turn_id);
1539
+ }
1540
+ const claimed = this.claimTurnForTerminalization(action.turn);
1541
+ if (!claimed) {
1542
+ throw runtimeTurnStillOwned(action.turn_id);
1543
+ }
1544
+ claims.push({
1545
+ kind: "terminalize",
1546
+ event,
1547
+ customToolUseId: event.custom_tool_use_id,
1548
+ action: { ...action, turn: claimed },
1549
+ });
1550
+ continue;
1551
+ }
1552
+ if (prior) {
1553
+ claims.push({
1554
+ kind: "duplicate",
1555
+ event,
1556
+ customToolUseId: event.custom_tool_use_id,
1557
+ });
1558
+ continue;
1559
+ }
1560
+ if (action &&
1561
+ (action.turn.state === "completed" ||
1562
+ action.turn.state === "terminalized")) {
1563
+ throw invalidRequest(`Custom tool result ${event.custom_tool_use_id} cannot be accepted because the runtime turn is already ${action.turn.state}`);
1564
+ }
1565
+ const commit = this.runtimeRunner?.claimCustomToolResult?.(workspaceId, sessionId, event);
1566
+ if (!commit && this.runtimeRunner?.claimCustomToolResult) {
1567
+ if (action) {
1568
+ if (action.turn.owner_id !== this.ownerId &&
1569
+ !isRuntimeLeaseExpired(action.turn.lease_expires_at)) {
1570
+ throw runtimeTurnStillOwned(action.turn_id);
1571
+ }
1572
+ const claimed = this.claimTurnForTerminalization(action.turn);
1573
+ if (!claimed) {
1574
+ throw runtimeTurnStillOwned(action.turn_id);
1575
+ }
1576
+ claims.push({
1577
+ kind: "terminalize",
1578
+ event,
1579
+ customToolUseId: event.custom_tool_use_id,
1580
+ action: { ...action, turn: claimed },
1581
+ });
1582
+ continue;
1583
+ }
1584
+ throw notFound(`No pending custom tool call: ${event.custom_tool_use_id}`);
1585
+ }
1586
+ if (commit) {
1587
+ runtimeResolvedInBatch.add(event.custom_tool_use_id);
1588
+ claims.push({
1589
+ kind: "live",
1590
+ event,
1591
+ customToolUseId: event.custom_tool_use_id,
1592
+ commit,
1593
+ action,
1594
+ });
1595
+ }
1596
+ }
1597
+ return claims;
1598
+ }
1599
+ rejectAmbiguousCustomToolResultDuplicates(events) {
1600
+ const seen = new Map();
1601
+ for (const event of events) {
1602
+ if (event.type !== "user.custom_tool_result")
1603
+ continue;
1604
+ const existing = seen.get(event.custom_tool_use_id);
1605
+ if (!existing) {
1606
+ seen.set(event.custom_tool_use_id, event);
1607
+ continue;
1608
+ }
1609
+ if (sameCustomToolResult(existing, event))
1610
+ continue;
1611
+ throw invalidRequest(`Custom tool result ${event.custom_tool_use_id} was already accepted with different content`);
1612
+ }
1613
+ }
1614
+ findPersistedCustomToolResult(workspaceId, sessionId, event) {
1615
+ const rows = this.listCustomToolHistory(workspaceId, sessionId);
1616
+ const row = rows.find((candidate) => candidate.type === "user.custom_tool_result" &&
1617
+ candidate.payload.custom_tool_use_id === event.custom_tool_use_id);
1618
+ if (!row)
1619
+ return undefined;
1620
+ if (!sameCustomToolResultPayload(row.payload, event)) {
1621
+ throw invalidRequest(`Custom tool result ${event.custom_tool_use_id} was already accepted with different content`);
1622
+ }
1623
+ return row;
1624
+ }
1625
+ listCustomToolHistory(workspaceId, sessionId) {
1626
+ const rows = [];
1627
+ let page;
1628
+ do {
1629
+ const result = this.events.listPage(workspaceId, sessionId, {
1630
+ order: "asc",
1631
+ limit: 1000,
1632
+ page,
1633
+ types: ["user.custom_tool_result"],
1634
+ });
1635
+ rows.push(...result.data);
1636
+ page = result.next_page ?? undefined;
1637
+ } while (page !== undefined);
1638
+ return rows;
1639
+ }
1640
+ claimToolConfirmations(workspaceId, sessionId, events) {
1641
+ const claims = [];
1642
+ let interruptedInBatch = false;
1643
+ const seenToolUseIds = new Set();
1644
+ for (const event of events) {
1645
+ if (event.type === "user.interrupt") {
1646
+ interruptedInBatch = true;
1647
+ continue;
1648
+ }
1649
+ if (event.type !== "user.tool_confirmation")
1650
+ continue;
1651
+ if (seenToolUseIds.has(event.tool_use_id)) {
1652
+ throw invalidRequest(`\`events\` cannot contain duplicate user.tool_confirmation for ${event.tool_use_id}`);
1653
+ }
1654
+ seenToolUseIds.add(event.tool_use_id);
1655
+ if (interruptedInBatch ||
1656
+ this.interruptedToolConfirmations
1657
+ .get(sessionScopeKey(workspaceId, sessionId))
1658
+ ?.has(event.tool_use_id) === true) {
1659
+ throw notFound(`No pending tool confirmation: ${event.tool_use_id}`);
1660
+ }
1661
+ const completed = this.completedToolConfirmations.get(event.tool_use_id);
1662
+ const persisted = this.findPersistedToolConfirmation(workspaceId, sessionId, event);
1663
+ const action = this.events.findRuntimeAction(workspaceId, sessionId, event.tool_use_id);
1664
+ if (actionClosedWithoutResult(action)) {
1665
+ throw notFound(`No pending tool confirmation: ${event.tool_use_id}`);
1666
+ }
1667
+ const durableCompleted = completed ?? persisted?.completed;
1668
+ if (durableCompleted) {
1669
+ if (durableCompleted.workspaceId !== workspaceId ||
1670
+ durableCompleted.sessionId !== sessionId) {
1671
+ throw notFound(`No pending tool confirmation: ${event.tool_use_id}`);
1672
+ }
1673
+ if (!sameToolConfirmation(durableCompleted, event)) {
1674
+ throw invalidRequest(`Tool confirmation ${event.tool_use_id} was already processed with a different result`);
1675
+ }
1676
+ claims.push({ event, row: durableCompleted.row });
1677
+ continue;
1678
+ }
1679
+ if (persisted?.row && action && isAcknowledgedInFlightAction(action)) {
1680
+ claims.push({ event, row: persisted.row });
1681
+ continue;
1682
+ }
1683
+ const commit = this.runtimeRunner?.claimToolConfirmation?.(workspaceId, sessionId, event);
1684
+ if (!commit) {
1685
+ if (action && !isRuntimeTurnClosed(action.turn.state)) {
1686
+ if (action.turn.owner_id !== this.ownerId &&
1687
+ !isRuntimeLeaseExpired(action.turn.lease_expires_at)) {
1688
+ throw runtimeTurnStillOwned(action.turn_id);
1689
+ }
1690
+ const claimed = this.claimTurnForTerminalization(action.turn);
1691
+ if (!claimed) {
1692
+ throw runtimeTurnStillOwned(action.turn_id);
1693
+ }
1694
+ claims.push({
1695
+ event,
1696
+ toolUseId: event.tool_use_id,
1697
+ action: { ...action, turn: claimed },
1698
+ ...(persisted?.row === undefined ? {} : { row: persisted.row }),
1699
+ });
1700
+ continue;
1701
+ }
1702
+ if (persisted?.row) {
1703
+ this.terminalizeLostToolConfirmation(workspaceId, sessionId, event.tool_use_id);
1704
+ claims.push({ event, row: persisted.row });
1705
+ continue;
1706
+ }
1707
+ if (action) {
1708
+ if (action.turn.owner_id !== this.ownerId &&
1709
+ !isRuntimeLeaseExpired(action.turn.lease_expires_at)) {
1710
+ throw runtimeTurnStillOwned(action.turn_id);
1711
+ }
1712
+ const claimed = this.claimTurnForTerminalization(action.turn);
1713
+ if (!claimed) {
1714
+ throw runtimeTurnStillOwned(action.turn_id);
1715
+ }
1716
+ claims.push({
1717
+ event,
1718
+ toolUseId: event.tool_use_id,
1719
+ action,
1720
+ });
1721
+ continue;
1722
+ }
1723
+ throw notFound(`No pending tool confirmation: ${event.tool_use_id}`);
1724
+ }
1725
+ claims.push({
1726
+ event,
1727
+ toolUseId: event.tool_use_id,
1728
+ commit,
1729
+ ...(persisted?.row === undefined ? {} : { row: persisted.row }),
1730
+ });
1731
+ }
1732
+ return claims;
1733
+ }
1734
+ findPersistedToolConfirmation(workspaceId, sessionId, event) {
1735
+ const rows = this.listToolConfirmationHistory(workspaceId, sessionId);
1736
+ const row = rows.find((candidate) => candidate.type === "user.tool_confirmation" &&
1737
+ candidate.payload.tool_use_id === event.tool_use_id);
1738
+ if (!row)
1739
+ return undefined;
1740
+ const result = row.payload.result;
1741
+ if (result !== "allow" && result !== "deny")
1742
+ return undefined;
1743
+ const denyMessage = row.payload.deny_message;
1744
+ const accepted = {
1745
+ result,
1746
+ denyMessage: denyMessage === null || typeof denyMessage === "string"
1747
+ ? denyMessage
1748
+ : undefined,
1749
+ };
1750
+ if (!sameToolConfirmation(accepted, event)) {
1751
+ throw invalidRequest(`Tool confirmation ${event.tool_use_id} was already accepted with a different result`);
1752
+ }
1753
+ if (!hasToolResultForToolUseId(rows, event.tool_use_id)) {
1754
+ return { row };
1755
+ }
1756
+ return {
1757
+ row,
1758
+ completed: {
1759
+ workspaceId,
1760
+ sessionId,
1761
+ result,
1762
+ denyMessage: accepted.denyMessage,
1763
+ row,
1764
+ },
1765
+ };
1766
+ }
1767
+ terminalizeLostToolConfirmation(workspaceId, sessionId, toolUseId) {
1768
+ this.persistRuntimeDrafts(workspaceId, sessionId, [
1769
+ this.lostToolConfirmationResultDraft(workspaceId, sessionId, toolUseId),
1770
+ {
1771
+ type: "session.status_idle",
1772
+ payload: { stop_reason: { type: "end_turn" } },
1773
+ },
1774
+ ]);
1775
+ }
1776
+ /**
1777
+ * The lost-runtime terminal result must match the use event's family:
1778
+ * agent.mcp_tool_use gets agent.mcp_tool_result (terminal-result rule,
1779
+ * plan 0122 §4.4), builtin gets agent.tool_result. Rare recovery path —
1780
+ * the paged scan is acceptable.
1781
+ */
1782
+ lostToolConfirmationResultDraft(workspaceId, sessionId, toolUseId) {
1783
+ if (this.isMcpToolUseEventId(workspaceId, sessionId, toolUseId)) {
1784
+ return {
1785
+ type: "agent.mcp_tool_result",
1786
+ payload: lostMcpToolConfirmationPayload(toolUseId),
1787
+ };
1788
+ }
1789
+ return {
1790
+ type: "agent.tool_result",
1791
+ payload: lostToolConfirmationPayload(toolUseId),
1792
+ };
1793
+ }
1794
+ isMcpToolUseEventId(workspaceId, sessionId, toolUseId) {
1795
+ let page;
1796
+ do {
1797
+ const result = this.events.listPage(workspaceId, sessionId, {
1798
+ order: "asc",
1799
+ limit: 1000,
1800
+ page,
1801
+ types: ["agent.mcp_tool_use"],
1802
+ });
1803
+ if (result.data.some((row) => row.id === toolUseId))
1804
+ return true;
1805
+ page = result.next_page ?? undefined;
1806
+ } while (page !== undefined);
1807
+ return false;
1808
+ }
1809
+ listToolConfirmationHistory(workspaceId, sessionId) {
1810
+ const rows = [];
1811
+ let page;
1812
+ do {
1813
+ const result = this.events.listPage(workspaceId, sessionId, {
1814
+ order: "asc",
1815
+ limit: 1000,
1816
+ page,
1817
+ types: [
1818
+ "user.tool_confirmation",
1819
+ "agent.tool_result",
1820
+ "agent.mcp_tool_result",
1821
+ ],
1822
+ });
1823
+ rows.push(...result.data);
1824
+ page = result.next_page ?? undefined;
1825
+ } while (page !== undefined);
1826
+ return rows;
1827
+ }
1828
+ addPendingCustomToolAction(workspaceId, sessionId, customToolUseId) {
1829
+ const key = sessionScopeKey(workspaceId, sessionId);
1830
+ let pending = this.pendingCustomToolActions.get(key);
1831
+ if (!pending) {
1832
+ pending = { workspaceId, ids: [], timer: undefined };
1833
+ this.pendingCustomToolActions.set(key, pending);
1834
+ }
1835
+ pending.ids.push(customToolUseId);
1836
+ if (pending.timer)
1837
+ return;
1838
+ // Pi may emit parallel custom-tool calls back-to-back in one runtime burst.
1839
+ // Defer the idle by one macrotask so those calls coalesce into one
1840
+ // requires_action event. If another tool arrives later, we re-emit
1841
+ // requires_action with the full remaining pending set.
1842
+ pending.timer = setTimeout(() => {
1843
+ this.flushPendingActions(pending.workspaceId, sessionId);
1844
+ }, 0);
1845
+ }
1846
+ removePendingCustomToolAction(workspaceId, sessionId, customToolUseId) {
1847
+ const key = sessionScopeKey(workspaceId, sessionId);
1848
+ const pending = this.pendingCustomToolActions.get(key);
1849
+ if (!pending)
1850
+ return;
1851
+ pending.ids = pending.ids.filter((id) => id !== customToolUseId);
1852
+ if (pending.ids.length === 0 && pending.timer === undefined) {
1853
+ this.pendingCustomToolActions.delete(key);
1854
+ }
1855
+ }
1856
+ clearPendingCustomToolActions(workspaceId, sessionId) {
1857
+ const key = sessionScopeKey(workspaceId, sessionId);
1858
+ const pending = this.pendingCustomToolActions.get(key);
1859
+ if (!pending)
1860
+ return [];
1861
+ if (pending.timer)
1862
+ clearTimeout(pending.timer);
1863
+ this.pendingCustomToolActions.delete(key);
1864
+ return [...pending.ids];
1865
+ }
1866
+ hasPendingCustomToolActions(workspaceId, sessionId) {
1867
+ return (this.pendingCustomToolActions.get(sessionScopeKey(workspaceId, sessionId))
1868
+ ?.ids.length ?? 0) > 0;
1869
+ }
1870
+ addPendingToolConfirmation(workspaceId, sessionId, toolUseId) {
1871
+ const key = sessionScopeKey(workspaceId, sessionId);
1872
+ let pending = this.pendingToolConfirmations.get(key);
1873
+ if (!pending) {
1874
+ pending = { workspaceId, ids: [], timer: undefined };
1875
+ this.pendingToolConfirmations.set(key, pending);
1876
+ }
1877
+ pending.ids.push(toolUseId);
1878
+ if (pending.timer)
1879
+ return;
1880
+ pending.timer = setTimeout(() => {
1881
+ this.flushPendingActions(pending.workspaceId, sessionId);
1882
+ }, 0);
1883
+ }
1884
+ removePendingToolConfirmation(workspaceId, sessionId, toolUseId) {
1885
+ const key = sessionScopeKey(workspaceId, sessionId);
1886
+ const pending = this.pendingToolConfirmations.get(key);
1887
+ if (!pending)
1888
+ return;
1889
+ pending.ids = pending.ids.filter((id) => id !== toolUseId);
1890
+ if (pending.ids.length === 0 && pending.timer === undefined) {
1891
+ this.pendingToolConfirmations.delete(key);
1892
+ }
1893
+ }
1894
+ clearPendingToolConfirmations(workspaceId, sessionId) {
1895
+ const key = sessionScopeKey(workspaceId, sessionId);
1896
+ const pending = this.pendingToolConfirmations.get(key);
1897
+ if (!pending)
1898
+ return [];
1899
+ if (pending.timer)
1900
+ clearTimeout(pending.timer);
1901
+ this.pendingToolConfirmations.delete(key);
1902
+ return [...pending.ids];
1903
+ }
1904
+ hasPendingToolConfirmations(workspaceId, sessionId) {
1905
+ return (this.pendingToolConfirmations.get(sessionScopeKey(workspaceId, sessionId))
1906
+ ?.ids.length ?? 0) > 0;
1907
+ }
1908
+ clearCompletedToolConfirmations(workspaceId, sessionId) {
1909
+ for (const [id, completed] of this.completedToolConfirmations) {
1910
+ if (completed.workspaceId === workspaceId &&
1911
+ completed.sessionId === sessionId) {
1912
+ this.completedToolConfirmations.delete(id);
1913
+ }
1914
+ }
1915
+ }
1916
+ hasPendingRuntimeActions(workspaceId, sessionId) {
1917
+ return (this.hasPendingCustomToolActions(workspaceId, sessionId) ||
1918
+ this.hasPendingToolConfirmations(workspaceId, sessionId));
1919
+ }
1920
+ blockInterruptedCustomToolActions(workspaceId, sessionId, customToolUseIds) {
1921
+ if (customToolUseIds.length === 0)
1922
+ return;
1923
+ const key = sessionScopeKey(workspaceId, sessionId);
1924
+ let blocked = this.interruptedCustomToolActions.get(key);
1925
+ if (!blocked) {
1926
+ blocked = new Set();
1927
+ this.interruptedCustomToolActions.set(key, blocked);
1928
+ }
1929
+ for (const id of customToolUseIds)
1930
+ blocked.add(id);
1931
+ }
1932
+ blockInterruptedToolConfirmations(workspaceId, sessionId, toolUseIds) {
1933
+ if (toolUseIds.length === 0)
1934
+ return;
1935
+ const key = sessionScopeKey(workspaceId, sessionId);
1936
+ let blocked = this.interruptedToolConfirmations.get(key);
1937
+ if (!blocked) {
1938
+ blocked = new Set();
1939
+ this.interruptedToolConfirmations.set(key, blocked);
1940
+ }
1941
+ for (const id of toolUseIds)
1942
+ blocked.add(id);
1943
+ }
1944
+ flushPendingActions(workspaceId, sessionId) {
1945
+ const key = sessionScopeKey(workspaceId, sessionId);
1946
+ const custom = this.pendingCustomToolActions.get(key);
1947
+ const confirmations = this.pendingToolConfirmations.get(key);
1948
+ if (!custom && !confirmations)
1949
+ return;
1950
+ if (custom?.timer) {
1951
+ clearTimeout(custom.timer);
1952
+ custom.timer = undefined;
1953
+ }
1954
+ if (confirmations?.timer) {
1955
+ clearTimeout(confirmations.timer);
1956
+ confirmations.timer = undefined;
1957
+ }
1958
+ const ids = [
1959
+ ...(custom?.ids ?? []),
1960
+ ...(confirmations?.ids ?? []),
1961
+ ];
1962
+ if (custom && custom.ids.length === 0) {
1963
+ this.pendingCustomToolActions.delete(key);
1964
+ }
1965
+ if (confirmations && confirmations.ids.length === 0) {
1966
+ this.pendingToolConfirmations.delete(key);
1967
+ }
1968
+ if (ids.length === 0)
1969
+ return;
1970
+ this.persistRuntimeDrafts(workspaceId, sessionId, [
1971
+ {
1972
+ type: "session.status_idle",
1973
+ payload: {
1974
+ stop_reason: {
1975
+ type: "requires_action",
1976
+ event_ids: ids,
1977
+ },
1978
+ },
1979
+ },
1980
+ ]);
1981
+ }
1982
+ flushPendingCustomToolActions(workspaceId, sessionId) {
1983
+ this.flushPendingActions(workspaceId, sessionId);
1984
+ }
1985
+ }