feat(tess): persist durable session state (#729)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #729.
This commit is contained in:
2026-07-13 05:14:11 +00:00
parent e3b5113be2
commit 99a2d0fc9d
27 changed files with 19237 additions and 15 deletions
@@ -0,0 +1,71 @@
CREATE TYPE "public"."interaction_handoff_status" AS ENUM('pending', 'accepted');--> statement-breakpoint
CREATE TYPE "public"."interaction_inbox_status" AS ENUM('pending', 'processing', 'processed');--> statement-breakpoint
CREATE TYPE "public"."interaction_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
CREATE TABLE "interaction_checkpoints" (
"session_id" text PRIMARY KEY NOT NULL,
"checkpoint_id" text NOT NULL,
"cursor" text NOT NULL,
"summary" text NOT NULL,
"compaction_epoch" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "interaction_checkpoints_checkpoint_id_unique" UNIQUE("checkpoint_id")
);
--> statement-breakpoint
CREATE TABLE "interaction_handoffs" (
"handoff_id" text PRIMARY KEY NOT NULL,
"session_id" text NOT NULL,
"destination" text NOT NULL,
"correlation_id" text NOT NULL,
"checkpoint_id" text NOT NULL,
"status" "interaction_handoff_status" DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "interaction_inbox" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" text NOT NULL,
"idempotency_key" text NOT NULL,
"correlation_id" text NOT NULL,
"content" text NOT NULL,
"content_digest" text NOT NULL,
"status" "interaction_inbox_status" DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "interaction_outbox" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_id" text NOT NULL,
"idempotency_key" text NOT NULL,
"correlation_id" text NOT NULL,
"kind" text NOT NULL,
"content" text NOT NULL,
"content_digest" text NOT NULL,
"status" "interaction_outbox_status" DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "interaction_sessions" (
"id" text PRIMARY KEY NOT NULL,
"agent_name" text NOT NULL,
"tenant_id" text NOT NULL,
"owner_id" text NOT NULL,
"provider_id" text NOT NULL,
"runtime_session_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "interaction_checkpoints" ADD CONSTRAINT "interaction_checkpoints_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "interaction_handoffs" ADD CONSTRAINT "interaction_handoffs_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "interaction_inbox" ADD CONSTRAINT "interaction_inbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "interaction_outbox" ADD CONSTRAINT "interaction_outbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "interaction_sessions" ADD CONSTRAINT "interaction_sessions_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "interaction_handoffs_session_status_idx" ON "interaction_handoffs" USING btree ("session_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "interaction_inbox_session_idempotency_idx" ON "interaction_inbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint
CREATE INDEX "interaction_inbox_session_status_created_idx" ON "interaction_inbox" USING btree ("session_id","status","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "interaction_outbox_session_idempotency_idx" ON "interaction_outbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint
CREATE INDEX "interaction_outbox_session_status_created_idx" ON "interaction_outbox" USING btree ("session_id","status","created_at");
@@ -0,0 +1,5 @@
ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_checkpoint_id_unique";--> statement-breakpoint
ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_pkey";--> statement-breakpoint
ALTER TABLE "interaction_checkpoints" ADD COLUMN "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "interaction_checkpoints_session_idempotency_idx" ON "interaction_checkpoints" USING btree ("session_id","checkpoint_id");--> statement-breakpoint
CREATE INDEX "interaction_checkpoints_session_epoch_idx" ON "interaction_checkpoints" USING btree ("session_id","compaction_epoch");
@@ -0,0 +1,5 @@
ALTER TABLE "interaction_outbox" ADD COLUMN "channel_id" text;
--> statement-breakpoint
UPDATE "interaction_outbox" SET "channel_id" = 'legacy:unknown' WHERE "channel_id" IS NULL;
--> statement-breakpoint
ALTER TABLE "interaction_outbox" ALTER COLUMN "channel_id" SET NOT NULL;
@@ -0,0 +1,3 @@
ALTER TABLE "interaction_checkpoints" ADD COLUMN "content_digest" text;--> statement-breakpoint
UPDATE "interaction_checkpoints" SET "content_digest" = 'legacy' WHERE "content_digest" IS NULL;--> statement-breakpoint
ALTER TABLE "interaction_checkpoints" ALTER COLUMN "content_digest" SET NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+28
View File
@@ -85,6 +85,34 @@
"when": 1782310438919,
"tag": "0011_bitter_gateway",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1783911983447,
"tag": "0012_interaction_durable_state",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1783913232578,
"tag": "0013_interaction_checkpoint_history",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1783913398006,
"tag": "0014_interaction_outbox_channel_scope",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1783942610000,
"tag": "0015_interaction_checkpoint_payload_digest",
"breakpoints": true
}
]
}
+114
View File
@@ -487,6 +487,120 @@ export const agentLogs = pgTable(
],
);
// ─── Tess durable session state ─────────────────────────────────────────────
// PostgreSQL is canonical for restart-safe Tess session recovery. The state
// machine lives in @mosaicstack/agent; these records are its durable adapter.
export const interactionInboxStatusEnum = pgEnum('interaction_inbox_status', [
'pending',
'processing',
'processed',
]);
export const interactionOutboxStatusEnum = pgEnum('interaction_outbox_status', [
'pending',
'processing',
'delivered',
]);
export const interactionHandoffStatusEnum = pgEnum('interaction_handoff_status', [
'pending',
'accepted',
]);
export const interactionSessions = pgTable('interaction_sessions', {
id: text('id').primaryKey(),
agentName: text('agent_name').notNull(),
tenantId: text('tenant_id').notNull(),
ownerId: text('owner_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
runtimeSessionId: text('runtime_session_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const interactionInbox = pgTable(
'interaction_inbox',
{
id: uuid('id').primaryKey().defaultRandom(),
sessionId: text('session_id')
.notNull()
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
idempotencyKey: text('idempotency_key').notNull(),
correlationId: text('correlation_id').notNull(),
content: text('content').notNull(),
contentDigest: text('content_digest').notNull(),
status: interactionInboxStatusEnum('status').notNull().default('pending'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('interaction_inbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey),
index('interaction_inbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt),
],
);
export const interactionOutbox = pgTable(
'interaction_outbox',
{
id: uuid('id').primaryKey().defaultRandom(),
sessionId: text('session_id')
.notNull()
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
idempotencyKey: text('idempotency_key').notNull(),
correlationId: text('correlation_id').notNull(),
channelId: text('channel_id').notNull(),
kind: text('kind').notNull(),
content: text('content').notNull(),
contentDigest: text('content_digest').notNull(),
status: interactionOutboxStatusEnum('status').notNull().default('pending'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('interaction_outbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey),
index('interaction_outbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt),
],
);
export const interactionCheckpoints = pgTable(
'interaction_checkpoints',
{
id: uuid('id').primaryKey().defaultRandom(),
sessionId: text('session_id')
.notNull()
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
checkpointId: text('checkpoint_id').notNull(),
contentDigest: text('content_digest').notNull(),
cursor: text('cursor').notNull(),
summary: text('summary').notNull(),
compactionEpoch: integer('compaction_epoch').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('interaction_checkpoints_session_idempotency_idx').on(t.sessionId, t.checkpointId),
index('interaction_checkpoints_session_epoch_idx').on(t.sessionId, t.compactionEpoch),
],
);
export const interactionHandoffs = pgTable(
'interaction_handoffs',
{
handoffId: text('handoff_id').primaryKey(),
sessionId: text('session_id')
.notNull()
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
destination: text('destination').notNull(),
correlationId: text('correlation_id').notNull(),
checkpointId: text('checkpoint_id').notNull(),
status: interactionHandoffStatusEnum('status').notNull().default('pending'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('interaction_handoffs_session_status_idx').on(t.sessionId, t.status)],
);
// ─── Skills ─────────────────────────────────────────────────────────────────
export const skills = pgTable(