@opengeni/db 0.27.3 → 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
@@ -1,7 +1,7 @@
1
1
  import type { Database } from "./index";
2
2
  export declare const SESSION_REALTIME_CONTEXT_MAX_BYTES = 65536;
3
3
  export declare const SESSION_REALTIME_TAIL_SOURCE = "transcript_tail_flush";
4
- export declare const SESSION_REALTIME_TAIL_INSTRUCTION = "The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.";
4
+ export declare const SESSION_REALTIME_TAIL_INSTRUCTION = "The user just ended the realtime voice session but remains reachable by text. Ending voice changes only the communication mode; it does not stop, pause, or complete existing work. Treat the remaining transcript tail as additional context. If work was already underway, continue it from the current state. Change or stop that work only if the user explicitly requested it. If nothing was underway and the transcript contains no unhandled request, acknowledge briefly; otherwise handle any unhandled request.";
5
5
  export type SessionRealtimeContextProjection = {
6
6
  id: string;
7
7
  workspaceId: string;
@@ -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.3",
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": {
@@ -51,9 +51,9 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@opengeni/codex": "^0.2.10",
54
- "@opengeni/config": "^0.10.9",
55
- "@opengeni/contracts": "^0.37.0",
56
- "@opengeni/network": "^0.1.2",
54
+ "@opengeni/config": "^0.10.11",
55
+ "@opengeni/contracts": "^0.38.1",
56
+ "@opengeni/network": "^0.2.0",
57
57
  "drizzle-orm": "^0.45.2",
58
58
  "postgres": "^3.4.7"
59
59
  },
package/src/index.ts CHANGED
@@ -1106,6 +1106,31 @@ export async function withRlsContext<T>(
1106
1106
  }, transactionConfig);
1107
1107
  }
1108
1108
 
