@opengeni/db 0.27.7 → 0.27.8

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.
package/dist/schema.d.ts CHANGED
@@ -15342,6 +15342,23 @@ export declare const sessionWorkflowWakeOutbox: import("drizzle-orm/pg-core").Pg
15342
15342
  identity: undefined;
15343
15343
  generated: undefined;
15344
15344
  }, {}, {}>;
15345
+ controlRevision: import("drizzle-orm/pg-core").PgColumn<{
15346
+ name: "control_revision";
15347
+ tableName: "session_workflow_wake_outbox";
15348
+ dataType: "number";
15349
+ columnType: "PgBigInt53";
15350
+ data: number;
15351
+ driverParam: string | number;
15352
+ notNull: true;
15353
+ hasDefault: true;
15354
+ isPrimaryKey: false;
15355
+ isAutoincrement: false;
15356
+ hasRuntimeDefault: false;
15357
+ enumValues: undefined;
15358
+ baseColumn: never;
15359
+ identity: undefined;
15360
+ generated: undefined;
15361
+ }, {}, {}>;
15345
15362
  reason: import("drizzle-orm/pg-core").PgColumn<{
15346
15363
  name: "reason";
15347
15364
  tableName: "session_workflow_wake_outbox";
package/dist/schema.js CHANGED
@@ -139,7 +139,7 @@ import {
139
139
  workspaceVariableSetVariables,
140
140
  workspaceVariableSets,
141
141
  workspaces
142
- } from "./chunk-EX7CDSGH.js";
142
+ } from "./chunk-M6EOXYHK.js";
143
143
  import "./chunk-PZ5AY32C.js";
144
144
  export {
145
145
  agentRunStates,
@@ -259,6 +259,7 @@ export declare function registerSessionWorkflowWakeInTransaction(db: Database, i
259
259
  sessionId: string;
260
260
  temporalWorkflowId: string;
261
261
  reason: string;
262
+ controlRequested?: boolean;
262
263
  }): Promise<number>;
