@gonzih/cc-tg 0.6.4 → 0.6.5
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 +11 -0
- package/dist/cc-agent-events.js +156 -2
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Controlled via CC_AGENT_EVENTS_ENABLED env var (default: true).
|
|
11
11
|
* Requires CC_AGENT_NOTIFY_CHAT_ID to send Telegram notifications.
|
|
12
12
|
*/
|
|
13
|
+
import { Redis } from "ioredis";
|
|
13
14
|
export interface JobEvent {
|
|
14
15
|
jobId: string;
|
|
15
16
|
status: "done" | "failed" | "interrupted" | "running" | "cancelled";
|
|
@@ -79,6 +80,16 @@ export declare function writeCoordinatorPlan(jobId: string, plan: {
|
|
|
79
80
|
* Exported for testability — production code passes defaultDeps.
|
|
80
81
|
*/
|
|
81
82
|
export declare function handleJobEvent(message: string, deps: HandlerDeps): Promise<void>;
|
|
83
|
+
/** Parse flat key-value field array from a Redis Stream entry into a record. */
|
|
84
|
+
export declare function parseStreamFields(fields: string[]): Record<string, string>;
|
|
85
|
+
/** Convert stream entry fields to a JobEvent JSON string for handleJobEvent. */
|
|
86
|
+
export declare function streamEntryToMessage(fields: Record<string, string>): string | null;
|
|
87
|
+
/**
|
|
88
|
+
* Replay events from the Redis Stream that were missed since last-seen ID.
|
|
89
|
+
* Uses `cca:event-stream:last-id:{botName}` in Redis to track position.
|
|
90
|
+
* Exported for testability — pass a real or mock Redis instance.
|
|
91
|
+
*/
|
|
92
|
+
export declare function replayStreamEvents(redis: Redis, deps: HandlerDeps, botName?: string): Promise<void>;
|
|
82
93
|
/**
|
|
83
94
|
* Connect to Redis and subscribe to cca:events.
|
|
84
95
|
* Reconnects automatically on disconnect.
|
package/dist/cc-agent-events.js
CHANGED
|
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
|
|
|
14
14
|
import { join } from "path";
|
|
15
15
|
import { Redis } from "ioredis";
|
|
16
16
|
import TelegramBot from "node-telegram-bot-api";
|
|
17
|
+
const STREAM_KEY = "cca:event-stream";
|
|
17
18
|
import { ClaudeProcess, extractText } from "./claude.js";
|
|
18
19
|
function log(level, ...args) {
|
|
19
20
|
const fn = level === "error"
|
|
@@ -315,6 +316,39 @@ export async function handleJobEvent(message, deps) {
|
|
|
315
316
|
catch (err) {
|
|
316
317
|
log("warn", "Failed to read coordinator plan:", err.message);
|
|
317
318
|
}
|
|
319
|
+
// Fast path: coordinator plan has explicit next step — spawn directly, no Claude needed
|
|
320
|
+
// This eliminates JSON truncation issues when Claude regenerates long task strings.
|
|
321
|
+
if (coordinatorPlan?.nextStep) {
|
|
322
|
+
log("info", `Fast path: coordinator plan nextStep found for job ${event.jobId}`);
|
|
323
|
+
const { repo_url, task } = coordinatorPlan.nextStep;
|
|
324
|
+
let fpChatIds = [];
|
|
325
|
+
try {
|
|
326
|
+
fpChatIds = await deps.getActiveChatIds();
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
log("warn", "Fast path: failed to get active chat IDs:", err.message);
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
await deps.spawnFollowupAgent(repo_url, task);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
log("error", "Fast path: spawnFollowupAgent failed:", err.message);
|
|
336
|
+
}
|
|
337
|
+
if (fpChatIds.length > 0) {
|
|
338
|
+
const scoreStr = event.score !== undefined ? ` (score: ${event.score})` : "";
|
|
339
|
+
const repoShort = repo_url.split("/").pop() ?? repo_url;
|
|
340
|
+
const msg = `✓ ${event.title} done${scoreStr}\n→ spawned: ${repoShort}`;
|
|
341
|
+
for (const chatId of fpChatIds) {
|
|
342
|
+
try {
|
|
343
|
+
await deps.sendTelegramMessage(chatId, msg);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
log("error", "Fast path: sendTelegramMessage failed:", err.message);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
318
352
|
let decision;
|
|
319
353
|
let rawResponse = "";
|
|
320
354
|
try {
|
|
@@ -379,6 +413,80 @@ export async function handleJobEvent(message, deps) {
|
|
|
379
413
|
log("error", `Action ${decision.action} failed:`, err.message);
|
|
380
414
|
}
|
|
381
415
|
}
|
|
416
|
+
/** Parse flat key-value field array from a Redis Stream entry into a record. */
|
|
417
|
+
export function parseStreamFields(fields) {
|
|
418
|
+
const obj = {};
|
|
419
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
420
|
+
obj[fields[i]] = fields[i + 1];
|
|
421
|
+
}
|
|
422
|
+
return obj;
|
|
423
|
+
}
|
|
424
|
+
/** Convert stream entry fields to a JobEvent JSON string for handleJobEvent. */
|
|
425
|
+
export function streamEntryToMessage(fields) {
|
|
426
|
+
try {
|
|
427
|
+
const score = fields["score"] !== undefined && fields["score"] !== ""
|
|
428
|
+
? Number(fields["score"])
|
|
429
|
+
: undefined;
|
|
430
|
+
const event = {
|
|
431
|
+
jobId: fields["jobId"] ?? "",
|
|
432
|
+
status: (fields["status"] ?? "done"),
|
|
433
|
+
title: fields["title"] ?? "",
|
|
434
|
+
repoUrl: fields["repoUrl"] ?? "",
|
|
435
|
+
lastLines: JSON.parse(fields["lastLines"] ?? "[]"),
|
|
436
|
+
score,
|
|
437
|
+
timestamp: Number(fields["timestamp"] ?? Date.now()),
|
|
438
|
+
};
|
|
439
|
+
return JSON.stringify(event);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Replay events from the Redis Stream that were missed since last-seen ID.
|
|
447
|
+
* Uses `cca:event-stream:last-id:{botName}` in Redis to track position.
|
|
448
|
+
* Exported for testability — pass a real or mock Redis instance.
|
|
449
|
+
*/
|
|
450
|
+
export async function replayStreamEvents(redis, deps, botName) {
|
|
451
|
+
const name = botName ?? (process.env.CC_TG_BOT_NAME ?? "cc-tg");
|
|
452
|
+
const lastIdKey = `cca:event-stream:last-id:${name}`;
|
|
453
|
+
let lastId = "0";
|
|
454
|
+
try {
|
|
455
|
+
lastId = (await redis.get(lastIdKey)) ?? "0";
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
log("warn", "replayStreamEvents: failed to read last-id:", err.message);
|
|
459
|
+
}
|
|
460
|
+
let results = null;
|
|
461
|
+
try {
|
|
462
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
463
|
+
results = (await redis.xread("COUNT", 20, "STREAMS", STREAM_KEY, lastId));
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
log("warn", "replayStreamEvents: xread failed:", err.message);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (!results || results.length === 0)
|
|
470
|
+
return;
|
|
471
|
+
log("info", `Replaying missed stream events from last-id=${lastId}`);
|
|
472
|
+
for (const [, entries] of results) {
|
|
473
|
+
for (const [id, fields] of entries) {
|
|
474
|
+
const message = streamEntryToMessage(parseStreamFields(fields));
|
|
475
|
+
if (message) {
|
|
476
|
+
await handleJobEvent(message, deps).catch((err) => {
|
|
477
|
+
log("error", `replayStreamEvents: handleJobEvent error for entry ${id}:`, err.message);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
await redis.set(lastIdKey, id);
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
log("warn", "replayStreamEvents: failed to update last-id:", err.message);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
log("info", "Stream replay complete.");
|
|
489
|
+
}
|
|
382
490
|
function makeDefaultDeps() {
|
|
383
491
|
return {
|
|
384
492
|
askClaude: defaultAskClaude,
|
|
@@ -405,10 +513,19 @@ export async function connectEventSubscriber() {
|
|
|
405
513
|
}
|
|
406
514
|
async function connectWithBackoff(attempt) {
|
|
407
515
|
const delay = Math.min(5_000 * Math.pow(2, attempt), 60_000);
|
|
408
|
-
const
|
|
516
|
+
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
|
|
517
|
+
const botName = process.env.CC_TG_BOT_NAME ?? "cc-tg";
|
|
518
|
+
const lastIdKey = `cca:event-stream:last-id:${botName}`;
|
|
519
|
+
// Pub/sub subscriber client — enters subscriber mode after subscribe()
|
|
520
|
+
const sub = new Redis(redisUrl, {
|
|
409
521
|
lazyConnect: true,
|
|
410
522
|
enableOfflineQueue: false,
|
|
411
523
|
});
|
|
524
|
+
// Regular command client — stays in normal mode for xread/get/set
|
|
525
|
+
const reg = new Redis(redisUrl, {
|
|
526
|
+
lazyConnect: true,
|
|
527
|
+
enableOfflineQueue: true,
|
|
528
|
+
});
|
|
412
529
|
subscriberClient = sub;
|
|
413
530
|
sub.on("error", (err) => {
|
|
414
531
|
log("warn", "subscriber error, reconnecting...", err.message);
|
|
@@ -416,8 +533,15 @@ async function connectWithBackoff(attempt) {
|
|
|
416
533
|
sub.disconnect();
|
|
417
534
|
}
|
|
418
535
|
catch { }
|
|
536
|
+
try {
|
|
537
|
+
reg.disconnect();
|
|
538
|
+
}
|
|
539
|
+
catch { }
|
|
419
540
|
setTimeout(() => connectWithBackoff(0), 5_000);
|
|
420
541
|
});
|
|
542
|
+
reg.on("error", (err) => {
|
|
543
|
+
log("warn", "regular client error (non-fatal):", err.message);
|
|
544
|
+
});
|
|
421
545
|
try {
|
|
422
546
|
await sub.connect();
|
|
423
547
|
}
|
|
@@ -430,11 +554,36 @@ async function connectWithBackoff(attempt) {
|
|
|
430
554
|
setTimeout(() => connectWithBackoff(attempt + 1), delay);
|
|
431
555
|
return;
|
|
432
556
|
}
|
|
557
|
+
// Connect regular client (best-effort — stream replay is non-critical)
|
|
558
|
+
try {
|
|
559
|
+
await reg.connect();
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
log("warn", "Regular Redis client connect failed (stream replay skipped):", err.message);
|
|
563
|
+
}
|
|
433
564
|
const deps = makeDefaultDeps();
|
|
565
|
+
// Replay events missed during downtime, then mark current time as last-id
|
|
566
|
+
// Must happen BEFORE sub.subscribe() because subscribe() puts sub in subscriber mode
|
|
567
|
+
try {
|
|
568
|
+
await replayStreamEvents(reg, deps, botName);
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
log("warn", "Stream replay failed, continuing:", err.message);
|
|
572
|
+
}
|
|
573
|
+
// Mark current timestamp so next restart only replays events after now
|
|
574
|
+
try {
|
|
575
|
+
await reg.set(lastIdKey, `${Date.now()}-0`);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// Non-fatal
|
|
579
|
+
}
|
|
434
580
|
sub.on("message", (channel, message) => {
|
|
435
581
|
if (channel !== "cca:events")
|
|
436
582
|
return;
|
|
437
|
-
handleJobEvent(message, deps).
|
|
583
|
+
handleJobEvent(message, deps).then(() => {
|
|
584
|
+
// Advance stream last-id so next restart doesn't re-replay this event
|
|
585
|
+
reg.set(lastIdKey, `${Date.now()}-0`).catch(() => { });
|
|
586
|
+
}).catch((err) => {
|
|
438
587
|
log("error", "handleJobEvent uncaught:", err.message);
|
|
439
588
|
});
|
|
440
589
|
});
|
|
@@ -448,6 +597,10 @@ async function connectWithBackoff(attempt) {
|
|
|
448
597
|
sub.disconnect();
|
|
449
598
|
}
|
|
450
599
|
catch { }
|
|
600
|
+
try {
|
|
601
|
+
reg.disconnect();
|
|
602
|
+
}
|
|
603
|
+
catch { }
|
|
451
604
|
setTimeout(() => connectWithBackoff(attempt + 1), delay);
|
|
452
605
|
return;
|
|
453
606
|
}
|
|
@@ -456,6 +609,7 @@ async function connectWithBackoff(attempt) {
|
|
|
456
609
|
try {
|
|
457
610
|
await sub.unsubscribe("cca:events");
|
|
458
611
|
sub.disconnect();
|
|
612
|
+
reg.disconnect();
|
|
459
613
|
}
|
|
460
614
|
catch { }
|
|
461
615
|
};
|