nudger: a schedule that keeps a chat moving when nobody is watching
an agent told to record blockers and continue still stops at the end of a turn, and at 3am nobody types the next message. this is the schedule that does. the door already existed. `startClaudeTurn` takes `ws: null` — extracted that way so command-hq could drive a turn over http — so this is a second caller of an existing path rather than a new way into a live session. i had said the platform lacked that; it did not. the rule that decides the design: never speak while the agent is working. so `every_seconds` is a FLOOR, not a metronome. when a nudge comes due and the session is generating, the row is left alone and reconsidered next tick, which means it fires as soon as the turn ends rather than sleeping through to the next hour. a strict alarm would skip an agent that finished four minutes late and leave it idle for the other fifty-six. `isGenerating` unknown counts as BUSY. a sidecar that cannot answer might be mid-turn, and interrupting one is the failure this exists to avoid. matched on both sessionKey and claudeSessionId: officer's handle and the transcript's filename are different ids, only the agent holds the mapping, and a schedule stored against one would silently never match a live row keyed by the other. one nudge per (user, session) — a second schedule for one conversation is two alarms for one sleeper. counted even when the turn fails to start, so a refusal cannot become a tight loop. disables itself at the ceiling rather than leaving a spent row armed. the tick starts in server.tsx rather than on first use, because a schedule armed before a restart has to survive one.
This commit is contained in:
@@ -35,6 +35,7 @@ export * from './permissions';
|
||||
export * from './chat-events';
|
||||
export * from './chat-session-models';
|
||||
export * from './chat-notification-prefs';
|
||||
export * from './nudges';
|
||||
export * from './ai-provider-usage';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { setNudge, getNudge, cancelNudge, dueNudges, recordNudgeSent } from './queries';
|
||||
@@ -0,0 +1,80 @@
|
||||
import { and, eq, lt, or, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { nudges } from './schema';
|
||||
import type { NudgeSelect } from '../types';
|
||||
|
||||
/** Create or replace the schedule for one session. Re-arming resets the count — it is a new night. */
|
||||
export async function setNudge(params: {
|
||||
userId: number;
|
||||
sessionId: string;
|
||||
message: string;
|
||||
everySeconds: number;
|
||||
maxCount: number;
|
||||
}): Promise<NudgeSelect> {
|
||||
const [row] = await db
|
||||
.insert(nudges)
|
||||
.values({ ...params, enabled: true, sentCount: 0, lastFiredAt: null })
|
||||
.onConflictDoUpdate({
|
||||
target: [nudges.userId, nudges.sessionId],
|
||||
set: {
|
||||
message: params.message,
|
||||
everySeconds: params.everySeconds,
|
||||
maxCount: params.maxCount,
|
||||
enabled: true,
|
||||
sentCount: 0,
|
||||
lastFiredAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function getNudge(userId: number, sessionId: string): Promise<NudgeSelect | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(nudges)
|
||||
.where(and(eq(nudges.userId, userId), eq(nudges.sessionId, sessionId)))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function cancelNudge(userId: number, sessionId: string): Promise<void> {
|
||||
await db.delete(nudges).where(and(eq(nudges.userId, userId), eq(nudges.sessionId, sessionId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the scheduler should consider this tick.
|
||||
*
|
||||
* Due means: enabled, under its ceiling, and either never fired or last fired at least `every_seconds`
|
||||
* ago. Whether the session is actually IDLE is not asked here — that lives in the sidecar, not the
|
||||
* database, and the scheduler checks it immediately before speaking.
|
||||
*/
|
||||
export async function dueNudges(now: Date = new Date()): Promise<NudgeSelect[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(nudges)
|
||||
.where(
|
||||
and(
|
||||
eq(nudges.enabled, true),
|
||||
lt(nudges.sentCount, nudges.maxCount),
|
||||
or(
|
||||
isNull(nudges.lastFiredAt),
|
||||
lt(nudges.lastFiredAt, sql`${now} - (${nudges.everySeconds} * interval '1 second')`),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** One nudge delivered. Disables itself on reaching the ceiling rather than leaving a spent row armed. */
|
||||
export async function recordNudgeSent(id: number): Promise<void> {
|
||||
await db
|
||||
.update(nudges)
|
||||
.set({
|
||||
sentCount: sql`${nudges.sentCount} + 1`,
|
||||
lastFiredAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
enabled: sql`${nudges.sentCount} + 1 < ${nudges.maxCount}`,
|
||||
})
|
||||
.where(eq(nudges.id, id));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, check, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
// Nudger: keep a chat moving while nobody is watching it.
|
||||
//
|
||||
// An agent told to record blockers and continue will still stop at the end of a turn, and at 3am nobody
|
||||
// types the next message. This is the schedule that does.
|
||||
//
|
||||
// One nudge per (user, session). A second schedule for the same conversation is not a feature — it is two
|
||||
// alarms for one sleeper, and the only thing it reliably produces is a doubled prompt.
|
||||
|
||||
export const nudges = pgTable(
|
||||
'nudges',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The chat session's transcript uuid — the same key `chat_session_models` uses. */
|
||||
sessionId: text('session_id').notNull(),
|
||||
message: text('message').notNull(),
|
||||
everySeconds: integer('every_seconds').notNull().default(3600),
|
||||
/** A loop nobody is watching should end on its own. */
|
||||
maxCount: integer('max_count').notNull().default(12),
|
||||
sentCount: integer('sent_count').notNull().default(0),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
/** Null until the first send. The scheduler treats null as "due now". */
|
||||
lastFiredAt: timestamp('last_fired_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
// uniqueIndex, not unique().on() — drizzle-kit re-creates named composite CONSTRAINTS on every push.
|
||||
// See src/databases/CLAUDE.md → "Composite keys".
|
||||
uniqueIndex('uq_nudges_user_session').on(table.userId, table.sessionId),
|
||||
// A floor, because the scheduler ticks once a minute and anything under that is a lie about cadence
|
||||
// rather than a faster nudge.
|
||||
check('ck_nudges_every_seconds', sql`${table.everySeconds} >= 60`),
|
||||
check('ck_nudges_max_count', sql`${table.maxCount} > 0`),
|
||||
],
|
||||
);
|
||||
@@ -45,6 +45,7 @@ export * from './app-store/schema'; // sidecar_installs
|
||||
export * from './plugins/schema'; // plugin_installs — core: the plugin system is the platform's own
|
||||
export * from './service-connections/schema'; // service_connections
|
||||
export * from './notify/schema'; // push_devices — core since 2026-08-19, see docs/push-notifications.md
|
||||
export * from './nudges/schema'; // nudges — the Nudger schedule
|
||||
|
||||
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export type PipelineJobSelect = typeof Schema.pipelineJobs.$inferSelect;
|
||||
export type PipelineJobInsert = typeof Schema.pipelineJobs.$inferInsert;
|
||||
|
||||
// Notifications
|
||||
export type NudgeSelect = typeof Schema.nudges.$inferSelect;
|
||||
export type PushDeviceSelect = typeof NotifySchema.pushDevices.$inferSelect;
|
||||
export type PushDeviceInsert = typeof NotifySchema.pushDevices.$inferInsert;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user