@gonzih/cc-tg 0.6.1 → 0.6.3
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.
- package/dist/cc-agent-events.d.ts +80 -0
- package/dist/cc-agent-events.js +413 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cc-agent Redis event subscriber.
|
|
3
|
+
*
|
|
4
|
+
* Listens to the `cca:events` pub/sub channel for job completion events,
|
|
5
|
+
* asks Claude to decide what to do, and acts accordingly:
|
|
6
|
+
* NOTIFY_ONLY — send a Telegram message to the configured chat
|
|
7
|
+
* SPAWN_FOLLOWUP — spawn a follow-up cc-agent job via MCP + notify Telegram
|
|
8
|
+
* SILENT — log and do nothing
|
|
9
|
+
*
|
|
10
|
+
* Controlled via CC_AGENT_EVENTS_ENABLED env var (default: true).
|
|
11
|
+
* Requires CC_AGENT_NOTIFY_CHAT_ID to send Telegram notifications.
|
|
12
|
+
*/
|
|
13
|
+
export interface JobEvent {
|
|
14
|
+
jobId: string;
|
|
15
|
+
status: "done" | "failed" | "interrupted" | "running" | "cancelled";
|
|
16
|
+
title: string;
|
|
17
|
+
repoUrl: string;
|
|
18
|
+
lastLines: string[];
|
|
19
|
+
score?: number;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
export interface CoordinatorPlan {
|
|
23
|
+
nextStep?: {
|
|
24
|
+
repo_url: string;
|
|
25
|
+
task: string;
|
|
26
|
+
};
|
|
27
|
+
summary: string;
|
|
28
|
+
}
|
|
29
|
+
export interface DecisionResult {
|
|
30
|
+
action: "NOTIFY_ONLY" | "SPAWN_FOLLOWUP" | "SILENT";
|
|
31
|
+
message?: string;
|
|
32
|
+
followup?: {
|
|
33
|
+
repo_url: string;
|
|
34
|
+
task: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Injectable dependencies for testability */
|
|
38
|
+
export interface HandlerDeps {
|
|
39
|
+
askClaude: (prompt: string) => Promise<string>;
|
|
40
|
+
sendTelegramMessage: (chatId: number, text: string) => Promise<void>;
|
|
41
|
+
spawnFollowupAgent: (repoUrl: string, task: string) => Promise<void>;
|
|
42
|
+
readJobOutput: (jobId: string) => Promise<string[]>;
|
|
43
|
+
readCoordinatorPlan: (jobId: string) => Promise<CoordinatorPlan | null>;
|
|
44
|
+
getRunningJobCount: () => Promise<number>;
|
|
45
|
+
}
|
|
46
|
+
export declare function buildDecisionPrompt(event: JobEvent, last40lines: string[], coordinatorPlan: CoordinatorPlan | null): string;
|
|
47
|
+
export declare function parseDecision(raw: string): DecisionResult;
|
|
48
|
+
/**
|
|
49
|
+
* Ask Claude to make a decision about a completed job.
|
|
50
|
+
* Returns the raw text response from Claude.
|
|
51
|
+
*/
|
|
52
|
+
export declare function defaultAskClaude(prompt: string): Promise<string>;
|
|
53
|
+
export declare function defaultSendTelegramMessage(chatId: number, text: string): Promise<void>;
|
|
54
|
+
export declare function defaultSpawnFollowupAgent(repoUrl: string, task: string): Promise<void>;
|
|
55
|
+
export declare function defaultReadJobOutput(jobId: string): Promise<string[]>;
|
|
56
|
+
export declare function defaultReadCoordinatorPlan(jobId: string): Promise<CoordinatorPlan | null>;
|
|
57
|
+
export declare function defaultGetRunningJobCount(): Promise<number>;
|
|
58
|
+
/**
|
|
59
|
+
* Write a coordinator plan for a job, so cc-tg knows what follow-up to spawn.
|
|
60
|
+
* Call this when spawning a job that has a planned follow-up.
|
|
61
|
+
* TTL: 7 days.
|
|
62
|
+
*/
|
|
63
|
+
export declare function writeCoordinatorPlan(jobId: string, plan: {
|
|
64
|
+
nextStep?: {
|
|
65
|
+
repo_url: string;
|
|
66
|
+
task: string;
|
|
67
|
+
};
|
|
68
|
+
summary: string;
|
|
69
|
+
}): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Handle a single job event message from Redis pub/sub.
|
|
72
|
+
* Exported for testability — production code passes defaultDeps.
|
|
73
|
+
*/
|
|
74
|
+
export declare function handleJobEvent(message: string, deps: HandlerDeps): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Connect to Redis and subscribe to cca:events.
|
|
77
|
+
* Reconnects automatically on disconnect.
|
|
78
|
+
* Call once at startup.
|
|
79
|
+
*/
|
|
80
|
+
export declare function connectEventSubscriber(): Promise<void>;
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cc-agent Redis event subscriber.
|
|
3
|
+
*
|
|
4
|
+
* Listens to the `cca:events` pub/sub channel for job completion events,
|
|
5
|
+
* asks Claude to decide what to do, and acts accordingly:
|
|
6
|
+
* NOTIFY_ONLY — send a Telegram message to the configured chat
|
|
7
|
+
* SPAWN_FOLLOWUP — spawn a follow-up cc-agent job via MCP + notify Telegram
|
|
8
|
+
* SILENT — log and do nothing
|
|
9
|
+
*
|
|
10
|
+
* Controlled via CC_AGENT_EVENTS_ENABLED env var (default: true).
|
|
11
|
+
* Requires CC_AGENT_NOTIFY_CHAT_ID to send Telegram notifications.
|
|
12
|
+
*/
|
|
13
|
+
import { Redis } from "ioredis";
|
|
14
|
+
import TelegramBot from "node-telegram-bot-api";
|
|
15
|
+
import { ClaudeProcess, extractText } from "./claude.js";
|
|
16
|
+
function log(level, ...args) {
|
|
17
|
+
const fn = level === "error"
|
|
18
|
+
? console.error
|
|
19
|
+
: level === "warn"
|
|
20
|
+
? console.warn
|
|
21
|
+
: console.log;
|
|
22
|
+
fn("[cc-agent-events]", ...args);
|
|
23
|
+
}
|
|
24
|
+
export function buildDecisionPrompt(event, last40lines, coordinatorPlan) {
|
|
25
|
+
const scoreStr = event.score !== undefined ? String(event.score) : "n/a";
|
|
26
|
+
const planStr = coordinatorPlan ? JSON.stringify(coordinatorPlan, null, 2) : "none";
|
|
27
|
+
return `A cc-agent job just completed.
|
|
28
|
+
|
|
29
|
+
Job: ${event.title}
|
|
30
|
+
Repo: ${event.repoUrl}
|
|
31
|
+
Status: ${event.status}
|
|
32
|
+
Score: ${scoreStr}
|
|
33
|
+
|
|
34
|
+
Last output + LEARNINGS:
|
|
35
|
+
${last40lines.join("\n")}
|
|
36
|
+
|
|
37
|
+
Coordinator plan for this job (if any):
|
|
38
|
+
${planStr}
|
|
39
|
+
|
|
40
|
+
Decide what to do next:
|
|
41
|
+
1. SPAWN_FOLLOWUP — spawn a follow-up job (provide repo_url and task)
|
|
42
|
+
2. NOTIFY_ONLY — send Telegram message, no spawn needed
|
|
43
|
+
3. SILENT — routine completion, no action
|
|
44
|
+
|
|
45
|
+
Rules:
|
|
46
|
+
- If LEARNINGS has "Recommendations for next agent" with a clear actionable next step → consider SPAWN_FOLLOWUP
|
|
47
|
+
- If coordinator plan has nextStep → SPAWN_FOLLOWUP with that task (prefer coordinator plan over LEARNINGS)
|
|
48
|
+
- Failed jobs → NOTIFY_ONLY always
|
|
49
|
+
- Score < 0.5 → NOTIFY_ONLY
|
|
50
|
+
- Routine/expected completions → SILENT
|
|
51
|
+
|
|
52
|
+
Reply in JSON:
|
|
53
|
+
{
|
|
54
|
+
"action": "SPAWN_FOLLOWUP" | "NOTIFY_ONLY" | "SILENT",
|
|
55
|
+
"message": "brief telegram message (1-2 lines)",
|
|
56
|
+
"followup": { "repo_url": "...", "task": "..." } | null
|
|
57
|
+
}`;
|
|
58
|
+
}
|
|
59
|
+
export function parseDecision(raw) {
|
|
60
|
+
const match = raw.match(/\{[\s\S]*\}/);
|
|
61
|
+
if (!match)
|
|
62
|
+
throw new Error(`No JSON found in Claude response: ${raw.slice(0, 200)}`);
|
|
63
|
+
const parsed = JSON.parse(match[0]);
|
|
64
|
+
if (!["NOTIFY_ONLY", "SPAWN_FOLLOWUP", "SILENT"].includes(parsed.action)) {
|
|
65
|
+
throw new Error(`Unknown action: ${parsed.action}`);
|
|
66
|
+
}
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
function formatSpawnMessage(event, followup, runningCount) {
|
|
70
|
+
const scoreStr = event.score !== undefined ? ` (score: ${event.score})` : "";
|
|
71
|
+
const repoShort = followup.repo_url.replace(/^https?:\/\/github\.com\//, "");
|
|
72
|
+
const lines = [
|
|
73
|
+
`✓ ${event.title} done${scoreStr}`,
|
|
74
|
+
`→ spawned: ${followup.task} (${repoShort})`,
|
|
75
|
+
];
|
|
76
|
+
if (runningCount > 0) {
|
|
77
|
+
lines.push(`${runningCount} jobs running`);
|
|
78
|
+
}
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
function formatFailureMessage(event) {
|
|
82
|
+
const lastLine = event.lastLines[event.lastLines.length - 1] ?? "";
|
|
83
|
+
const repoShort = event.repoUrl.replace(/^https?:\/\/github\.com\//, "");
|
|
84
|
+
return `✗ ${event.title} failed\n${repoShort} — exit 1\nLast line: ${lastLine}`;
|
|
85
|
+
}
|
|
86
|
+
function getChatId() {
|
|
87
|
+
const chatIdStr = process.env.CC_AGENT_NOTIFY_CHAT_ID;
|
|
88
|
+
if (!chatIdStr)
|
|
89
|
+
return null;
|
|
90
|
+
const chatId = Number(chatIdStr);
|
|
91
|
+
if (isNaN(chatId))
|
|
92
|
+
return null;
|
|
93
|
+
return chatId;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Ask Claude to make a decision about a completed job.
|
|
97
|
+
* Returns the raw text response from Claude.
|
|
98
|
+
*/
|
|
99
|
+
export function defaultAskClaude(prompt) {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const token = process.env.CLAUDE_CODE_TOKEN ??
|
|
102
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ??
|
|
103
|
+
process.env.ANTHROPIC_API_KEY;
|
|
104
|
+
if (!token) {
|
|
105
|
+
reject(new Error("No Claude token configured"));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const claude = new ClaudeProcess({ token });
|
|
109
|
+
let output = "";
|
|
110
|
+
const timeout = setTimeout(() => {
|
|
111
|
+
claude.kill();
|
|
112
|
+
reject(new Error("Claude decision timed out after 60s"));
|
|
113
|
+
}, 60_000);
|
|
114
|
+
claude.on("message", (msg) => {
|
|
115
|
+
if (msg.type === "result") {
|
|
116
|
+
const text = extractText(msg);
|
|
117
|
+
if (text)
|
|
118
|
+
output += text;
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
claude.kill();
|
|
121
|
+
resolve(output.trim());
|
|
122
|
+
}
|
|
123
|
+
else if (msg.type === "assistant") {
|
|
124
|
+
const text = extractText(msg);
|
|
125
|
+
if (text)
|
|
126
|
+
output += text;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
claude.on("error", (err) => {
|
|
130
|
+
clearTimeout(timeout);
|
|
131
|
+
reject(err);
|
|
132
|
+
});
|
|
133
|
+
claude.on("exit", (code) => {
|
|
134
|
+
clearTimeout(timeout);
|
|
135
|
+
if (!output) {
|
|
136
|
+
reject(new Error(`Claude exited with code ${code} and no output`));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
resolve(output.trim());
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
claude.sendPrompt(prompt);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export async function defaultSendTelegramMessage(chatId, text) {
|
|
146
|
+
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
147
|
+
if (!token)
|
|
148
|
+
throw new Error("TELEGRAM_BOT_TOKEN not set");
|
|
149
|
+
const tg = new TelegramBot(token, { polling: false });
|
|
150
|
+
await tg.sendMessage(chatId, text);
|
|
151
|
+
}
|
|
152
|
+
export async function defaultSpawnFollowupAgent(repoUrl, task) {
|
|
153
|
+
const token = process.env.CLAUDE_CODE_TOKEN ??
|
|
154
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ??
|
|
155
|
+
process.env.ANTHROPIC_API_KEY;
|
|
156
|
+
const prompt = `Use the spawn_agent MCP tool to start a new cc-agent job with these parameters:
|
|
157
|
+
repo_url: ${repoUrl}
|
|
158
|
+
task: ${task}
|
|
159
|
+
|
|
160
|
+
Call the spawn_agent tool now with these exact parameters. Report the job ID when done.`;
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const claude = new ClaudeProcess({ token: token ?? undefined });
|
|
163
|
+
const timeout = setTimeout(() => {
|
|
164
|
+
log("warn", "spawnFollowupAgent: timed out");
|
|
165
|
+
claude.kill();
|
|
166
|
+
resolve();
|
|
167
|
+
}, 120_000);
|
|
168
|
+
claude.on("message", (msg) => {
|
|
169
|
+
if (msg.type === "result") {
|
|
170
|
+
clearTimeout(timeout);
|
|
171
|
+
claude.kill();
|
|
172
|
+
resolve();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
claude.on("error", (err) => {
|
|
176
|
+
log("error", "spawnFollowupAgent error:", err.message);
|
|
177
|
+
clearTimeout(timeout);
|
|
178
|
+
resolve();
|
|
179
|
+
});
|
|
180
|
+
claude.on("exit", () => {
|
|
181
|
+
clearTimeout(timeout);
|
|
182
|
+
resolve();
|
|
183
|
+
});
|
|
184
|
+
claude.sendPrompt(prompt);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function makeRedisClient() {
|
|
188
|
+
return new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
|
|
189
|
+
lazyConnect: true,
|
|
190
|
+
enableOfflineQueue: false,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
export async function defaultReadJobOutput(jobId) {
|
|
194
|
+
const redis = makeRedisClient();
|
|
195
|
+
try {
|
|
196
|
+
await redis.connect();
|
|
197
|
+
const lines = await redis.lrange(`cca:job:${jobId}:output`, -40, -1);
|
|
198
|
+
return lines;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
try {
|
|
202
|
+
redis.disconnect();
|
|
203
|
+
}
|
|
204
|
+
catch { }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export async function defaultReadCoordinatorPlan(jobId) {
|
|
208
|
+
const redis = makeRedisClient();
|
|
209
|
+
try {
|
|
210
|
+
await redis.connect();
|
|
211
|
+
const raw = await redis.get(`cca:coordinator:plan:${jobId}`);
|
|
212
|
+
if (!raw)
|
|
213
|
+
return null;
|
|
214
|
+
return JSON.parse(raw);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
try {
|
|
218
|
+
redis.disconnect();
|
|
219
|
+
}
|
|
220
|
+
catch { }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
export async function defaultGetRunningJobCount() {
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Write a coordinator plan for a job, so cc-tg knows what follow-up to spawn.
|
|
228
|
+
* Call this when spawning a job that has a planned follow-up.
|
|
229
|
+
* TTL: 7 days.
|
|
230
|
+
*/
|
|
231
|
+
export async function writeCoordinatorPlan(jobId, plan) {
|
|
232
|
+
const redis = makeRedisClient();
|
|
233
|
+
try {
|
|
234
|
+
await redis.connect();
|
|
235
|
+
const key = `cca:coordinator:plan:${jobId}`;
|
|
236
|
+
const ttlSeconds = 7 * 24 * 60 * 60; // 7 days
|
|
237
|
+
await redis.set(key, JSON.stringify(plan), "EX", ttlSeconds);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
try {
|
|
241
|
+
redis.disconnect();
|
|
242
|
+
}
|
|
243
|
+
catch { }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Handle a single job event message from Redis pub/sub.
|
|
248
|
+
* Exported for testability — production code passes defaultDeps.
|
|
249
|
+
*/
|
|
250
|
+
export async function handleJobEvent(message, deps) {
|
|
251
|
+
let event;
|
|
252
|
+
try {
|
|
253
|
+
event = JSON.parse(message);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
log("error", "Failed to parse job event:", err.message);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Only act on terminal states
|
|
260
|
+
if (event.status !== "done" && event.status !== "failed") {
|
|
261
|
+
log("info", `Ignoring ${event.status} event for job ${event.jobId}`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
log("info", `Processing ${event.status} event for job: ${event.title} (${event.jobId})`);
|
|
265
|
+
// Read job output from Redis (fall back to event.lastLines on error)
|
|
266
|
+
let last40lines = event.lastLines;
|
|
267
|
+
try {
|
|
268
|
+
const lines = await deps.readJobOutput(event.jobId);
|
|
269
|
+
if (lines.length > 0)
|
|
270
|
+
last40lines = lines;
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
log("warn", "Failed to read job output, using event.lastLines:", err.message);
|
|
274
|
+
}
|
|
275
|
+
// Read coordinator plan from Redis (fall back to null on error)
|
|
276
|
+
let coordinatorPlan = null;
|
|
277
|
+
try {
|
|
278
|
+
coordinatorPlan = await deps.readCoordinatorPlan(event.jobId);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
log("warn", "Failed to read coordinator plan:", err.message);
|
|
282
|
+
}
|
|
283
|
+
let decision;
|
|
284
|
+
try {
|
|
285
|
+
const rawResponse = await deps.askClaude(buildDecisionPrompt(event, last40lines, coordinatorPlan));
|
|
286
|
+
decision = parseDecision(rawResponse);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
log("error", "Claude decision failed, falling back to NOTIFY_ONLY:", err.message);
|
|
290
|
+
const fallbackMsg = event.status === "failed"
|
|
291
|
+
? formatFailureMessage(event)
|
|
292
|
+
: `Job completed: ${event.title}`;
|
|
293
|
+
decision = { action: "NOTIFY_ONLY", message: fallbackMsg };
|
|
294
|
+
}
|
|
295
|
+
log("info", `Decision: ${decision.action} for job ${event.jobId}`);
|
|
296
|
+
const chatId = getChatId();
|
|
297
|
+
try {
|
|
298
|
+
if (decision.action === "NOTIFY_ONLY") {
|
|
299
|
+
if (!chatId) {
|
|
300
|
+
log("warn", "NOTIFY_ONLY: CC_AGENT_NOTIFY_CHAT_ID not set, skipping notification");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const msg = decision.message
|
|
304
|
+
?? (event.status === "failed" ? formatFailureMessage(event) : `Job completed: ${event.title}`);
|
|
305
|
+
await deps.sendTelegramMessage(chatId, msg);
|
|
306
|
+
}
|
|
307
|
+
else if (decision.action === "SPAWN_FOLLOWUP") {
|
|
308
|
+
if (!decision.followup) {
|
|
309
|
+
log("warn", "SPAWN_FOLLOWUP: no followup details in response");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
await deps.spawnFollowupAgent(decision.followup.repo_url, decision.followup.task);
|
|
313
|
+
// Send Telegram notification about the spawn
|
|
314
|
+
if (chatId) {
|
|
315
|
+
let runningCount = 0;
|
|
316
|
+
try {
|
|
317
|
+
runningCount = await deps.getRunningJobCount();
|
|
318
|
+
}
|
|
319
|
+
catch { }
|
|
320
|
+
const spawnMsg = formatSpawnMessage(event, decision.followup, runningCount);
|
|
321
|
+
await deps.sendTelegramMessage(chatId, spawnMsg);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
// SILENT — log only
|
|
326
|
+
log("info", `SILENT: no action taken for job ${event.jobId}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
log("error", `Action ${decision.action} failed:`, err.message);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function makeDefaultDeps() {
|
|
334
|
+
return {
|
|
335
|
+
askClaude: defaultAskClaude,
|
|
336
|
+
sendTelegramMessage: defaultSendTelegramMessage,
|
|
337
|
+
spawnFollowupAgent: defaultSpawnFollowupAgent,
|
|
338
|
+
readJobOutput: defaultReadJobOutput,
|
|
339
|
+
readCoordinatorPlan: defaultReadCoordinatorPlan,
|
|
340
|
+
getRunningJobCount: defaultGetRunningJobCount,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
let subscriberClient = null;
|
|
344
|
+
/**
|
|
345
|
+
* Connect to Redis and subscribe to cca:events.
|
|
346
|
+
* Reconnects automatically on disconnect.
|
|
347
|
+
* Call once at startup.
|
|
348
|
+
*/
|
|
349
|
+
export async function connectEventSubscriber() {
|
|
350
|
+
if (process.env.CC_AGENT_EVENTS_ENABLED === "false") {
|
|
351
|
+
log("info", "CC_AGENT_EVENTS_ENABLED=false, skipping subscriber");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
await connectWithBackoff(0);
|
|
355
|
+
}
|
|
356
|
+
async function connectWithBackoff(attempt) {
|
|
357
|
+
const delay = Math.min(5_000 * Math.pow(2, attempt), 60_000);
|
|
358
|
+
const sub = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
|
|
359
|
+
lazyConnect: true,
|
|
360
|
+
enableOfflineQueue: false,
|
|
361
|
+
});
|
|
362
|
+
subscriberClient = sub;
|
|
363
|
+
sub.on("error", (err) => {
|
|
364
|
+
log("warn", "subscriber error, reconnecting...", err.message);
|
|
365
|
+
try {
|
|
366
|
+
sub.disconnect();
|
|
367
|
+
}
|
|
368
|
+
catch { }
|
|
369
|
+
setTimeout(() => connectWithBackoff(0), 5_000);
|
|
370
|
+
});
|
|
371
|
+
try {
|
|
372
|
+
await sub.connect();
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
log("warn", `Redis connect failed (attempt ${attempt}), retrying in ${delay}ms:`, err.message);
|
|
376
|
+
try {
|
|
377
|
+
sub.disconnect();
|
|
378
|
+
}
|
|
379
|
+
catch { }
|
|
380
|
+
setTimeout(() => connectWithBackoff(attempt + 1), delay);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const deps = makeDefaultDeps();
|
|
384
|
+
sub.on("message", (channel, message) => {
|
|
385
|
+
if (channel !== "cca:events")
|
|
386
|
+
return;
|
|
387
|
+
handleJobEvent(message, deps).catch((err) => {
|
|
388
|
+
log("error", "handleJobEvent uncaught:", err.message);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
try {
|
|
392
|
+
await sub.subscribe("cca:events");
|
|
393
|
+
log("info", "Subscribed to cca:events");
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
log("warn", "subscribe failed, retrying...", err.message);
|
|
397
|
+
try {
|
|
398
|
+
sub.disconnect();
|
|
399
|
+
}
|
|
400
|
+
catch { }
|
|
401
|
+
setTimeout(() => connectWithBackoff(attempt + 1), delay);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const cleanup = async () => {
|
|
405
|
+
log("info", "SIGTERM received, shutting down event subscriber...");
|
|
406
|
+
try {
|
|
407
|
+
await sub.unsubscribe("cca:events");
|
|
408
|
+
sub.disconnect();
|
|
409
|
+
}
|
|
410
|
+
catch { }
|
|
411
|
+
};
|
|
412
|
+
process.once("SIGTERM", () => { cleanup().catch(() => { }); });
|
|
413
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { CcTgBot } from "./bot.js";
|
|
|
24
24
|
import { loadTokens } from "./tokens.js";
|
|
25
25
|
import { Registry, startControlServer } from "@gonzih/agent-ops";
|
|
26
26
|
import { Redis } from "ioredis";
|
|
27
|
+
import { connectEventSubscriber } from "./cc-agent-events.js";
|
|
27
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
29
|
const __dirname = dirname(__filename);
|
|
29
30
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
@@ -143,6 +144,10 @@ if (process.env.CC_AGENT_OPS_PORT) {
|
|
|
143
144
|
});
|
|
144
145
|
console.log(`[ops] control server on port ${process.env.CC_AGENT_OPS_PORT}`);
|
|
145
146
|
}
|
|
147
|
+
// cc-agent event subscriber — watches Redis cca:events for job completions
|
|
148
|
+
connectEventSubscriber().catch((err) => {
|
|
149
|
+
console.error("[cc-agent-events] startup error:", err.message);
|
|
150
|
+
});
|
|
146
151
|
process.on("SIGINT", () => {
|
|
147
152
|
console.log("\nShutting down...");
|
|
148
153
|
bot.stop();
|