1109
+ /**
1110
+ * Run one bounded database operation on a transaction-pinned backend.
1111
+ *
1112
+ * Callers that also have an application deadline should check their abort
1113
+ * signal before returning from `fn`; throwing there rolls the transaction back
1114
+ * even when the application deadline won a surrounding Promise race.
1115
+ */
1116
+ export async function withDatabaseStatementTimeout<T>(
1117
+ db: Database,
1118
+ timeoutMs: number,
1119
+ fn: (db: Database) => Promise<T>,
1120
+ ): Promise<T> {
1121
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1122
+ throw new Error("withDatabaseStatementTimeout requires a positive timeout");
1123
+ }
1124
+ const boundedTimeoutMs = Math.max(1, Math.floor(timeoutMs));
1125
+ return await db.transaction(async (tx) => {
1126
+ const scoped = tx as unknown as Database;
1127
+ await scoped.execute(
1128
+ sql`select set_config('statement_timeout', ${`${boundedTimeoutMs}ms`}, true)`,
1129
+ );
1130
+ return await fn(scoped);
1131
+ });
1132
+ }
1133
+
1109
1134
  export async function rlsContextForWorkspace(
1110
1135
  db: Database,
1111
1136
  workspaceId: string,
@@ -3698,6 +3723,10 @@ export type StoreIntegrationOAuthClientInput = {
3698
3723
 
3699
3724
  export type ReplaceIntegrationOAuthClientInput = StoreIntegrationOAuthClientInput;
3700
3725
 
3726
+ export type ReplaceIntegrationOAuthClientIfCurrentInput = ReplaceIntegrationOAuthClientInput & {
3727
+ expectedClientId: string;
3728
+ };
3729
+
3701
3730
  export type ConsumeOAuthStateNonceInput = {
3702
3731
  accountId: string;
3703
3732
  workspaceId: string;
@@ -7930,6 +7959,30 @@ export async function replaceIntegrationOAuthClient(
7930
7959
  return mapStoredIntegrationOAuthClient(row);
7931
7960
  }
7932
7961
 
7962
+ export async function replaceIntegrationOAuthClientIfCurrent(
7963
+ db: Database,
7964
+ input: ReplaceIntegrationOAuthClientIfCurrentInput,
7965
+ ): Promise<StoredIntegrationOAuthClient | null> {
7966
+ const [row] = await db
7967
+ .update(schema.integrationOauthClients)
7968
+ .set({
7969
+ authorizationServer: input.authorizationServer,
7970
+ clientId: input.clientId,
7971
+ clientSecretEncrypted: input.clientSecretEncrypted ?? null,
7972
+ tokenEndpointAuthMethod: input.tokenEndpointAuthMethod ?? "none",
7973
+ metadata: input.metadata ?? {},
7974
+ updatedAt: new Date(),
7975
+ })
7976
+ .where(
7977
+ and(
7978
+ eq(schema.integrationOauthClients.issuer, input.issuer),
7979
+ eq(schema.integrationOauthClients.clientId, input.expectedClientId),
7980
+ ),
7981
+ )
7982
+ .returning();
7983
+ return row ? mapStoredIntegrationOAuthClient(row) : null;
7984
+ }
7985
+
7933
7986
  function mapStoredIntegrationOAuthClient(
7934
7987
  row: typeof schema.integrationOauthClients.$inferSelect,
7935
7988
  ): StoredIntegrationOAuthClient {
@@ -37501,6 +37554,7 @@ export async function claimSessionWorkForAttempt(
37501
37554
  "delivered",
37502
37555
  "acknowledged",
37503
37556
  "settled",
37557
+ "rejected_stale",
37504
37558
  ]),
37505
37559
  isNull(schema.sessionTurnAttempts.quiescedAt),
37506
37560
  ),
@@ -38428,28 +38482,6 @@ export async function claimSessionWorkForAttempt(
38428
38482
  turn: mapSessionTurnForExecution(internalTurn),
38429
38483
  };
38430
38484
  }
38431
- const predecessorAttemptId = queuedSteerReplacementAttemptId(queuedTurn.metadata);
38432
- if (predecessorAttemptId) {
38433
- const [predecessor] = await tx
38434
- .select({ quiescedAt: schema.sessionTurnAttempts.quiescedAt })
38435
- .from(schema.sessionTurnAttempts)
38436
- .where(
38437
- and(
38438
- eq(schema.sessionTurnAttempts.workspaceId, workspaceId),
38439
- eq(schema.sessionTurnAttempts.sessionId, sessionId),
38440
- eq(schema.sessionTurnAttempts.id, predecessorAttemptId),
38441
- ),
38442
- )
38443
- .limit(1);
38444
- if (!predecessor) {
38445
- throw new SessionControlInvariantError(
38446
- `Queued Steer ${id} points to missing predecessor attempt ${predecessorAttemptId}`,
38447
- );
38448
- }
38449
- if (!predecessor.quiescedAt) {
38450
- return { action: "unclaimed", reason: "control-pending" };
38451
- }
38452
- }
38453
38485
  // The database guard makes this function the only supported
38454
38486
  // queued-to-running transition. Raw or stale claimers cannot bypass the
38455
38487
  // generation/active-pointer transaction.
@@ -39083,6 +39115,14 @@ export async function settleSessionAttemptInterruptions(
39083
39115
  interruptions.map((interruption) => interruption.id),
39084
39116
  ),
39085
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
+ });
39086
39126
  return {
39087
39127
  action: effectiveControl.state === "paused" ? "paused" : "continue",
39088
39128
  events: [],
@@ -39274,6 +39314,14 @@ export async function settleSessionAttemptInterruptions(
39274
39314
  interruptions.map((interruption) => interruption.id),
39275
39315
  ),
39276
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
+ });
39277
39325
  return {
39278
39326
  action: effectiveControl.state === "paused" ? "paused" : "continue",
39279
39327
  events: [...closedTools.events, ...eventRows.map(mapEvent)],
@@ -39302,6 +39350,50 @@ export type SessionWorkPeek =
39302
39350
  | { kind: "cancellation-wait"; attemptId: string }
39303
39351
  | { kind: "idle" };
39304
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
+
39305
39397
  async function latestSessionAttemptInterruption(
39306
39398
  db: Database,
39307
39399
  workspaceId: string,
@@ -39349,6 +39441,43 @@ async function latestSessionAttemptInterruption(
39349
39441
  : null;
39350
39442
  }
39351
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
+
39352
39481
  /** Read durable session state without reserving a turn-worker slot or mutating it. */
39353
39482
  export async function peekSessionWork(
39354
39483
  db: Database,
@@ -39392,19 +39521,15 @@ export async function peekSessionWork(
39392
39521
  }
39393
39522
  if (effectiveControl.state !== "active") return { kind: "idle" };
39394
39523
 
39395
- const latestInterruption = await latestSessionAttemptInterruption(
39524
+ const awaitingQuiescence = await nextSessionAttemptAwaitingQuiescence(
39396
39525
  scopedDb,
39397
39526
  workspaceId,
39398
39527
  sessionId,
39399
39528
  );
39400
- if (
39401
- latestInterruption &&
39402
- latestInterruption.quiescedAt === null &&
39403
- latestInterruption.interruptionState === "settled"
39404
- ) {
39529
+ if (awaitingQuiescence) {
39405
39530
  return {
39406
39531
  kind: "cancellation-wait",
39407
- attemptId: latestInterruption.attemptId,
39532
+ attemptId: awaitingQuiescence.attemptId,
39408
39533
  };
39409
39534
  }
39410
39535
 
@@ -41262,6 +41387,7 @@ export type RequestSessionTurnRecoveryInput = {
41262
41387
  attemptId: string;
41263
41388
  reason: string;
41264
41389
  detail?: Record<string, unknown>;
41390
+ providerRecoveryCount?: number;
41265
41391
  fromStatuses?: SessionTurnStatus[];
41266
41392
  providerArtifactInvalidation?: {
41267
41393
  codexCredentialId: string;
@@ -41315,6 +41441,25 @@ export async function requestSessionTurnRecovery(
41315
41441
  { workspaceControl: locks.control ?? undefined },
41316
41442
  );
41317
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
+ : [];
41318
41463
  if (
41319
41464
  !locks.workspace ||
41320
41465
  !turn ||
@@ -41328,7 +41473,8 @@ export async function requestSessionTurnRecovery(
41328
41473
  effectiveControl.state !== "active" ||
41329
41474
  session.activeTurnId !== input.turnId ||
41330
41475
  !fromStatuses.includes(turnStatus as SessionTurnStatus) ||
41331
- turn.activeAttemptId !== input.attemptId
41476
+ turn.activeAttemptId !== input.attemptId ||
41477
+ pendingInterruption !== undefined
41332
41478
  ) {
41333
41479
  return {
41334
41480
  action: "stale" as const,
@@ -41339,6 +41485,12 @@ export async function requestSessionTurnRecovery(
41339
41485
  }
41340
41486
 
41341
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
+ }
41342
41494
  let providerArtifactsInvalidated = 0;
41343
41495
  if (input.providerArtifactInvalidation) {
41344
41496
  const invalidatedHistory = await tx
@@ -41480,7 +41632,12 @@ export async function requestSessionTurnRecovery(
41480
41632
  cancelledBy: null,
41481
41633
  cancelReason: null,
41482
41634
  version: turn.version + 1,
41483
- metadata: metadataWithoutTurnDispatchAttempt(turn.metadata),
41635
+ metadata: {
41636
+ ...metadataWithoutTurnDispatchAttempt(turn.metadata),
41637
+ ...(input.providerRecoveryCount !== undefined
41638
+ ? { providerRecoveryCount: input.providerRecoveryCount }
41639
+ : {}),
41640
+ },
41484
41641
  updatedAt: now,
41485
41642
  })
41486
41643
  .where(
@@ -42089,12 +42246,25 @@ export async function getSessionQueueSnapshot(
42089
42246
  asc(schema.sessionSystemUpdates.createdAt),
42090
42247
  asc(schema.sessionSystemUpdates.id),
42091
42248
  );
42249
+ const items = rows.map(mapSessionTurn);
42092
42250
  const latestInterruption = await latestSessionAttemptInterruption(
42093
42251
  scopedDb,
42094
42252
  workspaceId,
42095
42253
  sessionId,
42096
42254
  );
42097
- 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
+ ));
42098
42268
  const nextInputBatch = selectBoundedSystemUpdateBatch(pendingInputs);
42099
42269
  const hasPendingAgentSteer = pendingInputs.some(
42100
42270
  (update) => update.kind === "agent_steer_instruction",
@@ -42106,10 +42276,7 @@ export async function getSessionQueueSnapshot(
42106
42276
  version: session.queueVersion,
42107
42277
  effectiveControl: serializeEffectiveSessionControl(effectiveControl),
42108
42278
  activePersonalConnections,
42109
- stoppingPreviousAttempt:
42110
- latestInterruption !== null &&
42111
- latestInterruption.interruptionState !== "rejected_stale" &&
42112
- latestInterruption.quiescedAt === null,
42279
+ stoppingPreviousAttempt,
42113
42280
  items,
42114
42281
  pendingInputs: pendingInputs.map((update) => {
42115
42282
  const canonical = mapSessionSystemUpdate(update);
@@ -42148,7 +42315,9 @@ function queuedSteerReplacementAttemptId(metadata: Record<string, unknown>): str
42148
42315
  ) {
42149
42316
  return attemptId;
42150
42317
  }
42151
- 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;
42152
42321
  }
42153
42322
 
42154
42323
  async function enqueueFailedChildOutboxForTurnTx(
@@ -42365,6 +42534,7 @@ export async function enqueueSessionWorkflowWakeInTransaction(
42365
42534
  temporalWorkflowId: string;
42366
42535
  reason: string;
42367
42536
  notBefore?: Date;
42537
+ controlRequested?: boolean;
42368
42538
  },
42369
42539
  ): Promise<number> {
42370
42540
  const now = new Date();
@@ -42376,6 +42546,7 @@ export async function enqueueSessionWorkflowWakeInTransaction(
42376
42546
  workspaceId: input.workspaceId,
42377
42547
  sessionId: input.sessionId,
42378
42548
  temporalWorkflowId: input.temporalWorkflowId,
42549
+ controlRevision: input.controlRequested ? 1 : 0,
42379
42550
  reason: input.reason,
42380
42551
  nextAttemptAt,
42381
42552
  })
@@ -42384,6 +42555,9 @@ export async function enqueueSessionWorkflowWakeInTransaction(
42384
42555
  set: {
42385
42556
  temporalWorkflowId: input.temporalWorkflowId,
42386
42557
  wakeRevision: sql`${schema.sessionWorkflowWakeOutbox.wakeRevision} + 1`,
42558
+ controlRevision: input.controlRequested
42559
+ ? sql`${schema.sessionWorkflowWakeOutbox.wakeRevision} + 1`
42560
+ : sql`${schema.sessionWorkflowWakeOutbox.controlRevision}`,
42387
42561
  reason: input.reason,
42388
42562
  attempts: 0,
42389
42563
  // Coalescing a delayed retry must never postpone an already-due wake
@@ -42409,6 +42583,7 @@ export async function enqueueSessionWorkflowWake(
42409
42583
  temporalWorkflowId: string;
42410
42584
  reason: string;
42411
42585
  notBefore?: Date;
42586
+ controlRequested?: boolean;
42412
42587
  },
42413
42588
  ): Promise<number> {
42414
42589
  return await withRlsContext(
@@ -42432,6 +42607,7 @@ export async function enqueueSessionWorkflowWakeIfRunnable(
42432
42607
  temporalWorkflowId: string;
42433
42608
  reason: string;
42434
42609
  notBefore?: Date;
42610
+ controlRequested?: boolean;
42435
42611
  },
42436
42612
  ): Promise<number | null> {
42437
42613
  return await withRlsContext(
@@ -43994,6 +44170,7 @@ function mapSession(
43994
44170
  // the fence exact even if the column type ever drifts (the lease-epoch lesson).
43995
44171
  activeSandboxId: row.activeSandboxId ?? null,
43996
44172
  activeEpoch: Number(row.activeEpoch),
44173
+ workingDir: row.workingDir ?? null,
43997
44174
  variableSetId: row.variableSetId,
43998
44175
  environmentId: row.variableSetId,
43999
44176
  // The rig + frozen rig version the session rides (M3). Both null for a
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],