@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/{chunk-EX7CDSGH.js → chunk-M6EOXYHK.js} +6 -1
- package/dist/chunk-M6EOXYHK.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +151 -36
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +17 -0
- package/dist/schema.js +1 -1
- package/dist/session-control.d.ts +1 -0
- package/dist/session-realtime-context.d.ts +1 -1
- package/drizzle/0170_session_control_wake_revision.sql +133 -0
- package/package.json +4 -4
- package/src/index.ts +214 -37
- package/src/schema.ts +5 -0
- package/src/session-control.ts +11 -4
- package/src/session-queue-commands.ts +3 -0
- package/src/session-realtime-context.ts +1 -1
- package/dist/chunk-EX7CDSGH.js.map +0 -1
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
|
@@ -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
|
|
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
|
+
"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.
|
|
55
|
-
"@opengeni/contracts": "^0.
|
|
56
|
-
"@opengeni/network": "^0.
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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],
|