263
264
  /**
264
265
  * Internal producers share one outstanding session-level receipt. While a wake
@@ -0,0 +1,133 @@
1
+ -- deployment-mode: rolling
2
+ -- Preserve control-priority Temporal signals across coalesced session wakes.
3
+
4
+ SET lock_timeout = '5s';
5
+ SET statement_timeout = '10min';
6
+
7
+ ALTER TABLE "session_workflow_wake_outbox"
8
+ ADD COLUMN "control_revision" bigint NOT NULL DEFAULT 0;
9
+
10
+ ALTER TABLE "session_workflow_wake_outbox"
11
+ ADD CONSTRAINT "session_workflow_wake_outbox_control_revision_check"
12
+ CHECK (
13
+ "control_revision" >= 0
14
+ AND "control_revision" <= "wake_revision"
15
+ AND "control_revision" <= 9007199254740991
16
+ ) NOT VALID;
17
+
18
+ -- Mixed-version writers do not know the new column. Stamp recognized control
19
+ -- reasons in the database so an old Steer followed by an old Send still leaves
20
+ -- the earlier control revision outstanding until delivery.
21
+ CREATE OR REPLACE FUNCTION opengeni_private.stamp_session_workflow_control_revision()
22
+ RETURNS trigger
23
+ LANGUAGE plpgsql
24
+ SET search_path = pg_catalog
25
+ AS $function$
26
+ BEGIN
27
+ IF NEW.reason IN (
28
+ 'prompt_steer',
29
+ 'queue_steer',
30
+ 'agent_steer',
31
+ 'session_cancelled',
32
+ 'session_pause_interruption',
33
+ 'workspace_pause_interruption',
34
+ 'attempt_interruption_settled',
35
+ 'attempt_interruption_rejected_stale'
36
+ ) AND NEW.wake_revision > NEW.delivered_revision THEN
37
+ NEW.control_revision := greatest(NEW.control_revision, NEW.wake_revision);
38
+ END IF;
39
+ RETURN NEW;
40
+ END
41
+ $function$;
42
+
43
+ DROP TRIGGER IF EXISTS session_workflow_wake_control_revision
44
+ ON "session_workflow_wake_outbox";
45
+ CREATE TRIGGER session_workflow_wake_control_revision
46
+ BEFORE INSERT OR UPDATE OF reason, wake_revision, control_revision
47
+ ON "session_workflow_wake_outbox"
48
+ FOR EACH ROW
49
+ EXECUTE FUNCTION opengeni_private.stamp_session_workflow_control_revision();
50
+
51
+ -- Preserve already-committed ownerless Steer/cancellation wakes during rolling
52
+ -- activation. Active-attempt controls remain independently discoverable from
53
+ -- the interruption ledger below.
54
+ UPDATE "session_workflow_wake_outbox"
55
+ SET "control_revision" = "wake_revision"
56
+ WHERE "delivered_revision" < "wake_revision"
57
+ AND "reason" IN (
58
+ 'prompt_steer',
59
+ 'queue_steer',
60
+ 'agent_steer',
61
+ 'session_cancelled',
62
+ 'session_pause_interruption',
63
+ 'workspace_pause_interruption'
64
+ );
65
+
66
+ ALTER TABLE "session_workflow_wake_outbox"
67
+ VALIDATE CONSTRAINT "session_workflow_wake_outbox_control_revision_check";
68
+
69
+ DROP FUNCTION opengeni_private.claim_session_workflow_wakes(integer);
70
+ DO $migration$
71
+ DECLARE target_schema text := current_schema();
72
+ BEGIN
73
+ EXECUTE format($create$
74
+ CREATE FUNCTION opengeni_private.claim_session_workflow_wakes(p_limit integer)
75
+ RETURNS TABLE (
76
+ account_id uuid,
77
+ workspace_id uuid,
78
+ session_id uuid,
79
+ temporal_workflow_id text,
80
+ wake_revision bigint,
81
+ interruption_requested boolean
82
+ )
83
+ LANGUAGE plpgsql
84
+ SECURITY DEFINER
85
+ SET search_path = pg_catalog
86
+ AS $function$
87
+ BEGIN
88
+ RETURN QUERY
89
+ WITH due AS (
90
+ SELECT o.session_id
91
+ FROM %1$I.session_workflow_wake_outbox o
92
+ WHERE o.wake_revision > o.delivered_revision
93
+ AND o.next_attempt_at <= now()
94
+ ORDER BY o.next_attempt_at, o.updated_at, o.session_id
95
+ FOR UPDATE SKIP LOCKED
96
+ LIMIT greatest(1, least(coalesce(p_limit, 100), 1000))
97
+ )
98
+ UPDATE %1$I.session_workflow_wake_outbox o
99
+ SET attempts = o.attempts + 1,
100
+ next_attempt_at = now() + make_interval(
101
+ secs => least(300, greatest(1, power(2, least(o.attempts, 8))::integer))
102
+ ),
103
+ updated_at = now()
104
+ FROM due
105
+ WHERE o.session_id = due.session_id
106
+ RETURNING o.account_id, o.workspace_id, o.session_id,
107
+ o.temporal_workflow_id, o.wake_revision,
108
+ o.control_revision > o.delivered_revision
109
+ OR EXISTS (
110
+ SELECT 1
111
+ FROM %1$I.session_attempt_interruptions interruption
112
+ WHERE interruption.workspace_id = o.workspace_id
113
+ AND interruption.session_id = o.session_id
114
+ AND interruption.state IN (
115
+ 'pending', 'delivered', 'acknowledged', 'settled', 'rejected_stale'
116
+ )
117
+ ) AS interruption_requested;
118
+ END $function$;
119
+ $create$, target_schema);
120
+ END $migration$;
121
+
122
+ REVOKE ALL ON FUNCTION opengeni_private.claim_session_workflow_wakes(integer) FROM PUBLIC;
123
+
124
+ DO $$
125
+ BEGIN
126
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
127
+ GRANT EXECUTE ON FUNCTION opengeni_private.claim_session_workflow_wakes(integer)
128
+ TO opengeni_app;
129
+ END IF;
130
+ END $$;
131
+
132
+ RESET statement_timeout;
133
+ RESET lock_timeout;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "0.27.7",
3
+ "version": "0.27.8",
4
4
  "description": "OpenGeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -37554,6 +37554,7 @@ export async function claimSessionWorkForAttempt(
37554
37554
  "delivered",
37555
37555
  "acknowledged",
37556
37556
  "settled",
37557
+ "rejected_stale",
37557
37558
  ]),
37558
37559
  isNull(schema.sessionTurnAttempts.quiescedAt),
37559
37560
  ),
@@ -38481,28 +38482,6 @@ export async function claimSessionWorkForAttempt(
38481
38482
  turn: mapSessionTurnForExecution(internalTurn),
38482
38483
  };
38483
38484
  }
38484
- const predecessorAttemptId = queuedSteerReplacementAttemptId(queuedTurn.metadata);
38485
- if (predecessorAttemptId) {
38486
- const [predecessor] = await tx
38487
- .select({ quiescedAt: schema.sessionTurnAttempts.quiescedAt })
38488
- .from(schema.sessionTurnAttempts)
38489
- .where(
38490
- and(
38491
- eq(schema.sessionTurnAttempts.workspaceId, workspaceId),
38492
- eq(schema.sessionTurnAttempts.sessionId, sessionId),
38493
- eq(schema.sessionTurnAttempts.id, predecessorAttemptId),
38494
- ),
38495
- )
38496
- .limit(1);
38497
- if (!predecessor) {
38498
- throw new SessionControlInvariantError(
38499
- `Queued Steer ${id} points to missing predecessor attempt ${predecessorAttemptId}`,
38500
- );
38501
- }
38502
- if (!predecessor.quiescedAt) {
38503
- return { action: "unclaimed", reason: "control-pending" };
38504
- }
38505
- }
38506
38485
  // The database guard makes this function the only supported
38507
38486
  // queued-to-running transition. Raw or stale claimers cannot bypass the
38508
38487
  // generation/active-pointer transaction.
@@ -39136,6 +39115,14 @@ export async function settleSessionAttemptInterruptions(
39136
39115
  interruptions.map((interruption) => interruption.id),
39137
39116
  ),
39138
39117
  );
39118
+ await enqueueSessionWorkflowWakeInTransaction(tx as unknown as Database, {
39119
+ accountId: session.accountId,
39120
+ workspaceId,
39121
+ sessionId,
39122
+ temporalWorkflowId: session.temporalWorkflowId ?? `session-${sessionId}`,
39123
+ reason: "attempt_interruption_rejected_stale",
39124
+ controlRequested: true,
39125
+ });
39139
39126
  return {
39140
39127
  action: effectiveControl.state === "paused" ? "paused" : "continue",
39141
39128
  events: [],
@@ -39327,6 +39314,14 @@ export async function settleSessionAttemptInterruptions(
39327
39314
  interruptions.map((interruption) => interruption.id),
39328
39315
  ),
39329
39316
  );
39317
+ await enqueueSessionWorkflowWakeInTransaction(tx as unknown as Database, {
39318
+ accountId: session.accountId,
39319
+ workspaceId,
39320
+ sessionId,
39321
+ temporalWorkflowId: session.temporalWorkflowId ?? `session-${sessionId}`,
39322
+ reason: "attempt_interruption_settled",
39323
+ controlRequested: true,
39324
+ });
39330
39325
  return {
39331
39326
  action: effectiveControl.state === "paused" ? "paused" : "continue",
39332
39327
  events: [...closedTools.events, ...eventRows.map(mapEvent)],
@@ -39355,6 +39350,50 @@ export type SessionWorkPeek =
39355
39350
  | { kind: "cancellation-wait"; attemptId: string }
39356
39351
  | { kind: "idle" };
39357
39352
 
39353
+ /**
39354
+ * Return the oldest exact attempt whose logical interruption settled but whose
39355
+ * physical quiescence receipt is still missing. This deliberately searches all
39356
+ * attempts: a provider-recovery race can create a newer generation before the
39357
+ * predecessor receipt is reconciled, and looking only at the newest attempt
39358
+ * would strand the replacement forever.
39359
+ */
39360
+ async function nextSessionAttemptAwaitingQuiescence(
39361
+ db: Database,
39362
+ workspaceId: string,
39363
+ sessionId: string,
39364
+ ): Promise<{
39365
+ attemptId: string;
39366
+ } | null> {
39367
+ const [row] = await db
39368
+ .select({
39369
+ attemptId: schema.sessionTurnAttempts.id,
39370
+ })
39371
+ .from(schema.sessionTurnAttempts)
39372
+ .innerJoin(
39373
+ schema.sessionAttemptInterruptions,
39374
+ and(
39375
+ eq(schema.sessionAttemptInterruptions.workspaceId, schema.sessionTurnAttempts.workspaceId),
39376
+ eq(schema.sessionAttemptInterruptions.sessionId, schema.sessionTurnAttempts.sessionId),
39377
+ eq(schema.sessionAttemptInterruptions.attemptId, schema.sessionTurnAttempts.id),
39378
+ ),
39379
+ )
39380
+ .where(
39381
+ and(
39382
+ eq(schema.sessionTurnAttempts.workspaceId, workspaceId),
39383
+ eq(schema.sessionTurnAttempts.sessionId, sessionId),
39384
+ eq(schema.sessionTurnAttempts.state, "closed"),
39385
+ isNull(schema.sessionTurnAttempts.quiescedAt),
39386
+ inArray(schema.sessionAttemptInterruptions.state, ["settled", "rejected_stale"]),
39387
+ ),
39388
+ )
39389
+ .orderBy(
39390
+ asc(schema.sessionAttemptInterruptions.requestedAt),
39391
+ asc(schema.sessionAttemptInterruptions.id),
39392
+ )
39393
+ .limit(1);
39394
+ return row ?? null;
39395
+ }
39396
+
39358
39397
  async function latestSessionAttemptInterruption(
39359
39398
  db: Database,
39360
39399
  workspaceId: string,
@@ -39402,6 +39441,43 @@ async function latestSessionAttemptInterruption(
39402
39441
  : null;
39403
39442
  }
39404
39443
 
39444
+ async function queuedSteerHasUnquiescedPredecessor(
39445
+ db: Database,
39446
+ workspaceId: string,
39447
+ sessionId: string,
39448
+ predecessorAttemptIds: string[],
39449
+ ): Promise<boolean> {
39450
+ if (predecessorAttemptIds.length === 0) return false;
39451
+ const [row] = await db
39452
+ .select({ attemptId: schema.sessionTurnAttempts.id })
39453
+ .from(schema.sessionTurnAttempts)
39454
+ .innerJoin(
39455
+ schema.sessionAttemptInterruptions,
39456
+ and(
39457
+ eq(schema.sessionAttemptInterruptions.workspaceId, schema.sessionTurnAttempts.workspaceId),
39458
+ eq(schema.sessionAttemptInterruptions.sessionId, schema.sessionTurnAttempts.sessionId),
39459
+ eq(schema.sessionAttemptInterruptions.attemptId, schema.sessionTurnAttempts.id),
39460
+ ),
39461
+ )
39462
+ .where(
39463
+ and(
39464
+ eq(schema.sessionTurnAttempts.workspaceId, workspaceId),
39465
+ eq(schema.sessionTurnAttempts.sessionId, sessionId),
39466
+ inArray(schema.sessionTurnAttempts.id, predecessorAttemptIds),
39467
+ isNull(schema.sessionTurnAttempts.quiescedAt),
39468
+ inArray(schema.sessionAttemptInterruptions.state, [
39469
+ "pending",
39470
+ "delivered",
39471
+ "acknowledged",
39472
+ "settled",
39473
+ "rejected_stale",
39474
+ ]),
39475
+ ),
39476
+ )
39477
+ .limit(1);
39478
+ return row !== undefined;
39479
+ }
39480
+
39405
39481
  /** Read durable session state without reserving a turn-worker slot or mutating it. */
39406
39482
  export async function peekSessionWork(
39407
39483
  db: Database,
@@ -39445,19 +39521,15 @@ export async function peekSessionWork(
39445
39521
  }
39446
39522
  if (effectiveControl.state !== "active") return { kind: "idle" };
39447
39523
 
39448
- const latestInterruption = await latestSessionAttemptInterruption(
39524
+ const awaitingQuiescence = await nextSessionAttemptAwaitingQuiescence(
39449
39525
  scopedDb,
39450
39526
  workspaceId,
39451
39527
  sessionId,
39452
39528
  );
39453
- if (
39454
- latestInterruption &&
39455
- latestInterruption.quiescedAt === null &&
39456
- latestInterruption.interruptionState === "settled"
39457
- ) {
39529
+ if (awaitingQuiescence) {
39458
39530
  return {
39459
39531
  kind: "cancellation-wait",
39460
- attemptId: latestInterruption.attemptId,
39532
+ attemptId: awaitingQuiescence.attemptId,
39461
39533
  };
39462
39534
  }
39463
39535
 
@@ -41315,6 +41387,7 @@ export type RequestSessionTurnRecoveryInput = {
41315
41387
  attemptId: string;
41316
41388
  reason: string;
41317
41389
  detail?: Record<string, unknown>;
41390
+ providerRecoveryCount?: number;
41318
41391
  fromStatuses?: SessionTurnStatus[];
41319
41392
  providerArtifactInvalidation?: {
41320
41393
  codexCredentialId: string;
@@ -41368,6 +41441,25 @@ export async function requestSessionTurnRecovery(
41368
41441
  { workspaceControl: locks.control ?? undefined },
41369
41442
  );
41370
41443
  const turnStatus = (turn?.status as SessionTurnStatus | undefined) ?? null;
41444
+ const [pendingInterruption] = attempt
41445
+ ? await tx
41446
+ .select({ id: schema.sessionAttemptInterruptions.id })
41447
+ .from(schema.sessionAttemptInterruptions)
41448
+ .where(
41449
+ and(
41450
+ eq(schema.sessionAttemptInterruptions.workspaceId, workspaceId),
41451
+ eq(schema.sessionAttemptInterruptions.sessionId, input.sessionId),
41452
+ eq(schema.sessionAttemptInterruptions.attemptId, input.attemptId),
41453
+ inArray(schema.sessionAttemptInterruptions.state, [
41454
+ "pending",
41455
+ "delivered",
41456
+ "acknowledged",
41457
+ ]),
41458
+ ),
41459
+ )
41460
+ .limit(1)
41461
+ .for("update")
41462
+ : [];
41371
41463
  if (
41372
41464
  !locks.workspace ||
41373
41465
  !turn ||
@@ -41381,7 +41473,8 @@ export async function requestSessionTurnRecovery(
41381
41473
  effectiveControl.state !== "active" ||
41382
41474
  session.activeTurnId !== input.turnId ||
41383
41475
  !fromStatuses.includes(turnStatus as SessionTurnStatus) ||
41384
- turn.activeAttemptId !== input.attemptId
41476
+ turn.activeAttemptId !== input.attemptId ||
41477
+ pendingInterruption !== undefined
41385
41478
  ) {
41386
41479
  return {
41387
41480
  action: "stale" as const,
@@ -41392,6 +41485,12 @@ export async function requestSessionTurnRecovery(
41392
41485
  }
41393
41486
 
41394
41487
  const now = new Date();
41488
+ if (
41489
+ input.providerRecoveryCount !== undefined &&
41490
+ (!Number.isSafeInteger(input.providerRecoveryCount) || input.providerRecoveryCount <= 0)
41491
+ ) {
41492
+ throw new Error("providerRecoveryCount must be a positive safe integer");
41493
+ }
41395
41494
  let providerArtifactsInvalidated = 0;
41396
41495
  if (input.providerArtifactInvalidation) {
41397
41496
  const invalidatedHistory = await tx
@@ -41533,7 +41632,12 @@ export async function requestSessionTurnRecovery(
41533
41632
  cancelledBy: null,
41534
41633
  cancelReason: null,
41535
41634
  version: turn.version + 1,
41536
- metadata: metadataWithoutTurnDispatchAttempt(turn.metadata),
41635
+ metadata: {
41636
+ ...metadataWithoutTurnDispatchAttempt(turn.metadata),
41637
+ ...(input.providerRecoveryCount !== undefined
41638
+ ? { providerRecoveryCount: input.providerRecoveryCount }
41639
+ : {}),
41640
+ },
41537
41641
  updatedAt: now,
41538
41642
  })
41539
41643
  .where(
@@ -42142,12 +42246,25 @@ export async function getSessionQueueSnapshot(
42142
42246
  asc(schema.sessionSystemUpdates.createdAt),
42143
42247
  asc(schema.sessionSystemUpdates.id),
42144
42248
  );
42249
+ const items = rows.map(mapSessionTurn);
42145
42250
  const latestInterruption = await latestSessionAttemptInterruption(
42146
42251
  scopedDb,
42147
42252
  workspaceId,
42148
42253
  sessionId,
42149
42254
  );
42150
- const items = rows.map(mapSessionTurn);
42255
+ const queuedSteerPredecessorIds = items
42256
+ .map((turn) => queuedSteerReplacementAttemptId(turn.metadata))
42257
+ .filter((attemptId): attemptId is string => attemptId !== null);
42258
+ const stoppingPreviousAttempt =
42259
+ (latestInterruption !== null &&
42260
+ latestInterruption.interruptionState !== "rejected_stale" &&
42261
+ latestInterruption.quiescedAt === null) ||
42262
+ (await queuedSteerHasUnquiescedPredecessor(
42263
+ scopedDb,
42264
+ workspaceId,
42265
+ sessionId,
42266
+ queuedSteerPredecessorIds,
42267
+ ));
42151
42268
  const nextInputBatch = selectBoundedSystemUpdateBatch(pendingInputs);
42152
42269
  const hasPendingAgentSteer = pendingInputs.some(
42153
42270
  (update) => update.kind === "agent_steer_instruction",
@@ -42159,10 +42276,7 @@ export async function getSessionQueueSnapshot(
42159
42276
  version: session.queueVersion,
42160
42277
  effectiveControl: serializeEffectiveSessionControl(effectiveControl),
42161
42278
  activePersonalConnections,
42162
- stoppingPreviousAttempt:
42163
- latestInterruption !== null &&
42164
- latestInterruption.interruptionState !== "rejected_stale" &&
42165
- latestInterruption.quiescedAt === null,
42279
+ stoppingPreviousAttempt,
42166
42280
  items,
42167
42281
  pendingInputs: pendingInputs.map((update) => {
42168
42282
  const canonical = mapSessionSystemUpdate(update);
@@ -42201,7 +42315,9 @@ function queuedSteerReplacementAttemptId(metadata: Record<string, unknown>): str
42201
42315
  ) {
42202
42316
  return attemptId;
42203
42317
  }
42204
- throw new SessionControlInvariantError("Queued Steer has malformed predecessor metadata");
42318
+ // The interruption ledger is authoritative. Historical or malformed display
42319
+ // metadata must not make the read-only queue projection fail.
42320
+ return null;
42205
42321
  }
42206
42322
 
42207
42323
  async function enqueueFailedChildOutboxForTurnTx(
@@ -42418,6 +42534,7 @@ export async function enqueueSessionWorkflowWakeInTransaction(
42418
42534
  temporalWorkflowId: string;
42419
42535
  reason: string;
42420
42536
  notBefore?: Date;
42537
+ controlRequested?: boolean;
42421
42538
  },
42422
42539
  ): Promise<number> {
42423
42540
  const now = new Date();
@@ -42429,6 +42546,7 @@ export async function enqueueSessionWorkflowWakeInTransaction(
42429
42546
  workspaceId: input.workspaceId,
42430
42547
  sessionId: input.sessionId,
42431
42548
  temporalWorkflowId: input.temporalWorkflowId,
42549
+ controlRevision: input.controlRequested ? 1 : 0,
42432
42550
  reason: input.reason,
42433
42551
  nextAttemptAt,
42434
42552
  })
@@ -42437,6 +42555,9 @@ export async function enqueueSessionWorkflowWakeInTransaction(
42437
42555
  set: {
42438
42556
  temporalWorkflowId: input.temporalWorkflowId,
42439
42557
  wakeRevision: sql`${schema.sessionWorkflowWakeOutbox.wakeRevision} + 1`,
42558
+ controlRevision: input.controlRequested
42559
+ ? sql`${schema.sessionWorkflowWakeOutbox.wakeRevision} + 1`
42560
+ : sql`${schema.sessionWorkflowWakeOutbox.controlRevision}`,
42440
42561
  reason: input.reason,
42441
42562
  attempts: 0,
42442
42563
  // Coalescing a delayed retry must never postpone an already-due wake
@@ -42462,6 +42583,7 @@ export async function enqueueSessionWorkflowWake(
42462
42583
  temporalWorkflowId: string;
42463
42584
  reason: string;
42464
42585
  notBefore?: Date;
42586
+ controlRequested?: boolean;
42465
42587
  },
42466
42588
  ): Promise<number> {
42467
42589
  return await withRlsContext(
@@ -42485,6 +42607,7 @@ export async function enqueueSessionWorkflowWakeIfRunnable(
42485
42607
  temporalWorkflowId: string;
42486
42608
  reason: string;
42487
42609
  notBefore?: Date;
42610
+ controlRequested?: boolean;
42488
42611
  },
42489
42612
  ): Promise<number | null> {
42490
42613
  return await withRlsContext(
package/src/schema.ts CHANGED
@@ -3246,6 +3246,7 @@ export const sessionWorkflowWakeOutbox = pgTable(
3246
3246
  temporalWorkflowId: text("temporal_workflow_id").notNull(),
3247
3247
  wakeRevision: bigint("wake_revision", { mode: "number" }).notNull().default(1),
3248
3248
  deliveredRevision: bigint("delivered_revision", { mode: "number" }).notNull().default(0),
3249
+ controlRevision: bigint("control_revision", { mode: "number" }).notNull().default(0),
3249
3250
  reason: text("reason").notNull(),
3250
3251
  attempts: integer("attempts").notNull().default(0),
3251
3252
  nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
@@ -3262,6 +3263,10 @@ export const sessionWorkflowWakeOutbox = pgTable(
3262
3263
  "session_workflow_wake_outbox_revision_safe_check",
3263
3264
  sql`${table.wakeRevision} <= 9007199254740991 and ${table.deliveredRevision} <= 9007199254740991`,
3264
3265
  ),
3266
+ controlRevisionValid: check(
3267
+ "session_workflow_wake_outbox_control_revision_check",
3268
+ sql`${table.controlRevision} >= 0 and ${table.controlRevision} <= ${table.wakeRevision} and ${table.controlRevision} <= 9007199254740991`,
3269
+ ),
3265
3270
  workspaceAccount: foreignKey({
3266
3271
  name: "session_workflow_wake_outbox_workspace_account_fk",
3267
3272
  columns: [table.workspaceId, table.accountId],
@@ -1494,12 +1494,13 @@ async function registerContinuableWakes(
1494
1494
  )
1495
1495
  ), upserted as (
1496
1496
  insert into ${schema.sessionWorkflowWakeOutbox} (
1497
- session_id, account_id, workspace_id, temporal_workflow_id, reason
1497
+ session_id, account_id, workspace_id, temporal_workflow_id, reason, control_revision
1498
1498
  )
1499
- select session_id, account_id, workspace_id, temporal_workflow_id, ${input.reason}
1499
+ select session_id, account_id, workspace_id, temporal_workflow_id, ${input.reason}, 1
1500
1500
  from eligible
1501
1501
  on conflict (session_id) do update set
1502
1502
  wake_revision = ${schema.sessionWorkflowWakeOutbox}.wake_revision + 1,
1503
+ control_revision = ${schema.sessionWorkflowWakeOutbox}.wake_revision + 1,
1503
1504
  temporal_workflow_id = excluded.temporal_workflow_id,
1504
1505
  reason = excluded.reason,
1505
1506
  attempts = 0,
@@ -1611,12 +1612,13 @@ async function registerCancellationWakes(
1611
1612
  and session.id in (${sessionIds})
1612
1613
  ), upserted as (
1613
1614
  insert into ${schema.sessionWorkflowWakeOutbox} (
1614
- session_id, account_id, workspace_id, temporal_workflow_id, reason
1615
+ session_id, account_id, workspace_id, temporal_workflow_id, reason, control_revision
1615
1616
  )
1616
- select session_id, account_id, workspace_id, temporal_workflow_id, 'session_cancelled'
1617
+ select session_id, account_id, workspace_id, temporal_workflow_id, 'session_cancelled', 1
1617
1618
  from eligible
1618
1619
  on conflict (session_id) do update set
1619
1620
  wake_revision = ${schema.sessionWorkflowWakeOutbox}.wake_revision + 1,
1621
+ control_revision = ${schema.sessionWorkflowWakeOutbox}.wake_revision + 1,
1620
1622
  temporal_workflow_id = excluded.temporal_workflow_id,
1621
1623
  reason = excluded.reason,
1622
1624
  attempts = 0,
@@ -1641,6 +1643,7 @@ export async function registerSessionWorkflowWakeInTransaction(
1641
1643
  sessionId: string;
1642
1644
  temporalWorkflowId: string;
1643
1645
  reason: string;
1646
+ controlRequested?: boolean;
1644
1647
  },
1645
1648
  ): Promise<number> {
1646
1649
  const [row] = await db
@@ -1650,6 +1653,7 @@ export async function registerSessionWorkflowWakeInTransaction(
1650
1653
  workspaceId: input.workspaceId,
1651
1654
  sessionId: input.sessionId,
1652
1655
  temporalWorkflowId: input.temporalWorkflowId,
1656
+ controlRevision: input.controlRequested ? 1 : 0,
1653
1657
  reason: input.reason,
1654
1658
  })
1655
1659
  .onConflictDoUpdate({
@@ -1657,6 +1661,9 @@ export async function registerSessionWorkflowWakeInTransaction(
1657
1661
  set: {
1658
1662
  temporalWorkflowId: input.temporalWorkflowId,
1659
1663
  wakeRevision: sql`${schema.sessionWorkflowWakeOutbox.wakeRevision} + 1`,
1664
+ controlRevision: input.controlRequested
1665
+ ? sql`${schema.sessionWorkflowWakeOutbox.wakeRevision} + 1`
1666
+ : sql`${schema.sessionWorkflowWakeOutbox.controlRevision}`,
1660
1667
  reason: input.reason,
1661
1668
  attempts: 0,
1662
1669
  nextAttemptAt: new Date(),
@@ -1180,6 +1180,7 @@ export async function steerQueuedTurnInTransaction(
1180
1180
  sessionId: input.sessionId,
1181
1181
  temporalWorkflowId: session.temporalWorkflowId ?? `session-${input.sessionId}`,
1182
1182
  reason: "queue_steer",
1183
+ controlRequested: true,
1183
1184
  });
1184
1185
  const receipt = await updateSessionCommandReceiptResult(db, reserved.receipt.id, {
1185
1186
  controlRevision: resumed.revision,
@@ -1739,6 +1740,7 @@ export async function submitHumanPromptInTransaction(
1739
1740
  sessionId: input.sessionId,
1740
1741
  temporalWorkflowId: workflowId,
1741
1742
  reason: input.delivery === "steer" ? "prompt_steer" : "prompt_send",
1743
+ controlRequested: input.delivery === "steer",
1742
1744
  });
1743
1745
  await db.insert(schema.auditEvents).values({
1744
1746
  accountId: input.accountId,
@@ -2183,6 +2185,7 @@ export async function steerAgentSessionInTransaction(
2183
2185
  sessionId: input.targetSessionId,
2184
2186
  temporalWorkflowId: workflowId,
2185
2187
  reason: "agent_steer",
2188
+ controlRequested: true,
2186
2189
  });
2187
2190
  await db
2188
2191
  .update(schema.sessions)