@opengeni/db 1.0.1 → 3.2.0-canary.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 (271) hide show
  1. package/dist/attached-browser-devices.d.ts +7 -0
  2. package/dist/automations.d.ts +154 -0
  3. package/dist/canonical-human-identities.js +3 -3
  4. package/dist/capability-integrations.d.ts +13 -0
  5. package/dist/child-lifecycle-notices.d.ts +68 -0
  6. package/dist/{chunk-4SR4HKKA.js → chunk-2EH4WRHL.js} +19 -11
  7. package/dist/chunk-2EH4WRHL.js.map +1 -0
  8. package/dist/{chunk-CUPM3FGB.js → chunk-2PRS4J5Q.js} +3 -3
  9. package/dist/chunk-6HMM2R5T.js +390 -0
  10. package/dist/chunk-6HMM2R5T.js.map +1 -0
  11. package/dist/{chunk-ZWREYEWA.js → chunk-AZHW7R5V.js} +56 -11
  12. package/dist/chunk-AZHW7R5V.js.map +1 -0
  13. package/dist/{chunk-LTG3S6ZY.js → chunk-E6CLMKEE.js} +2 -2
  14. package/dist/{chunk-6ZYKCRFO.js → chunk-KRLPEMLH.js} +7591 -4417
  15. package/dist/chunk-KRLPEMLH.js.map +1 -0
  16. package/dist/chunk-NAB6FKFN.js +516 -0
  17. package/dist/chunk-NAB6FKFN.js.map +1 -0
  18. package/dist/{chunk-SK7YYHBI.js → chunk-SIBYBA2D.js} +46 -1
  19. package/dist/chunk-SIBYBA2D.js.map +1 -0
  20. package/dist/chunk-UZNXG44K.js +4508 -0
  21. package/dist/chunk-UZNXG44K.js.map +1 -0
  22. package/dist/codemode-operations.d.ts +1 -6
  23. package/dist/company-brain-context-selection-schema.d.ts +705 -0
  24. package/dist/company-brain-context-selection.d.ts +94 -0
  25. package/dist/company-brain-governed-writes.d.ts +24 -0
  26. package/dist/company-brain.d.ts +55 -0
  27. package/dist/company-profile-agent-admin.d.ts +15 -0
  28. package/dist/company-profile-schema.d.ts +619 -2
  29. package/dist/company-profile.d.ts +9 -4
  30. package/dist/connection-authority.d.ts +95 -0
  31. package/dist/connection-scopes.d.ts +8 -0
  32. package/dist/connection-tenancy-backfill.d.ts +85 -0
  33. package/dist/connection-token-resolver.d.ts +30 -1
  34. package/dist/database.d.ts +23 -4
  35. package/dist/editable-artifact-durable-export.js +2 -2
  36. package/dist/editable-artifacts.d.ts +2 -2
  37. package/dist/editable-artifacts.js +3 -3
  38. package/dist/governed-learning-activation-schema.d.ts +984 -0
  39. package/dist/governed-learning-activation.d.ts +98 -0
  40. package/dist/governed-learning-evaluator-schema.d.ts +633 -0
  41. package/dist/governed-learning-evaluator.d.ts +28 -0
  42. package/dist/index.d.ts +1606 -81
  43. package/dist/index.js +21898 -8147
  44. package/dist/index.js.map +1 -1
  45. package/dist/insights.d.ts +18 -0
  46. package/dist/integration-bindings.d.ts +1 -0
  47. package/dist/integration-facets.d.ts +30 -0
  48. package/dist/interaction-placement-loss.d.ts +22 -0
  49. package/dist/managed-human-provisioning.d.ts +25 -0
  50. package/dist/memory-domain.d.ts +3 -3
  51. package/dist/migrate.d.ts +11 -1
  52. package/dist/migrate.js +1 -1
  53. package/dist/new-session-drafts.d.ts +43 -5
  54. package/dist/organization-membership-backfill.d.ts +174 -0
  55. package/dist/organization-membership-lifecycle.d.ts +162 -0
  56. package/dist/pr-review.d.ts +107 -0
  57. package/dist/preference-registry-schema.d.ts +2 -0
  58. package/dist/preference-registry.d.ts +2 -0
  59. package/dist/provision-roles.js +1 -1
  60. package/dist/runtime-posture.d.ts +29 -8
  61. package/dist/schema.d.ts +24914 -13703
  62. package/dist/schema.js +99 -1
  63. package/dist/scoped-knowledge-schema.d.ts +52 -1
  64. package/dist/session-background-commands.d.ts +125 -0
  65. package/dist/session-background-commands.js +34 -0
  66. package/dist/session-background-commands.js.map +1 -0
  67. package/dist/session-control.d.ts +156 -3
  68. package/dist/session-goal-pacing.d.ts +68 -0
  69. package/dist/session-queue-commands.d.ts +43 -4
  70. package/dist/session-realtime-ledger.d.ts +2 -0
  71. package/dist/session-tenancy.d.ts +71 -1
  72. package/dist/session-tenancy.js +25 -3
  73. package/dist/session-tool-call-settlement.d.ts +9 -4
  74. package/dist/slack-routing-personal-workspace.d.ts +23 -0
  75. package/dist/slack-routing.d.ts +268 -0
  76. package/dist/slack-task-policy-schema.d.ts +34 -0
  77. package/dist/slack-task-policy.d.ts +8 -0
  78. package/dist/slack-user-link-access.d.ts +28 -0
  79. package/dist/task-notes-schema.d.ts +594 -0
  80. package/dist/task-notes.d.ts +39 -0
  81. package/dist/turn-initiator.d.ts +2 -0
  82. package/dist/user-resource-authority.d.ts +109 -0
  83. package/dist/video-generation.js +3 -3
  84. package/dist/workspace-authority.d.ts +81 -0
  85. package/dist/workspace-instruction-policies-schema.d.ts +279 -0
  86. package/dist/workspace-instruction-policies.d.ts +62 -0
  87. package/dist/workspace-learning-policy.d.ts +1 -0
  88. package/dist/workspace-membership-permissions.d.ts +2 -0
  89. package/drizzle/0240_enrollment_connection_authority.sql +39 -0
  90. package/drizzle/0241_atomic_personal_resource_delegation.sql +1000 -0
  91. package/drizzle/0241_enrollment_agent_runtime.sql +70 -0
  92. package/drizzle/0244_slack_app_home_refresh_queue.sql +120 -0
  93. package/drizzle/0245_model_context_contribution_facts.sql +88 -0
  94. package/drizzle/0246_integration_personal_instance_authority.sql +70 -0
  95. package/drizzle/0247_terraform_stacks_provenance_repair.sql +428 -0
  96. package/drizzle/0248_terraform_stacks_component_resolution_fence.sql +83 -0
  97. package/drizzle/0249_personal_resource_delegation_authority_correction.sql +662 -0
  98. package/drizzle/0250_direct_retained_process_owner_liveness.sql +199 -0
  99. package/drizzle/0251_connected_machine_operation_policy.sql +45 -0
  100. package/drizzle/0252_scheduled_personal_resource_delegation.sql +1867 -0
  101. package/drizzle/0253_common_user_resource_authority_lifecycle.sql +1555 -0
  102. package/drizzle/0254_scoped_variable_set_authority.sql +1255 -0
  103. package/drizzle/0255_company_brain_governed_write_proposals.sql +524 -0
  104. package/drizzle/0256_connection_authority_delegation.sql +703 -0
  105. package/drizzle/0257_goal_revision_decisions_and_root_constraints.sql +436 -0
  106. package/drizzle/0258_three_scope_document_knowledge_authority.sql +1273 -0
  107. package/drizzle/0259_company_brain_context_selection_receipts.sql +869 -0
  108. package/drizzle/0260_task_note_knowledge_promotion.sql +820 -0
  109. package/drizzle/0261_preference_knowledge_proposal_actor_binding.sql +471 -0
  110. package/drizzle/0262_scoped_connected_machines_and_rigs.sql +1700 -0
  111. package/drizzle/0263_organization_membership_lifecycle.sql +3284 -0
  112. package/drizzle/0264_connection_authority_runtime_activation.sql +1084 -0
  113. package/drizzle/0266_company_brain_context_receipt_inspection.sql +156 -0
  114. package/drizzle/0268_governed_learning_decision_receipts.sql +825 -0
  115. package/drizzle/0269_governed_learning_activation_controller.sql +1874 -0
  116. package/drizzle/0270_governed_learning_history_inspection.sql +170 -0
  117. package/drizzle/0271_company_brain_retrieval_only_default.sql +524 -0
  118. package/drizzle/0272_human_confirmed_learning_activation.sql +591 -0
  119. package/drizzle/0273_scheduled_variable_set_materialization.sql +268 -0
  120. package/drizzle/0274_human_confirmed_knowledge_review.sql +310 -0
  121. package/drizzle/0275_scheduled_connection_authority.sql +5527 -0
  122. package/drizzle/0276_onboarding_proposal_initiating_human_guc.sql +198 -0
  123. package/drizzle/0277_workspace_writer_authority_attribution.sql +393 -0
  124. package/drizzle/0278_workspace_membership_removal_fencing.sql +1174 -0
  125. package/drizzle/0279_workspace_connection_use_lane.sql +343 -0
  126. package/drizzle/0280_connection_and_variable_set_audit_attribution.sql +773 -0
  127. package/drizzle/0281_viewer_holder_authority_claims.sql +52 -0
  128. package/drizzle/0282_variable_set_session_attach_attribution.sql +178 -0
  129. package/drizzle/0283_editable_spreadsheet_authored_state.sql +82 -0
  130. package/drizzle/0284_truthful_human_confirmed_review_reason.sql +930 -0
  131. package/drizzle/0285_organization_tenancy_inventory.sql +329 -0
  132. package/drizzle/0286_widen_task_note_expiry_ceiling.sql +391 -0
  133. package/drizzle/0287_open_suffix_pending_tool_calls.sql +18 -0
  134. package/drizzle/0288_attached_browser_reenrollment.sql +64 -0
  135. package/drizzle/0289_session_composer_policy_authority.sql +597 -0
  136. package/drizzle/0290_organization_membership_backfill.sql +268 -0
  137. package/drizzle/0291_resource_authority_classification_assertion.sql +371 -0
  138. package/drizzle/0292_truthful_tenancy_inventory_counters.sql +291 -0
  139. package/drizzle/0293_confirm_time_rule_rebaseline.sql +615 -0
  140. package/drizzle/0294_preference_activation_authority.sql +237 -0
  141. package/drizzle/0295_retire_legacy_standing_memory_mode.sql +476 -0
  142. package/drizzle/0296_force_rls_backfill_noop_repair.sql +215 -0
  143. package/drizzle/0297_session_ownership_classification_and_backfill.sql +752 -0
  144. package/drizzle/0298_organization_tenancy_parity.sql +609 -0
  145. package/drizzle/0299_organization_membership_lock_order.sql +291 -0
  146. package/drizzle/0300_tenancy_backfill_ledger.sql +459 -0
  147. package/drizzle/0301_session_snapshot_and_pin_visibility.sql +181 -0
  148. package/drizzle/0302_personal_workspace_session_ownership.sql +303 -0
  149. package/drizzle/0303_session_tenancy_product_activation.sql +919 -0
  150. package/drizzle/0304_personal_workspace_private_session_reads.sql +97 -0
  151. package/drizzle/0305_personal_resource_grant_management.sql +584 -0
  152. package/drizzle/0306_atomic_personal_resource_attachments.sql +1374 -0
  153. package/drizzle/0307_session_attention_state.sql +51 -0
  154. package/drizzle/0308_session_archives.sql +35 -0
  155. package/drizzle/0309_channel_project_pins.sql +6 -0
  156. package/drizzle/0310_channel_project_order.sql +27 -0
  157. package/drizzle/0311_company_scope_and_private_session_create.sql +656 -0
  158. package/drizzle/0312_quiescent_session_tree_deletion.sql +39 -0
  159. package/drizzle/0313_private_child_session_authority.sql +365 -0
  160. package/drizzle/0314_unregistered_organization_invitations.sql +606 -0
  161. package/drizzle/0315_personal_github_repository_selection.sql +624 -0
  162. package/drizzle/0316_human_confirmed_activation_resumed_generation.sql +869 -0
  163. package/drizzle/0317_session_goal_continuation_hold.sql +64 -0
  164. package/drizzle/0318_session_turn_startup_milestones.sql +115 -0
  165. package/drizzle/0319_event_triggered_automations.sql +254 -0
  166. package/drizzle/0320_pr_review_pack.sql +113 -0
  167. package/drizzle/0321_slack_bot_environment_display_name.sql +15 -0
  168. package/drizzle/0322_session_turns_unclaimed_prompt_trigger_index.sql +5 -0
  169. package/drizzle/0323_organization_private_session_enablement.sql +489 -0
  170. package/drizzle/0324_human_confirmed_company_profile_agent_admin.sql +856 -0
  171. package/drizzle/0325_child_lifecycle_notices.sql +44 -0
  172. package/drizzle/0326_interaction_operation_error_codes.sql +62 -0
  173. package/drizzle/0327_slack_first_task_hint.sql +29 -0
  174. package/drizzle/0328_session_background_commands.sql +590 -0
  175. package/drizzle/0329_slack_orchestration_delivery_events.sql +91 -0
  176. package/drizzle/0330_api_key_descriptions.sql +20 -0
  177. package/drizzle/0331_managed_organization_creation.sql +163 -0
  178. package/drizzle/0332_organization_shared_workspace_control_plane.sql +539 -0
  179. package/drizzle/0333_session_turn_prompt_routing.sql +45 -0
  180. package/drizzle/0334_connected_machine_workspace_root.sql +99 -0
  181. package/drizzle/0335_slack_workspace_routing.sql +460 -0
  182. package/drizzle/0336_atomic_session_fork_visibility.sql +667 -0
  183. package/drizzle/0337_slack_routed_action_handles.sql +189 -0
  184. package/drizzle/0338_atomic_connected_machine_attachments.sql +410 -0
  185. package/drizzle/0339_document_authority_reclassification.sql +1153 -0
  186. package/drizzle/0340_tenancy_backfill_activation_evidence.sql +1347 -0
  187. package/drizzle/0341_slack_routing_probe_organization_fence.sql +109 -0
  188. package/drizzle/0342_slack_route_prompt_single_pending.sql +32 -0
  189. package/drizzle/0343_personal_document_force_rls_lock_repair.sql +146 -0
  190. package/drizzle/0344_private_session_visibility_transition_gate.sql +85 -0
  191. package/drizzle/0345_tenant_scoped_session_tenancy_fence.sql +2839 -0
  192. package/drizzle/0346_document_migration_audit_surface.sql +454 -0
  193. package/drizzle/0347_connection_authority_convergence_evidence.sql +396 -0
  194. package/package.json +10 -6
  195. package/src/attached-browser-devices.ts +233 -5
  196. package/src/automations.ts +911 -0
  197. package/src/browser-sessions.ts +10 -0
  198. package/src/capability-integrations.ts +175 -1
  199. package/src/child-lifecycle-notices.ts +241 -0
  200. package/src/codemode-operations.ts +1 -35
  201. package/src/company-brain-context-selection-schema.ts +100 -0
  202. package/src/company-brain-context-selection.ts +359 -0
  203. package/src/company-brain-governed-writes.ts +1170 -0
  204. package/src/company-brain.ts +309 -0
  205. package/src/company-profile-agent-admin.ts +225 -0
  206. package/src/company-profile-schema.ts +121 -1
  207. package/src/company-profile.ts +21 -0
  208. package/src/computer-sessions.ts +10 -1
  209. package/src/connection-authority.ts +290 -0
  210. package/src/connection-scopes.ts +20 -0
  211. package/src/connection-tenancy-backfill.ts +167 -0
  212. package/src/connection-token-resolver.ts +263 -31
  213. package/src/database.ts +87 -2
  214. package/src/editable-artifacts-schema.ts +10 -5
  215. package/src/editable-artifacts.ts +21 -11
  216. package/src/governed-learning-activation-schema.ts +121 -0
  217. package/src/governed-learning-activation.ts +588 -0
  218. package/src/governed-learning-evaluator-schema.ts +72 -0
  219. package/src/governed-learning-evaluator.ts +217 -0
  220. package/src/index.ts +11676 -1550
  221. package/src/insights.ts +107 -0
  222. package/src/integration-bindings.ts +88 -6
  223. package/src/integration-facets.ts +287 -84
  224. package/src/interaction-placement-loss.ts +234 -0
  225. package/src/managed-human-provisioning.ts +116 -0
  226. package/src/memory-domain.ts +27 -32
  227. package/src/migrate.ts +77 -1
  228. package/src/new-session-drafts.ts +229 -35
  229. package/src/organization-membership-backfill.ts +735 -0
  230. package/src/organization-membership-lifecycle.ts +995 -0
  231. package/src/pr-review.ts +890 -0
  232. package/src/preference-registry.ts +31 -1
  233. package/src/provision-roles.ts +905 -6
  234. package/src/runtime-posture-cli.ts +2 -0
  235. package/src/runtime-posture.ts +1317 -72
  236. package/src/schema.ts +3316 -428
  237. package/src/scoped-knowledge-schema.ts +16 -8
  238. package/src/scoped-knowledge.ts +1 -1
  239. package/src/session-background-commands.ts +759 -0
  240. package/src/session-control.ts +640 -74
  241. package/src/session-goal-pacing.ts +126 -0
  242. package/src/session-queue-commands.ts +441 -85
  243. package/src/session-realtime-context.ts +4 -6
  244. package/src/session-realtime-ledger.ts +28 -14
  245. package/src/session-realtime-mirror.ts +1 -1
  246. package/src/session-tenancy.ts +410 -49
  247. package/src/session-tool-call-settlement.ts +38 -29
  248. package/src/slack-routing-personal-workspace.ts +67 -0
  249. package/src/slack-routing.ts +867 -0
  250. package/src/slack-task-policy-schema.ts +5 -0
  251. package/src/slack-task-policy.ts +18 -0
  252. package/src/slack-user-link-access.ts +138 -11
  253. package/src/task-notes-schema.ts +74 -0
  254. package/src/task-notes.ts +124 -2
  255. package/src/turn-initiator.ts +8 -0
  256. package/src/user-resource-authority.ts +377 -0
  257. package/src/workspace-authority.ts +160 -0
  258. package/src/workspace-instruction-policies-schema.ts +53 -1
  259. package/src/workspace-instruction-policies.ts +528 -77
  260. package/src/workspace-learning-policy.ts +21 -5
  261. package/src/workspace-membership-permissions.ts +9 -0
  262. package/dist/chunk-4SR4HKKA.js.map +0 -1
  263. package/dist/chunk-6ZYKCRFO.js.map +0 -1
  264. package/dist/chunk-NXAFLCQX.js +0 -2468
  265. package/dist/chunk-NXAFLCQX.js.map +0 -1
  266. package/dist/chunk-SK7YYHBI.js.map +0 -1
  267. package/dist/chunk-WD5ROSLL.js +0 -117
  268. package/dist/chunk-WD5ROSLL.js.map +0 -1
  269. package/dist/chunk-ZWREYEWA.js.map +0 -1
  270. /package/dist/{chunk-CUPM3FGB.js.map → chunk-2PRS4J5Q.js.map} +0 -0
  271. /package/dist/{chunk-LTG3S6ZY.js.map → chunk-E6CLMKEE.js.map} +0 -0
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import {
3
3
  boundWorkspaceControlEvent,
4
+ childPausedClassification,
4
5
  McpPersonalConnectionDelegations,
5
6
  workspaceControlUtf8Bytes,
6
7
  type SessionMcpApprovalPolicy,
@@ -9,12 +10,25 @@ import {
9
10
  import { and, eq, inArray, sql } from "drizzle-orm";
10
11
  import type { Database, SessionActivityDatabase } from "./database";
11
12
  import { withLosslessContentWriteVersion } from "./lossless-json";
13
+ import { nestedPostgresSqlState } from "./persistence-errors";
12
14
  import * as schema from "./schema";
15
+ import {
16
+ boundedChildPausedReason,
17
+ childLifecycleNoticesEnabled,
18
+ childPausedDedupeKey,
19
+ childPausedSummary,
20
+ childRequiresActionResolvedDedupeKey,
21
+ childRequiresActionResolvedSummary,
22
+ } from "./child-lifecycle-notices";
13
23
  import { closePendingSessionToolCallsInTransaction } from "./session-tool-call-settlement";
14
24
  import {
15
25
  mirrorSessionRealtimeContextInTransaction,
16
26
  renderRealtimeHumanInputResponseContext,
17
27
  } from "./session-realtime-mirror";
28
+ import {
29
+ requestSessionBackgroundCommandCancellationsInTransaction,
30
+ requestWorkspaceBackgroundCommandCancellationsInTransaction,
31
+ } from "./session-background-commands";
18
32
 
19
33
  type SessionEventInsertWithPayload = typeof schema.sessionEvents.$inferInsert & {
20
34
  payload: unknown;
@@ -84,6 +98,10 @@ export type EffectiveSessionControl = {
84
98
  interruptionPendingCount: number;
85
99
  quiescencePendingCount: number;
86
100
  } | null;
101
+ backgroundCommandSettlement: {
102
+ state: "stopping";
103
+ commandCount: number;
104
+ } | null;
87
105
  };
88
106
 
89
107
  type SettlementAttemptCounts = {
@@ -300,23 +318,8 @@ export async function assertAgentCommandAuthorityInTransaction(
300
318
  if (input.action === "steer" && input.targetSessionId === input.actor.sessionId) {
301
319
  throw new AgentCommandAuthorityError("SELF_STEER", "An agent cannot steer its own session");
302
320
  }
303
- const targetsSelf = targetSession.id === callerSession.id;
304
- const targetsDirectParent = callerSession.parentSessionId === targetSession.id;
305
- const targetsDirectChild = targetSession.parentSessionId === callerSession.id;
306
- const verticalTargetAllowed =
307
- input.action === "goal"
308
- ? targetsSelf
309
- : input.action === "message"
310
- ? targetsSelf || targetsDirectParent || targetsDirectChild
311
- : targetsDirectChild;
312
- if (!verticalTargetAllowed) {
313
- throw new AgentCommandAuthorityError(
314
- "TARGET_NOT_VERTICAL",
315
- input.action === "message"
316
- ? "An agent may message only its own session, direct parent, or direct child"
317
- : "An agent may control only a direct child session",
318
- );
319
- }
321
+ // Peer, parent, child, and unrelated-root targets are allowed here. Slack-private
322
+ // and user_private denies remain in requireSessionAuthorization.
320
323
  }
321
324
 
322
325
  function asSafeRevision(value: number | string | null, label: string): number | null {
@@ -360,25 +363,188 @@ function lockClause(mode: WorkspaceControlLockMode) {
360
363
  return mode === "update" ? sql.raw("for update") : sql.raw("for share");
361
364
  }
362
365
 
366
+ /**
367
+ * Default bounded control-prefix wait for request-scoped API mutations (Send,
368
+ * Steer, queued Steer, Pause/Resume/Cancel, workspace Pause/Resume, realtime
369
+ * sync). The bound is one budget across every lock step of the admission
370
+ * (advisory lock, row lock, and the savepoint escalation of a paused branch),
371
+ * not a per-statement timeout. A request that cannot enter the workspace
372
+ * control prefix within the budget fails with the typed, retryable
373
+ * `WorkspaceControlBusyError` before any write instead of holding a pooled
374
+ * connection and a snapshot for as long as the HTTP client (or ingress) keeps
375
+ * the socket open. Worker settlement, claims, and event appends never pass a
376
+ * bound: a legitimate wait there must not become a failed settlement.
377
+ * The deployment value is `OPENGENI_WORKSPACE_CONTROL_LOCK_TIMEOUT_MS`, parsed
378
+ * and validated once at boot by `@opengeni/config`
379
+ * (`settings.workspaceControlLockTimeoutMs`) and handed to this module through
380
+ * `configureWorkspaceControlRequestLockTimeoutMs` when the API app is built.
381
+ * This module never reads `process.env` per request.
382
+ */
383
+ export const WORKSPACE_CONTROL_REQUEST_LOCK_TIMEOUT_MS = 20_000;
384
+
385
+ let configuredRequestLockTimeoutMs = WORKSPACE_CONTROL_REQUEST_LOCK_TIMEOUT_MS;
386
+
387
+ /**
388
+ * Install the validated request-scoped budget for this process. The value is
389
+ * process-global and last-writer-wins: the API calls this exactly once from
390
+ * its settings at app composition (`createApp`), and every later
391
+ * `workspaceControlRequestLockTimeoutMs()` read observes that value. Workers
392
+ * never call it because they never pass a bound. A non-positive or
393
+ * non-integer value is a programming error here (the config layer already
394
+ * rejected it at boot).
395
+ */
396
+ export function configureWorkspaceControlRequestLockTimeoutMs(lockTimeoutMs: number): void {
397
+ if (!Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs <= 0) {
398
+ throw new Error("Workspace control request lock timeout must be a positive integer (ms)");
399
+ }
400
+ configuredRequestLockTimeoutMs = lockTimeoutMs;
401
+ }
402
+
403
+ /** The request-scoped budget in milliseconds currently configured for this process. */
404
+ export function workspaceControlRequestLockTimeoutMs(): number {
405
+ return configuredRequestLockTimeoutMs;
406
+ }
407
+
408
+ export class WorkspaceControlBusyError extends Error {
409
+ readonly code = "WORKSPACE_CONTROL_BUSY";
410
+
411
+ constructor(
412
+ readonly workspaceId: string,
413
+ readonly lockTimeoutMs: number,
414
+ ) {
415
+ super("The workspace control row is busy; retry shortly");
416
+ this.name = "WorkspaceControlBusyError";
417
+ }
418
+ }
419
+
420
+ /**
421
+ * One wall-clock budget shared by every lock step of one control-prefix
422
+ * acquisition. Each step sets `lock_timeout` to the remaining budget, so the
423
+ * total wait never exceeds the requested bound even when the advisory lock,
424
+ * the row lock, and a savepoint escalation each wait in turn.
425
+ */
426
+ export type WorkspaceControlLockBudget = {
427
+ readonly totalMs: number;
428
+ readonly deadlineAt: number;
429
+ };
430
+
431
+ export function workspaceControlLockBudget(lockTimeoutMs: number): WorkspaceControlLockBudget {
432
+ if (!Number.isFinite(lockTimeoutMs) || lockTimeoutMs <= 0) {
433
+ throw new Error("Workspace control lockTimeoutMs must be a positive number");
434
+ }
435
+ const totalMs = Math.max(1, Math.floor(lockTimeoutMs));
436
+ return { totalMs, deadlineAt: Date.now() + totalMs };
437
+ }
438
+
439
+ export type WorkspaceControlLockOptions = {
440
+ /**
441
+ * Bound the whole prefix acquisition in milliseconds. Omit for
442
+ * worker/lifecycle writers. The previous `lock_timeout` is restored once the
443
+ * prefix is held.
444
+ */
445
+ lockTimeoutMs?: number;
446
+ /** Share an already running budget (used by the admission helper). */
447
+ budget?: WorkspaceControlLockBudget;
448
+ };
449
+
450
+ function advisoryLockFunction(mode: Exclude<WorkspaceControlLockMode, "none">) {
451
+ return mode === "update"
452
+ ? sql.raw("pg_advisory_xact_lock")
453
+ : sql.raw("pg_advisory_xact_lock_shared");
454
+ }
455
+
456
+ function lockBudgetFor(options: WorkspaceControlLockOptions): WorkspaceControlLockBudget | null {
457
+ if (options.budget) return options.budget;
458
+ if (options.lockTimeoutMs === undefined) return null;
459
+ return workspaceControlLockBudget(options.lockTimeoutMs);
460
+ }
461
+
462
+ /** Run one lock-acquiring statement under the remaining budget. */
463
+ async function boundedLockStep<T>(
464
+ db: Database,
465
+ workspaceId: string,
466
+ budget: WorkspaceControlLockBudget | null,
467
+ step: () => Promise<T>,
468
+ ): Promise<T> {
469
+ if (!budget) return await step();
470
+ const remainingMs = budget.deadlineAt - Date.now();
471
+ if (remainingMs <= 0) throw new WorkspaceControlBusyError(workspaceId, budget.totalMs);
472
+ await db.execute(
473
+ sql`select set_config('lock_timeout', ${`${Math.max(1, Math.floor(remainingMs))}ms`}, true)`,
474
+ );
475
+ try {
476
+ return await step();
477
+ } catch (error) {
478
+ if (nestedPostgresSqlState(error) === "55P03") {
479
+ // Deliberately no `cause`: the 55P03 must not stay reachable through the
480
+ // cause chain, or persistence-retry classification and the delete-tree
481
+ // `live_sandboxes` 55P03 mapping would reclassify this typed busy error.
482
+ throw new WorkspaceControlBusyError(workspaceId, budget.totalMs);
483
+ }
484
+ throw error;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Lock the mandatory workspace inference-control row.
490
+ *
491
+ * `share` and `update` first take a transaction-scoped advisory lock on
492
+ * `hashtextextended('workspace-control:<workspace id>', 0)` in the matching
493
+ * mode and only then the row lock. The advisory lock is what makes the prefix
494
+ * fair: PostgreSQL row locks let a new FOR SHARE requester join a share-locked
495
+ * tuple without queueing behind an already waiting FOR UPDATE, so under
496
+ * continuous share traffic (claims, settlement, appends across many sessions)
497
+ * a genuine control mutation can starve indefinitely. Heavyweight locks have a
498
+ * FIFO wait queue: a shared request conflicts with a waiting exclusive request
499
+ * and queues behind it, so a mutator waits only for the holders that preceded
500
+ * it, and sharers keep flowing between mutations. The row lock is retained
501
+ * unchanged for FK/consistency semantics and for SQL-function writers that
502
+ * lock the row directly (session-create triggers and capability functions).
503
+ *
504
+ * Invariant: a transaction that locks this row through any other path (a
505
+ * SECURITY DEFINER function, a trigger, raw SQL) must already hold the prefix
506
+ * through this helper or must never call this helper afterwards for the same
507
+ * workspace. Requesting the advisory lock after holding the row lock can
508
+ * deadlock against a mutator that holds the advisory lock and waits for the
509
+ * row. Every current SQL-function locker either runs in its own transaction or
510
+ * runs after this helper in the same transaction.
511
+ */
363
512
  export async function lockWorkspaceInferenceControl(
364
513
  db: Database,
365
514
  workspaceId: string,
366
515
  mode: WorkspaceControlLockMode,
516
+ options: WorkspaceControlLockOptions = {},
367
517
  ): Promise<WorkspaceControlRow> {
368
- const rows = await db.execute<WorkspaceControlRow>(sql`
369
- select
370
- workspace_id as "workspaceId",
371
- account_id as "accountId",
372
- revision,
373
- workspace_state as "workspaceState",
374
- workspace_pause_revision as "workspacePauseRevision",
375
- reason,
376
- changed_by as "changedBy",
377
- changed_at as "changedAt"
378
- from ${schema.workspaceInferenceControls}
379
- where workspace_id = ${workspaceId}
380
- ${lockClause(mode)}
381
- `);
518
+ const budget = lockBudgetFor(options);
519
+ const [prior] = budget
520
+ ? await db.execute<{ value: string }>(sql`select current_setting('lock_timeout') as value`)
521
+ : [];
522
+ if (mode !== "none") {
523
+ await boundedLockStep(db, workspaceId, budget, () =>
524
+ db.execute(
525
+ sql`select ${advisoryLockFunction(mode)}(hashtextextended(${`workspace-control:${workspaceId}`}, 0))`,
526
+ ),
527
+ );
528
+ }
529
+ const rows = await boundedLockStep(db, workspaceId, budget, () =>
530
+ db.execute<WorkspaceControlRow>(sql`
531
+ select
532
+ workspace_id as "workspaceId",
533
+ account_id as "accountId",
534
+ revision,
535
+ workspace_state as "workspaceState",
536
+ workspace_pause_revision as "workspacePauseRevision",
537
+ reason,
538
+ changed_by as "changedBy",
539
+ changed_at as "changedAt"
540
+ from ${schema.workspaceInferenceControls}
541
+ where workspace_id = ${workspaceId}
542
+ ${lockClause(mode)}
543
+ `),
544
+ );
545
+ if (budget) {
546
+ await db.execute(sql`select set_config('lock_timeout', ${prior?.value ?? "0"}, true)`);
547
+ }
382
548
  const row = rows[0];
383
549
  if (!row) {
384
550
  throw new SessionControlInvariantError(
@@ -388,6 +554,69 @@ export async function lockWorkspaceInferenceControl(
388
554
  return row;
389
555
  }
390
556
 
557
+ export type WorkspaceControlAdmissionLock = {
558
+ control: WorkspaceControlRow;
559
+ mode: "share" | "update";
560
+ };
561
+
562
+ const WORKSPACE_CONTROL_ADMISSION_SAVEPOINT = "opengeni_workspace_control_admission";
563
+
564
+ /**
565
+ * Lock the workspace control prefix for an admission. Callers that may resume
566
+ * a paused branch request escalation; inert queue admission stays shared.
567
+ *
568
+ * The common case (the target branch is active) holds only `FOR SHARE`. That
569
+ * still excludes every control mutation (Pause/Resume/Cancel, workspace
570
+ * control, auto-resume all take `FOR UPDATE`), so the effective state observed
571
+ * here cannot change for the rest of the transaction, while other Sends,
572
+ * Steers, claims, and event appends on the workspace proceed concurrently.
573
+ *
574
+ * When the branch is paused the admission must advance the workspace control
575
+ * revision, so it needs `FOR UPDATE`. Upgrading in place would deadlock as soon
576
+ * as two sharers decide to upgrade together (each waits for the other's share
577
+ * lock), so the shared prefix is taken inside a savepoint and rolled back
578
+ * before the exclusive prefix is requested: PostgreSQL releases row locks and
579
+ * transaction-level advisory locks acquired inside a rolled-back savepoint, so
580
+ * the exclusive request queues normally behind other holders.
581
+ *
582
+ * Callers that already hold the prefix exclusively (for example a realtime
583
+ * sync that admitted a delegation) simply observe their own stronger lock.
584
+ */
585
+ export async function lockWorkspaceInferenceControlForAdmission(
586
+ db: Database,
587
+ input: {
588
+ workspaceId: string;
589
+ sessionId: string;
590
+ resumePausedBranch: boolean;
591
+ } & WorkspaceControlLockOptions,
592
+ ): Promise<WorkspaceControlAdmissionLock> {
593
+ // One budget spans the shared attempt and any escalation to the exclusive
594
+ // prefix, so the caller's bound is the total wait, not a per-step timeout.
595
+ const lockOptions: WorkspaceControlLockOptions =
596
+ input.lockTimeoutMs === undefined
597
+ ? {}
598
+ : { budget: workspaceControlLockBudget(input.lockTimeoutMs) };
599
+ await db.execute(sql.raw(`savepoint ${WORKSPACE_CONTROL_ADMISSION_SAVEPOINT}`));
600
+ const shared = await lockWorkspaceInferenceControl(db, input.workspaceId, "share", lockOptions);
601
+ if (!input.resumePausedBranch) {
602
+ await db.execute(sql.raw(`release savepoint ${WORKSPACE_CONTROL_ADMISSION_SAVEPOINT}`));
603
+ return { control: shared, mode: "share" };
604
+ }
605
+ const effective = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
606
+ workspaceControl: shared,
607
+ });
608
+ if (effective.state === "active") {
609
+ await db.execute(sql.raw(`release savepoint ${WORKSPACE_CONTROL_ADMISSION_SAVEPOINT}`));
610
+ return { control: shared, mode: "share" };
611
+ }
612
+ await db.execute(sql.raw(`rollback to savepoint ${WORKSPACE_CONTROL_ADMISSION_SAVEPOINT}`));
613
+ await db.execute(sql.raw(`release savepoint ${WORKSPACE_CONTROL_ADMISSION_SAVEPOINT}`));
614
+ return {
615
+ control: await lockWorkspaceInferenceControl(db, input.workspaceId, "update", lockOptions),
616
+ mode: "update",
617
+ };
618
+ }
619
+
391
620
  export type SessionEventWriteLockInput = {
392
621
  workspaceId: string;
393
622
  /**
@@ -414,12 +643,19 @@ export type SessionEventWriteLocks = {
414
643
  /**
415
644
  * Establish the one canonical lock prefix for every `session_events` writer:
416
645
  *
417
- * workspace_inference_controls (when control-aware)
646
+ * workspace-control advisory lock + workspace_inference_controls row
647
+ * (when control-aware; see `lockWorkspaceInferenceControl`)
418
648
  * -> actual workspaces row FOR KEY SHARE
419
649
  * -> session rows FOR NO KEY UPDATE, UUID ordered
420
650
  * -> exact turn rows FOR UPDATE, UUID ordered
421
651
  * -> exact attempt rows FOR UPDATE, UUID ordered
422
652
  *
653
+ * Control-aware writers that do not mutate the control row take it `share`;
654
+ * only genuine control mutations (Pause/Resume/Cancel, workspace control,
655
+ * auto-resume, settings narrowing, quiescent tree deletion) take `update`.
656
+ * Prompt admission declares whether it may resume a paused branch through
657
+ * `lockWorkspaceInferenceControlForAdmission`.
658
+ *
423
659
  * `FOR KEY SHARE` is deliberate. Event inserts need the workspace key to remain
424
660
  * stable for their FK, but they do not mutate the workspace. The old generic
425
661
  * `FOR UPDATE` lock serialized unrelated sessions and inverted the activity
@@ -434,6 +670,11 @@ export type SessionEventWriteLocks = {
434
670
  * this helper and may discover exact turn IDs only after locking the session.
435
671
  * They use the explicit `already_locked` stages, while retaining the same
436
672
  * monotonic table order. New event writers should prefer one complete call.
673
+ *
674
+ * Retained-screenshot prepare is not an event writer, but its INSERT still
675
+ * KEY SHAREs the turn and attempt through FKs. It must take this same prefix
676
+ * first: PostgreSQL fires those FK checks in catalog name/OID order, which can
677
+ * be attempt-then-turn and otherwise deadlocks against this helper.
437
678
  */
438
679
  export async function lockSessionEventWriteRows(
439
680
  db: Database,
@@ -523,6 +764,7 @@ export async function registerSessionTurnAttemptClaim(
523
764
  authorityEpoch: number;
524
765
  authorityVisibility: "user_private" | "workspace_shared";
525
766
  authorityOwnerOrganizationMembershipId: string | null;
767
+ personalResourceProtocolVersion: number;
526
768
  mcpApprovalPolicies: Record<string, SessionMcpApprovalPolicy>;
527
769
  connectorActionPolicies: schema.ConnectorActionPolicySnapshotEntry[];
528
770
  },
@@ -577,6 +819,7 @@ export async function registerSessionTurnAttemptClaim(
577
819
  existing.temporalWorkflowId !== input.temporalWorkflowId ||
578
820
  existing.temporalWorkflowRunId !== input.temporalWorkflowRunId ||
579
821
  existing.temporalActivityId !== input.temporalActivityId ||
822
+ existing.personalResourceProtocolVersion !== input.personalResourceProtocolVersion ||
580
823
  JSON.stringify(existing.connectorActionPolicies) !==
581
824
  JSON.stringify(input.connectorActionPolicies) ||
582
825
  existing.state === "closed"
@@ -912,6 +1155,7 @@ function projectEffectiveControl(
912
1155
  targetId: string,
913
1156
  rows: AncestryRow[],
914
1157
  settlementAttempts: SettlementAttemptCounts,
1158
+ stoppingBackgroundCommandCount: number,
915
1159
  ): EffectiveSessionControl {
916
1160
  assertCompleteAncestry(targetId, rows);
917
1161
  const workspaceRevision = asSafeRevision(workspace.revision, "workspace control revision")!;
@@ -1045,9 +1289,49 @@ function projectEffectiveControl(
1045
1289
  },
1046
1290
  settlement:
1047
1291
  settlementAttempts.attemptCount > 0 ? { state: "stopping", ...settlementAttempts } : null,
1292
+ backgroundCommandSettlement:
1293
+ stoppingBackgroundCommandCount > 0
1294
+ ? { state: "stopping", commandCount: stoppingBackgroundCommandCount }
1295
+ : null,
1048
1296
  };
1049
1297
  }
1050
1298
 
1299
+ async function stoppingBackgroundCommandCounts(
1300
+ db: Database,
1301
+ workspaceId: string,
1302
+ sessionIds: string[],
1303
+ ): Promise<Map<string, number>> {
1304
+ const rows = await db.execute<{ sessionId: string; commandCount: number | string }>(sql`
1305
+ with recursive targets(id) as (values ${targetValues(sessionIds)}),
1306
+ command_ancestry(command_id, ancestor_id, depth, path) as (
1307
+ select command.id, command.session_id, 0::integer, array[command.session_id]::uuid[]
1308
+ from ${schema.sessionBackgroundCommands} command
1309
+ where command.workspace_id = ${workspaceId}
1310
+ and command.state = 'stopping'
1311
+ union all
1312
+ select ancestry.command_id, current.parent_session_id,
1313
+ ancestry.depth + 1, ancestry.path || current.parent_session_id
1314
+ from command_ancestry ancestry
1315
+ join ${schema.sessions} current
1316
+ on current.workspace_id = ${workspaceId}
1317
+ and current.id = ancestry.ancestor_id
1318
+ where current.parent_session_id is not null
1319
+ and not current.parent_session_id = any(ancestry.path)
1320
+ and ancestry.depth < ${SESSION_ANCESTRY_LIMIT}
1321
+ )
1322
+ select target.id as "sessionId", count(distinct ancestry.command_id)::integer as "commandCount"
1323
+ from targets target
1324
+ join command_ancestry ancestry on ancestry.ancestor_id = target.id
1325
+ group by target.id
1326
+ `);
1327
+ return new Map(
1328
+ rows.map((row: { sessionId: string; commandCount: number | string }) => [
1329
+ row.sessionId,
1330
+ Number(row.commandCount),
1331
+ ]),
1332
+ );
1333
+ }
1334
+
1051
1335
  async function settlementAttemptCounts(
1052
1336
  db: Database,
1053
1337
  workspaceId: string,
@@ -1170,6 +1454,7 @@ export async function evaluateSessionControls(
1170
1454
  options.workspaceControl ??
1171
1455
  (await lockWorkspaceInferenceControl(db, workspaceId, options.lock ?? "share"));
1172
1456
  const stopping = await settlementAttemptCounts(db, workspaceId, uniqueIds);
1457
+ const stoppingCommands = await stoppingBackgroundCommandCounts(db, workspaceId, uniqueIds);
1173
1458
  const result = new Map<string, EffectiveSessionControl>();
1174
1459
  if (uniqueIds.length <= TARGET_PATH_PROJECTION_LIMIT) {
1175
1460
  // PostgreSQL's direct target-path plan is substantially faster for the
@@ -1189,6 +1474,7 @@ export async function evaluateSessionControls(
1189
1474
  sessionId,
1190
1475
  ancestryByTarget.get(sessionId) ?? [],
1191
1476
  stopping.get(sessionId) ?? NO_SETTLEMENT_ATTEMPTS,
1477
+ stoppingCommands.get(sessionId) ?? 0,
1192
1478
  ),
1193
1479
  );
1194
1480
  }
@@ -1207,6 +1493,7 @@ export async function evaluateSessionControls(
1207
1493
  sessionId,
1208
1494
  ancestryRowsForTarget(sessionId, ancestry),
1209
1495
  stopping.get(sessionId) ?? NO_SETTLEMENT_ATTEMPTS,
1496
+ stoppingCommands.get(sessionId) ?? 0,
1210
1497
  ),
1211
1498
  );
1212
1499
  }
@@ -1500,7 +1787,7 @@ export async function reserveSessionCommandReceipt(
1500
1787
  if (
1501
1788
  identityScope === "goal_operation" &&
1502
1789
  (input.actor.type !== "agent_attempt" ||
1503
- !["goal.update", "goal.progress"].includes(input.action) ||
1790
+ !["goal.update", "goal.progress", "goal.wait"].includes(input.action) ||
1504
1791
  input.targetSessionId === null ||
1505
1792
  input.targetTurnId !== null)
1506
1793
  ) {
@@ -1940,13 +2227,57 @@ export type SessionControlMutationResult = {
1940
2227
  sessionControlEventId: string;
1941
2228
  workspaceControlEventId: string;
1942
2229
  interruptionCount: number;
2230
+ backgroundCommandCount: number;
1943
2231
  wakeCount: number;
1944
2232
  cancelledSessionCount: number;
1945
2233
  cancelledTurnCount: number;
1946
2234
  affectedSessionEvents: Array<{ sessionId: string; eventIds: string[] }>;
2235
+ workflowWake: SessionControlWorkflowWake | null;
1947
2236
  replay: boolean;
1948
2237
  };
1949
2238
 
2239
+ export type SessionControlWorkflowWake = {
2240
+ accountId: string;
2241
+ workspaceId: string;
2242
+ sessionId: string;
2243
+ temporalWorkflowId: string;
2244
+ wakeRevision: number;
2245
+ };
2246
+
2247
+ async function pendingSessionControlWorkflowWake(
2248
+ db: Database,
2249
+ workspaceId: string,
2250
+ sessionId: string,
2251
+ wakeCount: number,
2252
+ ): Promise<SessionControlWorkflowWake | null> {
2253
+ if (wakeCount === 0) return null;
2254
+ const [wake] = await db
2255
+ .select({
2256
+ accountId: schema.sessionWorkflowWakeOutbox.accountId,
2257
+ workspaceId: schema.sessionWorkflowWakeOutbox.workspaceId,
2258
+ sessionId: schema.sessionWorkflowWakeOutbox.sessionId,
2259
+ temporalWorkflowId: schema.sessionWorkflowWakeOutbox.temporalWorkflowId,
2260
+ wakeRevision: schema.sessionWorkflowWakeOutbox.wakeRevision,
2261
+ deliveredRevision: schema.sessionWorkflowWakeOutbox.deliveredRevision,
2262
+ })
2263
+ .from(schema.sessionWorkflowWakeOutbox)
2264
+ .where(
2265
+ and(
2266
+ eq(schema.sessionWorkflowWakeOutbox.workspaceId, workspaceId),
2267
+ eq(schema.sessionWorkflowWakeOutbox.sessionId, sessionId),
2268
+ ),
2269
+ )
2270
+ .limit(1);
2271
+ if (!wake || wake.wakeRevision <= wake.deliveredRevision) return null;
2272
+ return {
2273
+ accountId: wake.accountId,
2274
+ workspaceId: wake.workspaceId,
2275
+ sessionId: wake.sessionId,
2276
+ temporalWorkflowId: wake.temporalWorkflowId,
2277
+ wakeRevision: wake.wakeRevision,
2278
+ };
2279
+ }
2280
+
1950
2281
  async function loadSessionSubtreeIds(
1951
2282
  db: Database,
1952
2283
  workspaceId: string,
@@ -2013,25 +2344,55 @@ async function loadCancellationTurnIds(
2013
2344
  return rows.map((row) => row.id);
2014
2345
  }
2015
2346
 
2016
- async function enqueueCancelledChildOutboxInTransaction(
2347
+ async function loadParentSessionId(
2348
+ db: Database,
2349
+ workspaceId: string,
2350
+ sessionId: string,
2351
+ ): Promise<string | null> {
2352
+ const [row] = await db
2353
+ .select({ parentSessionId: schema.sessions.parentSessionId })
2354
+ .from(schema.sessions)
2355
+ .where(and(eq(schema.sessions.workspaceId, workspaceId), eq(schema.sessions.id, sessionId)))
2356
+ .limit(1);
2357
+ return row?.parentSessionId ?? null;
2358
+ }
2359
+
2360
+ /**
2361
+ * The one control-plane child -> parent outbox insert (terminal cancellation,
2362
+ * direct Pause, and cancelled human-input resolutions). The caller holds the
2363
+ * canonical prefix with the child and its parent locked UUID-ordered; the
2364
+ * causal parent-turn personal delegation snapshot is copied verbatim.
2365
+ */
2366
+ async function insertChildOutboxRowInTransaction(
2017
2367
  db: Database,
2018
2368
  input: {
2019
2369
  workspaceId: string;
2020
- rootSession: typeof schema.sessions.$inferSelect;
2370
+ childSession: typeof schema.sessions.$inferSelect;
2021
2371
  parentSession: typeof schema.sessions.$inferSelect | null;
2372
+ dedupeKey: string;
2373
+ kind: "child_terminal_result" | "child_paused" | "child_requires_action_resolved";
2374
+ classification: "info" | "action_required";
2375
+ summary: string;
2376
+ payload: Record<string, unknown> & { type: string };
2377
+ lineage?: Record<string, unknown>;
2022
2378
  },
2023
2379
  ): Promise<void> {
2024
- const parentSessionId = input.rootSession.parentSessionId;
2380
+ const parentSessionId = input.childSession.parentSessionId;
2025
2381
  if (!parentSessionId) return;
2026
2382
  if (!input.parentSession || input.parentSession.id !== parentSessionId) {
2027
2383
  throw new SessionControlInvariantError(
2028
- `Parent session ${parentSessionId} was not locked with cancellation root ${input.rootSession.id}`,
2384
+ `Parent session ${parentSessionId} was not locked with child ${input.childSession.id}`,
2029
2385
  );
2030
2386
  }
2031
2387
  if (input.parentSession.status === "cancelled") return;
2388
+ if (input.payload.type !== input.kind) {
2389
+ throw new SessionControlInvariantError(
2390
+ `Child outbox ${input.kind} payload discriminator mismatch`,
2391
+ );
2392
+ }
2032
2393
  let personalConnectionDelegations: (typeof schema.sessionTurns.$inferSelect)["personalConnectionDelegations"] =
2033
2394
  [];
2034
- if (input.rootSession.parentTurnId) {
2395
+ if (input.childSession.parentTurnId) {
2035
2396
  const [parentTurn] = await db
2036
2397
  .select({
2037
2398
  delegations: schema.sessionTurns.personalConnectionDelegations,
@@ -2041,7 +2402,7 @@ async function enqueueCancelledChildOutboxInTransaction(
2041
2402
  and(
2042
2403
  eq(schema.sessionTurns.workspaceId, input.workspaceId),
2043
2404
  eq(schema.sessionTurns.sessionId, parentSessionId),
2044
- eq(schema.sessionTurns.id, input.rootSession.parentTurnId),
2405
+ eq(schema.sessionTurns.id, input.childSession.parentTurnId),
2045
2406
  ),
2046
2407
  )
2047
2408
  .limit(1);
@@ -2049,7 +2410,7 @@ async function enqueueCancelledChildOutboxInTransaction(
2049
2410
  const parsed = McpPersonalConnectionDelegations.safeParse(parentTurn.delegations);
2050
2411
  if (!parsed.success) {
2051
2412
  throw new SessionControlInvariantError(
2052
- `Invalid personal MCP delegation snapshot at session_turns:${input.workspaceId}:${parentSessionId}:${input.rootSession.parentTurnId}`,
2413
+ `Invalid personal MCP delegation snapshot at session_turns:${input.workspaceId}:${parentSessionId}:${input.childSession.parentTurnId}`,
2053
2414
  );
2054
2415
  }
2055
2416
  personalConnectionDelegations = parsed.data.map((delegation) => ({
@@ -2063,26 +2424,23 @@ async function enqueueCancelledChildOutboxInTransaction(
2063
2424
  withLosslessContentWriteVersion(
2064
2425
  withLosslessContentWriteVersion(
2065
2426
  {
2066
- accountId: input.rootSession.accountId,
2427
+ accountId: input.childSession.accountId,
2067
2428
  workspaceId: input.workspaceId,
2068
- sourceSessionId: input.rootSession.id,
2429
+ sourceSessionId: input.childSession.id,
2069
2430
  targetSessionId: parentSessionId,
2070
- dedupeKey: `child-completion:${input.rootSession.id}:cancelled`,
2071
- kind: "child_terminal_result",
2072
- classification: "info",
2073
- sourceId: input.rootSession.id,
2074
- summary: "Child session was terminally cancelled.",
2075
- payload: {
2076
- type: "child_terminal_result",
2077
- childSessionId: input.rootSession.id,
2078
- status: "cancelled",
2079
- },
2431
+ dedupeKey: input.dedupeKey,
2432
+ kind: input.kind,
2433
+ classification: input.classification,
2434
+ sourceId: input.childSession.id,
2435
+ summary: input.summary,
2436
+ payload: input.payload,
2080
2437
  lineage: {
2081
- childSessionId: input.rootSession.id,
2438
+ childSessionId: input.childSession.id,
2082
2439
  parentSessionId,
2083
- ...(input.rootSession.parentTurnId
2084
- ? { parentTurnId: input.rootSession.parentTurnId }
2440
+ ...(input.childSession.parentTurnId
2441
+ ? { parentTurnId: input.childSession.parentTurnId }
2085
2442
  : {}),
2443
+ ...(input.lineage ?? {}),
2086
2444
  },
2087
2445
  personalConnectionDelegations,
2088
2446
  },
@@ -2101,6 +2459,118 @@ async function enqueueCancelledChildOutboxInTransaction(
2101
2459
  });
2102
2460
  }
2103
2461
 
2462
+ async function enqueueCancelledChildOutboxInTransaction(
2463
+ db: Database,
2464
+ input: {
2465
+ workspaceId: string;
2466
+ rootSession: typeof schema.sessions.$inferSelect;
2467
+ parentSession: typeof schema.sessions.$inferSelect | null;
2468
+ },
2469
+ ): Promise<void> {
2470
+ await insertChildOutboxRowInTransaction(db, {
2471
+ workspaceId: input.workspaceId,
2472
+ childSession: input.rootSession,
2473
+ parentSession: input.parentSession,
2474
+ dedupeKey: `child-completion:${input.rootSession.id}:cancelled`,
2475
+ kind: "child_terminal_result",
2476
+ classification: "info",
2477
+ summary: "Child session was terminally cancelled.",
2478
+ payload: {
2479
+ type: "child_terminal_result",
2480
+ childSessionId: input.rootSession.id,
2481
+ status: "cancelled",
2482
+ },
2483
+ });
2484
+ }
2485
+
2486
+ /** child_paused notice for a direct Pause of a child; gated by the rollout flag. */
2487
+ async function enqueueChildPausedOutboxInTransaction(
2488
+ db: Database,
2489
+ input: {
2490
+ workspaceId: string;
2491
+ childSession: typeof schema.sessions.$inferSelect;
2492
+ parentSession: typeof schema.sessions.$inferSelect;
2493
+ operationId: string;
2494
+ actorKind: "human" | "api" | "agent";
2495
+ reason: string | null;
2496
+ },
2497
+ ): Promise<void> {
2498
+ if (!childLifecycleNoticesEnabled()) return;
2499
+ const payload = {
2500
+ type: "child_paused" as const,
2501
+ childSessionId: input.childSession.id,
2502
+ operationId: input.operationId,
2503
+ actorKind: input.actorKind,
2504
+ reason: boundedChildPausedReason(input.reason),
2505
+ };
2506
+ await insertChildOutboxRowInTransaction(db, {
2507
+ workspaceId: input.workspaceId,
2508
+ childSession: input.childSession,
2509
+ parentSession: input.parentSession,
2510
+ dedupeKey: childPausedDedupeKey({
2511
+ childSessionId: input.childSession.id,
2512
+ receiptId: input.operationId,
2513
+ }),
2514
+ kind: "child_paused",
2515
+ classification: childPausedClassification(input.actorKind),
2516
+ summary: childPausedSummary(input.childSession.id, payload),
2517
+ payload,
2518
+ });
2519
+ }
2520
+
2521
+ /**
2522
+ * Terminal cancellation of the selected root's pending human-input rows: each
2523
+ * becomes one child_requires_action_resolved notice for the surviving parent
2524
+ * so its pending child_requires_action can be superseded. Cancelled
2525
+ * descendants do not notify parents inside the same terminal subtree.
2526
+ */
2527
+ async function enqueueCancelledChildRequiresActionResolvedOutboxInTransaction(
2528
+ db: Database,
2529
+ input: {
2530
+ workspaceId: string;
2531
+ rootSession: typeof schema.sessions.$inferSelect;
2532
+ parentSession: typeof schema.sessions.$inferSelect | null;
2533
+ cancelledHumanInputs: ReadonlyArray<{
2534
+ id: string;
2535
+ sessionId: string;
2536
+ turnId: string;
2537
+ turnGeneration: number;
2538
+ }>;
2539
+ },
2540
+ ): Promise<void> {
2541
+ if (!childLifecycleNoticesEnabled() || !input.parentSession) return;
2542
+ for (const humanInput of input.cancelledHumanInputs) {
2543
+ if (humanInput.sessionId !== input.rootSession.id) continue;
2544
+ const payload = {
2545
+ type: "child_requires_action_resolved" as const,
2546
+ childSessionId: input.rootSession.id,
2547
+ childTurnId: humanInput.turnId,
2548
+ childTurnGeneration: humanInput.turnGeneration,
2549
+ requestId: humanInput.id,
2550
+ approvalId: null,
2551
+ outcome: "cancelled" as const,
2552
+ respondedByKind: "system" as const,
2553
+ };
2554
+ await insertChildOutboxRowInTransaction(db, {
2555
+ workspaceId: input.workspaceId,
2556
+ childSession: input.rootSession,
2557
+ parentSession: input.parentSession,
2558
+ dedupeKey: childRequiresActionResolvedDedupeKey({
2559
+ childSessionId: input.rootSession.id,
2560
+ turnId: humanInput.turnId,
2561
+ turnGeneration: humanInput.turnGeneration,
2562
+ requestId: humanInput.id,
2563
+ approvalId: null,
2564
+ }),
2565
+ kind: "child_requires_action_resolved",
2566
+ classification: "info",
2567
+ summary: childRequiresActionResolvedSummary(input.rootSession.id, payload),
2568
+ payload,
2569
+ lineage: { turnId: humanInput.turnId },
2570
+ });
2571
+ }
2572
+ }
2573
+
2104
2574
  async function assertSessionBranchIsNotCancelled(
2105
2575
  db: Database,
2106
2576
  workspaceId: string,
@@ -2212,6 +2682,7 @@ async function cancelSessionSubtreeInTransaction(
2212
2682
  id: string;
2213
2683
  sessionId: string;
2214
2684
  turnId: string;
2685
+ turnGeneration: number;
2215
2686
  questions: (typeof schema.sessionHumanInputRequests.$inferSelect)["questions"];
2216
2687
  }> = [];
2217
2688
  if (immediatelyCancelledTurnIds.length > 0) {
@@ -2247,6 +2718,7 @@ async function cancelSessionSubtreeInTransaction(
2247
2718
  id: schema.sessionHumanInputRequests.id,
2248
2719
  sessionId: schema.sessionHumanInputRequests.sessionId,
2249
2720
  turnId: schema.sessionHumanInputRequests.turnId,
2721
+ turnGeneration: schema.sessionHumanInputRequests.turnGeneration,
2250
2722
  questions: schema.sessionHumanInputRequests.questions,
2251
2723
  });
2252
2724
  await db
@@ -2450,6 +2922,12 @@ async function cancelSessionSubtreeInTransaction(
2450
2922
  if (session.id === input.rootSessionId) eventIds.unshift(input.rootControlEventId);
2451
2923
  if (eventIds.length > 0) affectedSessionEvents.push({ sessionId: session.id, eventIds });
2452
2924
  }
2925
+ await enqueueCancelledChildRequiresActionResolvedOutboxInTransaction(db, {
2926
+ workspaceId: input.workspaceId,
2927
+ rootSession,
2928
+ parentSession: rootParentSession,
2929
+ cancelledHumanInputs,
2930
+ });
2453
2931
  await enqueueCancelledChildOutboxInTransaction(db, {
2454
2932
  workspaceId: input.workspaceId,
2455
2933
  rootSession,
@@ -2473,9 +2951,15 @@ export async function mutateSessionControlInTransaction(
2473
2951
  action: "pause" | "resume" | "cancel";
2474
2952
  reason?: string | null;
2475
2953
  expectedControlEtag?: string | null;
2954
+ /** Request-scoped callers bound the control prefix wait; lifecycle callers omit it. */
2955
+ controlLockTimeoutMs?: number;
2476
2956
  },
2477
2957
  ): Promise<SessionControlMutationResult> {
2478
- const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
2958
+ const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update", {
2959
+ ...(input.controlLockTimeoutMs !== undefined
2960
+ ? { lockTimeoutMs: input.controlLockTimeoutMs }
2961
+ : {}),
2962
+ });
2479
2963
  const cancellationSubtree =
2480
2964
  input.action === "cancel"
2481
2965
  ? await loadSessionSubtreeIds(db, input.workspaceId, input.sessionId)
@@ -2485,6 +2969,14 @@ export async function mutateSessionControlInTransaction(
2485
2969
  input.action === "cancel"
2486
2970
  ? await loadCancellationTurnIds(db, input.workspaceId, cancellationSessionIds)
2487
2971
  : [];
2972
+ // A direct Pause of a child notifies its parent (child_paused) through the
2973
+ // outbox in this same commit, so the parent joins the UUID-ordered session
2974
+ // lock exactly like Cancel locks the root's surviving parent.
2975
+ const pauseParentSessionId =
2976
+ input.action === "pause" && childLifecycleNoticesEnabled()
2977
+ ? await loadParentSessionId(db, input.workspaceId, input.sessionId)
2978
+ : null;
2979
+ const lineageParentSessionId = cancellationSubtree.rootParentSessionId ?? pauseParentSessionId;
2488
2980
  const locks = await lockSessionEventWriteRows(db, {
2489
2981
  workspaceId: input.workspaceId,
2490
2982
  controlLock: "already_locked",
@@ -2493,27 +2985,20 @@ export async function mutateSessionControlInTransaction(
2493
2985
  ? [
2494
2986
  input.actor.sessionId,
2495
2987
  ...cancellationSessionIds,
2496
- ...(cancellationSubtree.rootParentSessionId
2497
- ? [cancellationSubtree.rootParentSessionId]
2498
- : []),
2988
+ ...(lineageParentSessionId ? [lineageParentSessionId] : []),
2499
2989
  ]
2500
- : [
2501
- ...cancellationSessionIds,
2502
- ...(cancellationSubtree.rootParentSessionId
2503
- ? [cancellationSubtree.rootParentSessionId]
2504
- : []),
2505
- ],
2990
+ : [...cancellationSessionIds, ...(lineageParentSessionId ? [lineageParentSessionId] : [])],
2506
2991
  turnIds:
2507
2992
  input.actor.type === "agent_attempt"
2508
2993
  ? [input.actor.turnId, ...cancellationTurnIds]
2509
2994
  : cancellationTurnIds,
2510
2995
  attemptIds: input.actor.type === "agent_attempt" ? [input.actor.attemptId] : [],
2511
2996
  });
2512
- if (input.action === "cancel") {
2997
+ if (input.action === "cancel" || pauseParentSessionId !== null) {
2513
2998
  const rootSession = locks.sessions.find((session) => session.id === input.sessionId);
2514
- if (!rootSession || rootSession.parentSessionId !== cancellationSubtree.rootParentSessionId) {
2999
+ if (!rootSession || rootSession.parentSessionId !== lineageParentSessionId) {
2515
3000
  throw new SessionControlInvariantError(
2516
- `Session ${input.sessionId} parent changed while establishing cancellation locks`,
3001
+ `Session ${input.sessionId} parent changed while establishing ${input.action} locks`,
2517
3002
  );
2518
3003
  }
2519
3004
  }
@@ -2543,6 +3028,7 @@ export async function mutateSessionControlInTransaction(
2543
3028
  "Replayed session control receipt has no session event",
2544
3029
  );
2545
3030
  }
3031
+ const wakeCount = Number(reserved.receipt.result.wakeCount ?? 0);
2546
3032
  return {
2547
3033
  receipt: reserved.receipt,
2548
3034
  control: await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
@@ -2551,7 +3037,8 @@ export async function mutateSessionControlInTransaction(
2551
3037
  sessionControlEventId,
2552
3038
  workspaceControlEventId,
2553
3039
  interruptionCount: Number(reserved.receipt.result.interruptionCount ?? 0),
2554
- wakeCount: Number(reserved.receipt.result.wakeCount ?? 0),
3040
+ backgroundCommandCount: Number(reserved.receipt.result.backgroundCommandCount ?? 0),
3041
+ wakeCount,
2555
3042
  cancelledSessionCount: Number(reserved.receipt.result.cancelledSessionCount ?? 0),
2556
3043
  cancelledTurnCount: Number(reserved.receipt.result.cancelledTurnCount ?? 0),
2557
3044
  affectedSessionEvents: Array.isArray(reserved.receipt.result.affectedSessionEvents)
@@ -2560,6 +3047,12 @@ export async function mutateSessionControlInTransaction(
2560
3047
  eventIds: string[];
2561
3048
  }>)
2562
3049
  : [{ sessionId: input.sessionId, eventIds: [sessionControlEventId] }],
3050
+ workflowWake: await pendingSessionControlWorkflowWake(
3051
+ db,
3052
+ input.workspaceId,
3053
+ input.sessionId,
3054
+ wakeCount,
3055
+ ),
2563
3056
  replay: true,
2564
3057
  };
2565
3058
  }
@@ -2632,6 +3125,14 @@ export async function mutateSessionControlInTransaction(
2632
3125
  input.actor.type === "agent_attempt"
2633
3126
  ? `attempt:${input.actor.attemptId}`
2634
3127
  : input.actor.subjectId;
3128
+ const backgroundCommandCount =
3129
+ input.action === "pause" || input.action === "cancel"
3130
+ ? await requestSessionBackgroundCommandCancellationsInTransaction(db, {
3131
+ workspaceId: input.workspaceId,
3132
+ rootSessionIds: [input.sessionId],
3133
+ subjectId: actor,
3134
+ })
3135
+ : 0;
2635
3136
  const workspaceControlEventId = await insertWorkspaceControlEventInTransaction(db, {
2636
3137
  accountId: input.accountId,
2637
3138
  workspaceId: input.workspaceId,
@@ -2752,10 +3253,36 @@ export async function mutateSessionControlInTransaction(
2752
3253
  cancelledTurnCount: 0,
2753
3254
  affectedSessionEvents: [{ sessionId: input.sessionId, eventIds: [controlEvent.id] }],
2754
3255
  };
3256
+ if (input.action === "pause" && pauseParentSessionId !== null) {
3257
+ // Direct Pause only (a recursive ancestor pause never reaches this command
3258
+ // for the child), and never when the parent's own live attempt issued it:
3259
+ // that parent already knows. Human/API pauses are action-required for the
3260
+ // parent; an agent pause is informational.
3261
+ const pausedSession = locks.sessions.find((session) => session.id === input.sessionId);
3262
+ const parentSession = locks.sessions.find((session) => session.id === pauseParentSessionId);
3263
+ const issuedByParentAttempt =
3264
+ input.actor.type === "agent_attempt" && input.actor.sessionId === pauseParentSessionId;
3265
+ if (pausedSession && parentSession && !issuedByParentAttempt) {
3266
+ await enqueueChildPausedOutboxInTransaction(db, {
3267
+ workspaceId: input.workspaceId,
3268
+ childSession: pausedSession,
3269
+ parentSession,
3270
+ operationId: reserved.receipt.id,
3271
+ actorKind:
3272
+ input.actor.type === "agent_attempt"
3273
+ ? "agent"
3274
+ : input.actor.type === "service"
3275
+ ? "api"
3276
+ : "human",
3277
+ reason: input.reason ?? null,
3278
+ });
3279
+ }
3280
+ }
2755
3281
  const receipt = await updateSessionCommandReceiptResult(db, reserved.receipt.id, {
2756
3282
  controlRevision: revision,
2757
3283
  result: {
2758
3284
  interruptionCount,
3285
+ backgroundCommandCount,
2759
3286
  wakeCount,
2760
3287
  eventId: controlEvent.id,
2761
3288
  workspaceControlEventId,
@@ -2773,10 +3300,17 @@ export async function mutateSessionControlInTransaction(
2773
3300
  sessionControlEventId: controlEvent.id,
2774
3301
  workspaceControlEventId,
2775
3302
  interruptionCount,
3303
+ backgroundCommandCount,
2776
3304
  wakeCount,
2777
3305
  cancelledSessionCount: cancellation.cancelledSessionCount,
2778
3306
  cancelledTurnCount: cancellation.cancelledTurnCount,
2779
3307
  affectedSessionEvents: cancellation.affectedSessionEvents,
3308
+ workflowWake: await pendingSessionControlWorkflowWake(
3309
+ db,
3310
+ input.workspaceId,
3311
+ input.sessionId,
3312
+ wakeCount,
3313
+ ),
2780
3314
  replay: false,
2781
3315
  };
2782
3316
  }
@@ -2787,8 +3321,15 @@ export async function autoResumeSessionBranchInTransaction(
2787
3321
  workspaceId: string;
2788
3322
  sessionId: string;
2789
3323
  actor: string;
2790
- reason: "human_send" | "human_steer" | "service_send" | "service_steer" | "agent_steer";
3324
+ reason: "human_steer" | "service_steer" | "agent_steer";
2791
3325
  observedControlEtag?: string | null;
3326
+ /**
3327
+ * The admission prefix the caller already holds. A `share` admission
3328
+ * proves the branch was active under a lock that excludes every control
3329
+ * mutation, so it never needs to resume; only an `update` admission may
3330
+ * advance the workspace revision here.
3331
+ */
3332
+ admission?: WorkspaceControlAdmissionLock;
2792
3333
  },
2793
3334
  ): Promise<{
2794
3335
  revision: number;
@@ -2796,10 +3337,12 @@ export async function autoResumeSessionBranchInTransaction(
2796
3337
  changed: boolean;
2797
3338
  workspaceControlEventId: string | null;
2798
3339
  }> {
2799
- const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
3340
+ const workspace =
3341
+ input.admission?.control ??
3342
+ (await lockWorkspaceInferenceControl(db, input.workspaceId, "update"));
2800
3343
  await assertSessionBranchIsNotCancelled(db, input.workspaceId, input.sessionId);
2801
3344
  const before = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
2802
- lock: "share",
3345
+ workspaceControl: workspace,
2803
3346
  });
2804
3347
  if (input.observedControlEtag && input.observedControlEtag !== before.controlEtag) {
2805
3348
  throw new SessionControlConflictError();
@@ -2812,6 +3355,13 @@ export async function autoResumeSessionBranchInTransaction(
2812
3355
  workspaceControlEventId: null,
2813
3356
  };
2814
3357
  }
3358
+ if (input.admission?.mode === "share") {
3359
+ // Impossible by construction: the shared prefix observed an active branch
3360
+ // and excludes every control mutation until this transaction ends.
3361
+ throw new SessionControlInvariantError(
3362
+ `Session ${input.sessionId} became paused under a shared workspace control lock`,
3363
+ );
3364
+ }
2815
3365
  const revision = nextRevision(workspace);
2816
3366
  await advanceWorkspaceRevision(db, input.workspaceId, revision);
2817
3367
  await db
@@ -2859,6 +3409,7 @@ export type WorkspaceControlMutationResult = {
2859
3409
  workspaceControlEventId: string;
2860
3410
  workspaceState: EffectiveControlState;
2861
3411
  interruptionCount: number;
3412
+ backgroundCommandCount: number;
2862
3413
  wakeCount: number;
2863
3414
  replay: boolean;
2864
3415
  };
@@ -2873,9 +3424,15 @@ export async function mutateWorkspaceControlInTransaction(
2873
3424
  action: "pause" | "resume";
2874
3425
  reason?: string | null;
2875
3426
  expectedRevision?: number | null;
3427
+ /** Request-scoped callers bound the control prefix wait; lifecycle callers omit it. */
3428
+ controlLockTimeoutMs?: number;
2876
3429
  },
2877
3430
  ): Promise<WorkspaceControlMutationResult> {
2878
- const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
3431
+ const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update", {
3432
+ ...(input.controlLockTimeoutMs !== undefined
3433
+ ? { lockTimeoutMs: input.controlLockTimeoutMs }
3434
+ : {}),
3435
+ });
2879
3436
  const currentRevision = asSafeRevision(workspace.revision, "workspace control revision")!;
2880
3437
  const hash = canonicalSessionCommandHash({
2881
3438
  action: input.action,
@@ -2903,6 +3460,7 @@ export async function mutateWorkspaceControlInTransaction(
2903
3460
  workspaceControlEventId,
2904
3461
  workspaceState: input.action === "pause" ? "paused" : "active",
2905
3462
  interruptionCount: Number(reserved.receipt.result.interruptionCount ?? 0),
3463
+ backgroundCommandCount: Number(reserved.receipt.result.backgroundCommandCount ?? 0),
2906
3464
  wakeCount: Number(reserved.receipt.result.wakeCount ?? 0),
2907
3465
  replay: true,
2908
3466
  };
@@ -2916,6 +3474,13 @@ export async function mutateWorkspaceControlInTransaction(
2916
3474
  input.actor.type === "agent_attempt"
2917
3475
  ? `attempt:${input.actor.attemptId}`
2918
3476
  : input.actor.subjectId;
3477
+ const backgroundCommandCount =
3478
+ input.action === "pause"
3479
+ ? await requestWorkspaceBackgroundCommandCancellationsInTransaction(db, {
3480
+ workspaceId: input.workspaceId,
3481
+ subjectId: actor,
3482
+ })
3483
+ : 0;
2919
3484
  const [updated] = await db
2920
3485
  .update(schema.workspaceInferenceControls)
2921
3486
  .set({
@@ -2968,7 +3533,7 @@ export async function mutateWorkspaceControlInTransaction(
2968
3533
  });
2969
3534
  const receipt = await updateSessionCommandReceiptResult(db, reserved.receipt.id, {
2970
3535
  controlRevision: revision,
2971
- result: { interruptionCount, wakeCount, workspaceControlEventId },
3536
+ result: { interruptionCount, backgroundCommandCount, wakeCount, workspaceControlEventId },
2972
3537
  });
2973
3538
  return {
2974
3539
  receipt,
@@ -2976,6 +3541,7 @@ export async function mutateWorkspaceControlInTransaction(
2976
3541
  workspaceControlEventId,
2977
3542
  workspaceState: input.action === "pause" ? "paused" : "active",
2978
3543
  interruptionCount,
3544
+ backgroundCommandCount,
2979
3545
  wakeCount,
2980
3546
  replay: false,
2981
3547
  };