@fickydev/pigent 0.1.9 → 0.1.11

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.
@@ -0,0 +1,223 @@
1
+ import type { SchedulerConfig, TaskConfig } from "../config/schemas";
2
+ import type { Repositories } from "../db/repositories";
3
+ import type { TaskConfigRow } from "../db/schema";
4
+ import { logger } from "../logging/logger";
5
+ import type { AgentRegistry } from "../agents/AgentRegistry";
6
+ import type { AgentRunner } from "../agents/AgentRunner";
7
+ import type { ChannelAdapter } from "../channels/types";
8
+ import { isTaskDue } from "./taskDue";
9
+
10
+ export class Scheduler {
11
+ private running = false;
12
+ private tickTimer: ReturnType<typeof setTimeout> | null = null;
13
+ private readonly taskLocks = new Map<string, boolean>();
14
+ private tasks: TaskConfig[] = [];
15
+
16
+ constructor(
17
+ private readonly schedulerConfig: SchedulerConfig,
18
+ private readonly registry: AgentRegistry,
19
+ private readonly repositories: Repositories,
20
+ private readonly adapters: ChannelAdapter[],
21
+ private readonly agentRunner: AgentRunner,
22
+ ) {}
23
+
24
+ async start(): Promise<void> {
25
+ if (this.running) return;
26
+
27
+ this.tasks = await this.loadAllTasks();
28
+
29
+ if (this.tasks.length === 0) {
30
+ logger.info("scheduler skipped — no tasks configured");
31
+ return;
32
+ }
33
+
34
+ this.running = true;
35
+ logger.info("scheduler started", {
36
+ tickIntervalMs: this.schedulerConfig.tickIntervalMs,
37
+ taskCount: this.tasks.length,
38
+ });
39
+ this.tick();
40
+ }
41
+
42
+ stop(): void {
43
+ if (!this.running) return;
44
+ this.running = false;
45
+ if (this.tickTimer !== null) {
46
+ clearTimeout(this.tickTimer);
47
+ this.tickTimer = null;
48
+ }
49
+ logger.info("scheduler stopped");
50
+ }
51
+
52
+ getTasks(): TaskConfig[] {
53
+ return [...this.tasks];
54
+ }
55
+
56
+ /** Add a runtime-created task (from Telegram). Saves to DB, hot-adds to scheduler. */
57
+ async addDynamicTask(input: {
58
+ agent: string;
59
+ intervalMs: number;
60
+ prompt: string;
61
+ channel: string;
62
+ chatId: string;
63
+ }): Promise<TaskConfigRow> {
64
+ const saved = await this.repositories.taskConfigs.create(input);
65
+
66
+ this.tasks.push({
67
+ id: saved.id,
68
+ agent: saved.agent,
69
+ intervalMs: saved.intervalMs,
70
+ prompt: saved.prompt,
71
+ channel: saved.channel,
72
+ chatId: saved.chatId,
73
+ });
74
+
75
+ if (!this.running && this.tasks.length === 1) {
76
+ // First task added, start scheduler
77
+ this.running = true;
78
+ this.tick();
79
+ logger.info("scheduler started on first dynamic task");
80
+ }
81
+
82
+ return saved;
83
+ }
84
+
85
+ /** Remove a runtime task. Deletes from DB, hot-removes from scheduler. */
86
+ async removeDynamicTask(id: string): Promise<boolean> {
87
+ const before = this.tasks.length;
88
+ this.tasks = this.tasks.filter((t) => t.id !== id);
89
+ await this.repositories.taskConfigs.remove(id);
90
+
91
+ const removed = this.tasks.length < before;
92
+
93
+ if (removed && this.tasks.length === 0 && this.running) {
94
+ this.stop();
95
+ }
96
+
97
+ return removed;
98
+ }
99
+
100
+ private async loadAllTasks(): Promise<TaskConfig[]> {
101
+ const configTasks: TaskConfig[] = this.schedulerConfig.tasks;
102
+ const dbTasks = await this.repositories.taskConfigs.findAllEnabled();
103
+
104
+ const dbTaskConfigs: TaskConfig[] = dbTasks.map((row) => ({
105
+ id: row.id,
106
+ agent: row.agent,
107
+ intervalMs: row.intervalMs,
108
+ prompt: row.prompt,
109
+ channel: row.channel,
110
+ chatId: row.chatId,
111
+ }));
112
+
113
+ return [...configTasks, ...dbTaskConfigs];
114
+ }
115
+
116
+ private tick(): void {
117
+ if (!this.running) return;
118
+
119
+ for (const task of this.tasks) {
120
+ this.runTaskIfDue(task);
121
+ }
122
+
123
+ this.tickTimer = setTimeout(() => this.tick(), this.schedulerConfig.tickIntervalMs);
124
+ }
125
+
126
+ private async runTaskIfDue(task: TaskConfig): Promise<void> {
127
+ if (this.taskLocks.get(task.id)) return;
128
+
129
+ const lastRun = await this.repositories.taskRuns.findLatest(task.agent, task.id);
130
+ if (!isTaskDue(lastRun?.createdAt ?? null, task.intervalMs)) return;
131
+
132
+ const agent = this.registry.getAgent(task.agent);
133
+ if (!agent) {
134
+ logger.warn("task references unknown agent, skipping", { taskId: task.id, agent: task.agent });
135
+ return;
136
+ }
137
+
138
+ this.taskLocks.set(task.id, true);
139
+
140
+ try {
141
+ await this.executeTask(task, agent);
142
+ } finally {
143
+ this.taskLocks.set(task.id, false);
144
+ }
145
+ }
146
+
147
+ private async executeTask(task: TaskConfig, agent: any): Promise<void> {
148
+ logger.info("task executing", { taskId: task.id, agentId: task.agent });
149
+
150
+ const run = await this.repositories.taskRuns.create({
151
+ agentId: task.agent,
152
+ taskId: task.id,
153
+ prompt: task.prompt,
154
+ });
155
+
156
+ await this.repositories.taskRuns.markRunning(run.id);
157
+
158
+ try {
159
+ const fullPrompt = this.composeTaskPrompt(task);
160
+
161
+ // Use AgentRunner to run the prompt — shares the session lock with user messages
162
+ const response = await this.agentRunner.run({
163
+ agentId: task.agent,
164
+ text: fullPrompt,
165
+ message: {
166
+ id: "task:" + task.id,
167
+ channel: task.channel as any,
168
+ chatId: task.chatId!,
169
+ text: fullPrompt,
170
+ raw: { type: "task", taskId: task.id },
171
+ receivedAt: new Date(),
172
+ source: "task",
173
+ },
174
+ });
175
+
176
+ const trimmed = response.trim();
177
+
178
+ if (trimmed.toUpperCase() === "NOOP") {
179
+ await this.repositories.taskRuns.markNoop(run.id, { result: response });
180
+ logger.info("task noop", { taskId: task.id });
181
+ return;
182
+ }
183
+
184
+ await this.repositories.taskRuns.markNotified(run.id, { result: response });
185
+ await this.sendToChannel(task, agent, response);
186
+ } catch (error) {
187
+ const errorMessage = error instanceof Error ? error.message : String(error);
188
+ logger.error("task failed", { taskId: task.id, error: errorMessage });
189
+ await this.repositories.taskRuns.markFailed(run.id, { error: errorMessage });
190
+ }
191
+ }
192
+
193
+ private async sendToChannel(task: TaskConfig, agent: any, text: string): Promise<void> {
194
+ const adapter = this.adapters.find((a) => a.id === task.channel);
195
+ if (!adapter) {
196
+ logger.warn("no adapter for task output", { taskId: task.id, channel: task.channel });
197
+ return;
198
+ }
199
+
200
+ if (!task.chatId) {
201
+ logger.warn("no chatId for task output", { taskId: task.id });
202
+ return;
203
+ }
204
+
205
+ await adapter.send({
206
+ channel: task.channel as any,
207
+ chatId: task.chatId,
208
+ text: `[${agent.name} / ${task.id}]\n${text}`,
209
+ });
210
+ }
211
+
212
+ private composeTaskPrompt(task: TaskConfig): string {
213
+ return [
214
+ `[Task: ${task.id}]`,
215
+ "",
216
+ task.prompt,
217
+ "",
218
+ "You are running as a scheduled task inside Pigent.",
219
+ "If no useful action is needed, reply exactly: NOOP.",
220
+ "Otherwise, reply with the result of your task.",
221
+ ].join("\n");
222
+ }
223
+ }
@@ -0,0 +1,4 @@
1
+ export function isTaskDue(lastRunCreatedAt: number | null, intervalMs: number, now = Date.now()): boolean {
2
+ if (lastRunCreatedAt === null) return true;
3
+ return now - lastRunCreatedAt >= intervalMs;
4
+ }
@@ -0,0 +1,63 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import type { DbClient } from "../client";
4
+ import { taskConfigs, type TaskConfigRow } from "../schema";
5
+
6
+ export type CreateTaskConfigInput = {
7
+ agent: string;
8
+ intervalMs: number;
9
+ prompt: string;
10
+ channel: string;
11
+ chatId: string;
12
+ };
13
+
14
+ export class TaskConfigRepository {
15
+ constructor(private readonly db: DbClient) {}
16
+
17
+ async create(input: CreateTaskConfigInput): Promise<TaskConfigRow> {
18
+ const id = nanoid(8);
19
+ const now = Date.now();
20
+
21
+ await this.db.insert(taskConfigs).values({
22
+ id,
23
+ agent: input.agent,
24
+ intervalMs: input.intervalMs,
25
+ prompt: input.prompt,
26
+ channel: input.channel,
27
+ chatId: input.chatId,
28
+ enabled: true,
29
+ createdAt: now,
30
+ updatedAt: now,
31
+ });
32
+
33
+ const row = await this.findById(id);
34
+ if (!row) throw new Error(`failed to create task config ${id}`);
35
+
36
+ return row;
37
+ }
38
+
39
+ async remove(id: string): Promise<void> {
40
+ await this.db.delete(taskConfigs).where(eq(taskConfigs.id, id));
41
+ }
42
+
43
+ async findById(id: string): Promise<TaskConfigRow | null> {
44
+ const row = await this.db.query.taskConfigs.findFirst({
45
+ where: eq(taskConfigs.id, id),
46
+ });
47
+
48
+ return row ?? null;
49
+ }
50
+
51
+ async findAllByChatId(chatId: string): Promise<TaskConfigRow[]> {
52
+ return this.db.query.taskConfigs.findMany({
53
+ where: eq(taskConfigs.chatId, chatId),
54
+ orderBy: (fields, { asc }) => [asc(fields.createdAt)],
55
+ });
56
+ }
57
+
58
+ async findAllEnabled(): Promise<TaskConfigRow[]> {
59
+ return this.db.query.taskConfigs.findMany({
60
+ where: eq(taskConfigs.enabled, true),
61
+ });
62
+ }
63
+ }
@@ -0,0 +1,129 @@
1
+ import { and, desc, eq, gte } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import type { DbClient } from "../client";
4
+ import { taskRuns, type TaskRunRow } from "../schema";
5
+
6
+ export type TaskRunStatus = "pending" | "running" | "noop" | "notified" | "failed";
7
+
8
+ export type CreateTaskRunInput = {
9
+ agentId: string;
10
+ taskId: string;
11
+ prompt: string;
12
+ sessionId?: string | null;
13
+ status?: Extract<TaskRunStatus, "pending" | "running">;
14
+ startedAt?: number | null;
15
+ };
16
+
17
+ export type FinishTaskRunInput = {
18
+ result?: string | null;
19
+ error?: string | null;
20
+ finishedAt?: number;
21
+ };
22
+
23
+ export class TaskRepository {
24
+ constructor(private readonly db: DbClient) {}
25
+
26
+ async create(input: CreateTaskRunInput): Promise<TaskRunRow> {
27
+ const id = nanoid();
28
+ const now = Date.now();
29
+
30
+ await this.db.insert(taskRuns).values({
31
+ id,
32
+ agentId: input.agentId,
33
+ taskId: input.taskId,
34
+ prompt: input.prompt,
35
+ sessionId: input.sessionId ?? null,
36
+ status: input.status ?? "pending",
37
+ result: null,
38
+ error: null,
39
+ startedAt: input.startedAt ?? null,
40
+ finishedAt: null,
41
+ createdAt: now,
42
+ });
43
+
44
+ const row = await this.findById(id);
45
+ if (!row) throw new Error(`failed to create task run ${id}`);
46
+
47
+ return row;
48
+ }
49
+
50
+ async markRunning(id: string, startedAt = Date.now()): Promise<TaskRunRow> {
51
+ await this.db
52
+ .update(taskRuns)
53
+ .set({ status: "running", startedAt })
54
+ .where(eq(taskRuns.id, id));
55
+
56
+ return this.requireById(id);
57
+ }
58
+
59
+ async markNoop(id: string, input: FinishTaskRunInput = {}): Promise<TaskRunRow> {
60
+ return this.finish(id, "noop", input);
61
+ }
62
+
63
+ async markNotified(id: string, input: FinishTaskRunInput = {}): Promise<TaskRunRow> {
64
+ return this.finish(id, "notified", input);
65
+ }
66
+
67
+ async markFailed(id: string, input: FinishTaskRunInput): Promise<TaskRunRow> {
68
+ return this.finish(id, "failed", input);
69
+ }
70
+
71
+ async findById(id: string): Promise<TaskRunRow | null> {
72
+ const row = await this.db.query.taskRuns.findFirst({
73
+ where: eq(taskRuns.id, id),
74
+ });
75
+
76
+ return row ?? null;
77
+ }
78
+
79
+ async findLatest(agentId: string, taskId: string): Promise<TaskRunRow | null> {
80
+ const row = await this.db.query.taskRuns.findFirst({
81
+ where: and(eq(taskRuns.agentId, agentId), eq(taskRuns.taskId, taskId)),
82
+ orderBy: desc(taskRuns.createdAt),
83
+ });
84
+
85
+ return row ?? null;
86
+ }
87
+
88
+ async countRecentNotified(agentId: string, taskId: string, since: number): Promise<number> {
89
+ const conditions: any[] = [
90
+ eq(taskRuns.agentId, agentId),
91
+ eq(taskRuns.taskId, taskId),
92
+ eq(taskRuns.status, "notified"),
93
+ ];
94
+
95
+ const rows = await this.db.query.taskRuns.findMany({
96
+ where: and(gte(taskRuns.finishedAt, since), ...conditions),
97
+ columns: { id: true },
98
+ });
99
+
100
+ return rows.length;
101
+ }
102
+
103
+ private async finish(
104
+ id: string,
105
+ status: Extract<TaskRunStatus, "noop" | "notified" | "failed">,
106
+ input: FinishTaskRunInput,
107
+ ): Promise<TaskRunRow> {
108
+ await this.db
109
+ .update(taskRuns)
110
+ .set({
111
+ status,
112
+ result: input.result ?? null,
113
+ error: input.error ?? null,
114
+ finishedAt: input.finishedAt ?? Date.now(),
115
+ })
116
+ .where(eq(taskRuns.id, id));
117
+
118
+ return this.requireById(id);
119
+ }
120
+
121
+ private async requireById(id: string): Promise<TaskRunRow> {
122
+ const row = await this.findById(id);
123
+ if (!row) throw new Error(`task run not found ${id}`);
124
+
125
+ return row;
126
+ }
127
+ }
128
+
129
+
@@ -4,6 +4,8 @@ import { HeartbeatRepository } from "./HeartbeatRepository";
4
4
  import { MessageRepository } from "./MessageRepository";
