diff --git a/client/src/components/SidePanel/Schedules/SchedulePanel.tsx b/client/src/components/SidePanel/Schedules/SchedulePanel.tsx
index 7f9e5b3d5f..6abbde2b63 100644
--- a/client/src/components/SidePanel/Schedules/SchedulePanel.tsx
+++ b/client/src/components/SidePanel/Schedules/SchedulePanel.tsx
@@ -9,7 +9,7 @@ import ScheduleCard from './ScheduleCard';
export default function SchedulePanel() {
const localize = useLocalize();
- const { data, isLoading } = useSchedulesQuery();
+ const { data, isLoading, isError, refetch } = useSchedulesQuery();
const [createOpen, setCreateOpen] = useState(false);
const hasCreateAccess = useHasAccess({
@@ -25,6 +25,23 @@ export default function SchedulePanel() {
);
}
+ // A failed query leaves `data` undefined, which the empty-state branch below would
+ // render as "no scheduled chats yet" — telling the user their schedules are gone when
+ // the request merely failed. `maxPerUser` is unknown too, so the create button would
+ // stay enabled and any create would 4xx against a limit we cannot see.
+ if (isError) {
+ return (
+
+
+ {localize('com_ui_schedules_error')}
+
+
+
+ );
+ }
+
const schedules = data?.schedules ?? [];
const maxPerUser = data?.limits.maxPerUser;
const atLimit = maxPerUser !== undefined && schedules.length >= maxPerUser;
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index ec381595aa..4a189c5b9d 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1707,6 +1707,7 @@
"com_ui_schedule_weekdays": "Weekdays",
"com_ui_schedule_weekly": "Weekly",
"com_ui_schedules": "Scheduled chats",
+ "com_ui_schedules_error": "Couldn't load your scheduled chats",
"com_ui_schedules_empty": "No scheduled chats yet",
"com_ui_schedules_used": "{{used}} of {{max}} schedules used",
"com_ui_schema": "Schema",
diff --git a/config/delete-user.js b/config/delete-user.js
index d7ae20e410..d8ae4e2e23 100644
--- a/config/delete-user.js
+++ b/config/delete-user.js
@@ -102,6 +102,17 @@ async function gracefulExit(code = 0) {
AclEntry.deleteMany({ principalId: user._id }),
];
+ // Raise the durable deletion barrier BEFORE counting. A bare count is a
+ // time-of-check/time-of-use read: a fire can be claimed and accepted between the zero
+ // result and the deletes below, and this script cannot abort or drain it. The barrier
+ // is what makes the count meaningful — a live server refuses new fires at the dispatch
+ // boundary (fireSchedule's isOwnerDeleting probe) from this point on, so anything the
+ // count then misses cannot have started after it.
+ await User.updateOne(
+ { _id: uid, deletionRequestedAt: { $exists: false } },
+ { $set: { deletionRequestedAt: new Date() } },
+ );
+
// REFUSE rather than warn when a scheduled run is in flight. This script talks to the
// database directly, so unlike the HTTP deletion paths it cannot abort a live loopback
// generation or wait for it to drain. That generation can already have passed its
diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts
index 798118e54c..a637452f24 100644
--- a/packages/api/src/stream/GenerationJobManager.ts
+++ b/packages/api/src/stream/GenerationJobManager.ts
@@ -3198,10 +3198,15 @@ class GenerationJobManagerClass {
} catch (err) {
logger.warn(`[GenerationJobManager] Failed to read approval before expiry ${streamId}`, err);
}
+ // Forward the job we ALREADY read. Without it the expiry re-reads the store, which
+ // is not just a wasted round trip: the preserve decision keys on `scheduleId`, so a
+ // second read that returns null (or a replacement) would drop a scheduled run's
+ // retained evidence — the opposite of what the first read established.
const expiredCreatedAt = await this._approvals.expireWithIdentity(
streamId,
actionId,
observedJob?.createdAt,
+ observedJob,
);
if (expiredCreatedAt == null) {
return false;