Author SHA1 Message Date
pastilhas 211e913501 a restart mid-sync stranded accounts at syncing or queued forever
getAllSyncedAccounts only picks up status = synced, so an account whose sidecar
process died between marking it syncing/queued and clearing that status again
dropped out of the cron and the IDLE watcher silently, with no error to explain
why background sync had stopped. Reset both on startup instead: the job runner
is in-memory, so neither state can be genuinely in flight in a fresh process.
2026-09-14 20:56:24 +01:00
pastilhasandClaude Opus 5 fda9beb80a the skill's name is an identifier, not a label
lowercase-hyphenated and equal to its directory, which is what both harnesses
require to LOAD it. carrying a human title there meant the skill was listed to an
agent and then failed to open. the prose moves to `title`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:11:34 +00:00
3 changed files with 25 additions and 3 deletions
+19 -1
View File
@@ -1,4 +1,4 @@
import { eq, and } from 'drizzle-orm';
import { eq, and, inArray } from 'drizzle-orm';
import { db } from 'officerdb/db';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
import { emailAccounts } from './schema';
@@ -71,6 +71,24 @@ export async function getAllSyncedAccounts(): Promise<EmailAccount[]> {
return rows.map(open);
}
/**
* Clear a `syncing` or `queued` status left behind by a process that died mid-sync.
*
* `getAllSyncedAccounts` only picks up `status = 'synced'`, so an account stranded at either — the
* resync route sets `syncing` before awaiting and only clears it after; a first sync sets `queued`
* before handing off to the in-process job runner — silently drops out of both the cron and the IDLE
* watcher forever. A fresh process can't have either genuinely in flight (the job runner is in-memory,
* not persisted), so on startup that state only ever means "orphaned by the last restart." `performResync`
* — what the cron and IDLE watcher call — already handles an account with no prior sync state by doing a
* full fetch, so resetting to `synced` rather than replaying the dedicated first-sync job is safe.
*/
export async function resetStuckSyncingAccounts(): Promise<void> {
await db
.update(emailAccounts)
.set({ status: 'synced' })
.where(inArray(emailAccounts.status, ['syncing', 'queued']));
}
/** Replace an account's stored secret — used when an OAuth token is refreshed. */
export async function updateEmailAccountCredentials(id: number, credentials: Record<string, unknown>): Promise<void> {
await db.update(emailAccounts).set({ credentials: seal(credentials) }).where(eq(emailAccounts.id, id));
+4 -1
View File
@@ -1,5 +1,5 @@
import { getUserById } from 'officerdb';
import { getAllSyncedAccounts } from '../db/queries';
import { getAllSyncedAccounts, resetStuckSyncingAccounts } from '../db/queries';
import { performResync } from './resync';
const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
@@ -32,6 +32,9 @@ async function tick() {
export function initEmailCron() {
if (timer) return;
resetStuckSyncingAccounts().catch((err) => {
console.error('[email-cron] Failed to reset stuck accounts:', err instanceof Error ? err.message : err);
});
console.log(`[email-cron] Starting email sync cron (every ${INTERVAL_MS / 60_000} min)`);
timer = setInterval(tick, INTERVAL_MS);
setTimeout(tick, 30_000);
+2 -1
View File
@@ -1,5 +1,6 @@
---
name: Email database
name: email-db
title: Email database
description: Use when querying the user's synced email — searching messages, senders, domains, attachments or counts. Carries the SQLite schema and the queries worth knowing, so you do not have to rediscover them.
version: 1
---