5
5
  import { RuntimeKvRepository } from "./RuntimeKvRepository";
6
6
  import { SessionRepository } from "./SessionRepository";
7
+ import { TaskConfigRepository } from "./TaskConfigRepository";
8
+ import { TaskRepository } from "./TaskRepository";
7
9
  import { TelegramRepository } from "./TelegramRepository";
8
10
 
9
11
  export type Repositories = ReturnType<typeof createRepositories>;
@@ -15,6 +17,8 @@ export function createRepositories(dbClient = db) {
15
17
  messages: new MessageRepository(dbClient),
16
18
  runtimeKv: new RuntimeKvRepository(dbClient),
17
19
  sessions: new SessionRepository(dbClient),
20
+ taskConfigs: new TaskConfigRepository(dbClient),
21
+ taskRuns: new TaskRepository(dbClient),
18
22
  telegram: new TelegramRepository(dbClient),
19
23
  };
20
24
  }
package/src/db/schema.ts CHANGED
@@ -90,6 +90,32 @@ export const heartbeats = sqliteTable("heartbeats", {
90
90
  createdAt: integer("created_at").notNull(),
91
91
  });
92
92
 
93
+ export const taskRuns = sqliteTable("task_runs", {
94
+ id: text("id").primaryKey(),
95
+ agentId: text("agent_id").notNull(),
96
+ taskId: text("task_id").notNull(),
97
+ prompt: text("prompt").notNull(),
98
+ sessionId: text("session_id"),
99
+ status: text("status", { enum: ["pending", "running", "noop", "notified", "failed"] }).notNull(),
100
+ result: text("result"),
101
+ error: text("error"),
102
+ startedAt: integer("started_at"),
103
+ finishedAt: integer("finished_at"),
104
+ createdAt: integer("created_at").notNull(),
105
+ });
106
+
107
+ export const taskConfigs = sqliteTable("task_configs", {
108
+ id: text("id").primaryKey(),
109
+ agent: text("agent").notNull(),
110
+ intervalMs: integer("interval_ms").notNull(),
111
+ prompt: text("prompt").notNull(),
112
+ channel: text("channel").notNull().default("telegram"),
113
+ chatId: text("chat_id").notNull(),
114
+ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
115
+ createdAt: integer("created_at").notNull(),
116
+ updatedAt: integer("updated_at").notNull(),
117
+ });
118
+
93
119
  export const runtimeKv = sqliteTable("runtime_kv", {
94
120
  key: text("key").primaryKey(),
95
121
  value: text("value").notNull(),
@@ -106,3 +132,7 @@ export type MessageRow = typeof messages.$inferSelect;
106
132
  export type NewMessageRow = typeof messages.$inferInsert;
107
133
  export type HeartbeatRow = typeof heartbeats.$inferSelect;
108
134
  export type NewHeartbeatRow = typeof heartbeats.$inferInsert;
135
+ export type TaskRunRow = typeof taskRuns.$inferSelect;
136
+ export type NewTaskRunRow = typeof taskRuns.$inferInsert;
137
+ export type TaskConfigRow = typeof taskConfigs.$inferSelect;
138
+ export type NewTaskConfigRow = typeof taskConfigs.$inferInsert;