glab-agent 0.2.6 → 0.2.8
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/package.json +1 -1
- package/src/local-agent/agent-config.ts +9 -1
- package/src/local-agent/agent-runner.ts +44 -0
- package/src/local-agent/claude-runner.ts +189 -2
- package/src/local-agent/codex-runner.ts +107 -2
- package/src/local-agent/feishu-client.ts +226 -0
- package/src/local-agent/gitlab-glab-client.ts +82 -2
- package/src/local-agent/notifier.ts +71 -8
- package/src/local-agent/watcher.ts +496 -12
|
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
import { AgentRunnerError, type AgentRunner } from "./agent-runner.js";
|
|
5
|
+
import { AgentRunnerError, type AgentRunner, type SpawnedRun } from "./agent-runner.js";
|
|
6
6
|
import type { AgentDefinition, AgentLabelConfig } from "./agent-config.js";
|
|
7
7
|
import {
|
|
8
8
|
loadAgentByName,
|
|
@@ -24,9 +24,9 @@ import { ClaudeRunner } from "./claude-runner.js";
|
|
|
24
24
|
import { CodexRunner } from "./codex-runner.js";
|
|
25
25
|
import { GitlabGlabClient } from "./gitlab-glab-client.js";
|
|
26
26
|
import type { GitlabClient, GitlabIssue, GitlabTodoItem } from "./gitlab-glab-client.js";
|
|
27
|
-
import { FileStateStore, rememberIds, appendRunHistory } from "./state-store.js";
|
|
27
|
+
import { FileStateStore, rememberIds, appendRunHistory, addActiveRun, removeActiveRun } from "./state-store.js";
|
|
28
28
|
import type { ActiveRunState, LocalAgentState, StateStore } from "./state-store.js";
|
|
29
|
-
import { notifyAccepted, notifyCompleted, notifyFailed, sendWebhook } from "./notifier.js";
|
|
29
|
+
import { notifyAccepted, notifyCompleted, notifyFailed, notifyQueued, sendWebhook } from "./notifier.js";
|
|
30
30
|
import { appendMetric, metricsPath } from "./metrics.js";
|
|
31
31
|
import { rotateIfNeeded } from "./log-rotate.js";
|
|
32
32
|
|
|
@@ -36,6 +36,7 @@ import type { WorktreeInfo } from "./worktree-manager.js";
|
|
|
36
36
|
import { injectSkillFiles } from "./skill-inject.js";
|
|
37
37
|
import { startHealthServer } from "./health-server.js";
|
|
38
38
|
import type { HealthStatus } from "./health-server.js";
|
|
39
|
+
import { FeishuClient, buildProgressCard } from "./feishu-client.js";
|
|
39
40
|
|
|
40
41
|
const STATUS_LABELS_DEFAULT = [
|
|
41
42
|
DEFAULT_LABELS.backlog,
|
|
@@ -47,6 +48,18 @@ const STATUS_LABELS_DEFAULT = [
|
|
|
47
48
|
];
|
|
48
49
|
const ACCEPTED_TODO_ACTIONS = new Set(["mentioned", "directly_addressed"]);
|
|
49
50
|
|
|
51
|
+
/** In-memory registry of spawned runs (only used when concurrency > 1) */
|
|
52
|
+
const spawnedRuns = new Map<number, { // keyed by issueIid
|
|
53
|
+
run: SpawnedRun;
|
|
54
|
+
issue: GitlabIssue;
|
|
55
|
+
todo: GitlabTodoItem;
|
|
56
|
+
worktree: WorktreeInfo;
|
|
57
|
+
startedAt: string;
|
|
58
|
+
feishuMessageId?: string;
|
|
59
|
+
feishuOpenId?: string;
|
|
60
|
+
progressLines?: string[];
|
|
61
|
+
}>();
|
|
62
|
+
|
|
50
63
|
export interface LocalAgentConfig {
|
|
51
64
|
gitlabHost: string;
|
|
52
65
|
gitlabProjectId?: number;
|
|
@@ -63,6 +76,9 @@ export interface LocalAgentConfig {
|
|
|
63
76
|
agentDefinition?: AgentDefinition;
|
|
64
77
|
skillsDir?: string;
|
|
65
78
|
webhookUrl?: string;
|
|
79
|
+
feishuAppId?: string;
|
|
80
|
+
feishuAppSecret?: string;
|
|
81
|
+
feishuEmailDomain?: string;
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
export interface ConfigResolutionOptions {
|
|
@@ -85,6 +101,8 @@ export interface WatcherDependencies {
|
|
|
85
101
|
pid?: number;
|
|
86
102
|
/** Override close-detection interval for testing (default: 60000ms) */
|
|
87
103
|
issueCloseCheckIntervalMs?: number;
|
|
104
|
+
/** Feishu app client for targeted personal notifications */
|
|
105
|
+
feishuClient?: import("./feishu-client.js").FeishuClient;
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
export interface WatcherCycleResult {
|
|
@@ -284,6 +302,42 @@ export function formatBusyNote(
|
|
|
284
302
|
return lines.join("\n");
|
|
285
303
|
}
|
|
286
304
|
|
|
305
|
+
/** Add emoji reaction to the todo's triggering note. Silent on failure. */
|
|
306
|
+
async function addReactionToTodo(
|
|
307
|
+
client: GitlabClient,
|
|
308
|
+
todo: GitlabTodoItem,
|
|
309
|
+
emojiName: string,
|
|
310
|
+
logger?: Logger | typeof console
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
if (!todo.noteId || !todo.projectId) return;
|
|
313
|
+
try {
|
|
314
|
+
await client.addAwardEmoji(todo.projectId, todo.issueIid, todo.noteId, emojiName);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
(logger as Logger | undefined)?.info?.(`Emoji reaction (${emojiName}) skipped: ${String(error).slice(0, 80)}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Remove a specific emoji from the todo's triggering note, replace with another. Silent on failure. */
|
|
321
|
+
async function replaceReactionOnTodo(
|
|
322
|
+
client: GitlabClient,
|
|
323
|
+
todo: GitlabTodoItem,
|
|
324
|
+
removeEmoji: string,
|
|
325
|
+
addEmoji: string,
|
|
326
|
+
logger?: Logger | typeof console
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
if (!todo.noteId || !todo.projectId) return;
|
|
329
|
+
try {
|
|
330
|
+
const emojis = await client.listAwardEmoji(todo.projectId, todo.issueIid, todo.noteId);
|
|
331
|
+
const toRemove = emojis.find(e => e.name === removeEmoji);
|
|
332
|
+
if (toRemove) {
|
|
333
|
+
await client.removeAwardEmoji(todo.projectId, todo.issueIid, todo.noteId, toRemove.id);
|
|
334
|
+
}
|
|
335
|
+
await client.addAwardEmoji(todo.projectId, todo.issueIid, todo.noteId, addEmoji);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
(logger as Logger | undefined)?.info?.(`Emoji replace (${removeEmoji}→${addEmoji}) skipped: ${String(error).slice(0, 80)}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
287
341
|
export async function runWatcherCycle(
|
|
288
342
|
config: LocalAgentConfig,
|
|
289
343
|
dependencies: WatcherDependencies
|
|
@@ -485,8 +539,35 @@ export async function runWatcherCycle(
|
|
|
485
539
|
}
|
|
486
540
|
|
|
487
541
|
await updateAgentUserStatus(dependencies, "busy", `#${issue.iid}`);
|
|
488
|
-
await notifyAccepted(config.agentDefinition?.name ?? "agent", issue.iid, issue.title, config.webhookUrl);
|
|
542
|
+
await notifyAccepted(config.agentDefinition?.name ?? "agent", issue.iid, issue.title, issue.webUrl || undefined, config.webhookUrl);
|
|
543
|
+
await addReactionToTodo(dependencies.gitlabClient, candidate, "eyes", logger);
|
|
489
544
|
logger.info(`Starting agent for issue #${issue.iid}: "${issue.title}" branch=${worktree.branch}`);
|
|
545
|
+
|
|
546
|
+
// Feishu: send accepted card to the @mention author (single-task path)
|
|
547
|
+
let singleTaskFeishuMsgId: string | undefined;
|
|
548
|
+
let singleTaskFeishuOpenId: string | undefined;
|
|
549
|
+
if (dependencies.feishuClient && candidate.authorUsername && config.feishuEmailDomain) {
|
|
550
|
+
try {
|
|
551
|
+
const email = `${candidate.authorUsername}@${config.feishuEmailDomain}`;
|
|
552
|
+
{
|
|
553
|
+
const openId = await dependencies.feishuClient.getUserByEmail(email);
|
|
554
|
+
if (openId) {
|
|
555
|
+
const card = buildProgressCard({
|
|
556
|
+
agentName: config.agentDefinition?.name ?? "agent",
|
|
557
|
+
issueIid: issue.iid,
|
|
558
|
+
issueTitle: issue.title,
|
|
559
|
+
issueUrl: issue.webUrl || undefined,
|
|
560
|
+
status: "accepted",
|
|
561
|
+
});
|
|
562
|
+
singleTaskFeishuMsgId = await dependencies.feishuClient.sendCard(openId, card);
|
|
563
|
+
singleTaskFeishuOpenId = openId;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch (e) {
|
|
567
|
+
logger.info?.(`Feishu card send skipped: ${String(e).slice(0, 100)}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
490
571
|
const agentStartTime = Date.now();
|
|
491
572
|
|
|
492
573
|
const closeCheckIntervalMs = dependencies.issueCloseCheckIntervalMs ?? 60_000;
|
|
@@ -523,6 +604,20 @@ export async function runWatcherCycle(
|
|
|
523
604
|
`⚠️ Issue 在处理过程中被关闭,agent 已停止工作。`
|
|
524
605
|
);
|
|
525
606
|
await updateAgentUserStatus(dependencies, "idle", undefined, config);
|
|
607
|
+
// Feishu: update card to failed state (issue closed)
|
|
608
|
+
if (dependencies.feishuClient && singleTaskFeishuMsgId) {
|
|
609
|
+
const card = buildProgressCard({
|
|
610
|
+
agentName: config.agentDefinition?.name ?? "agent",
|
|
611
|
+
issueIid: issue.iid,
|
|
612
|
+
issueTitle: issue.title,
|
|
613
|
+
issueUrl: issue.webUrl || undefined,
|
|
614
|
+
status: "failed",
|
|
615
|
+
summary: summary.slice(0, 300),
|
|
616
|
+
duration: `${durationSec}s`,
|
|
617
|
+
});
|
|
618
|
+
dependencies.feishuClient.updateCard(singleTaskFeishuMsgId, card).catch(() => {});
|
|
619
|
+
}
|
|
620
|
+
await replaceReactionOnTodo(dependencies.gitlabClient, candidate, "eyes", "x", logger);
|
|
526
621
|
await finalizeTodo(config, dependencies, state, candidate, issue, {
|
|
527
622
|
status: "failed",
|
|
528
623
|
summary,
|
|
@@ -544,7 +639,24 @@ export async function runWatcherCycle(
|
|
|
544
639
|
logger.info(`Agent completed issue #${issue.iid} in ${durationSec}s. Summary: ${result.summary.slice(0, 200)}`);
|
|
545
640
|
|
|
546
641
|
// Agent manages labels and notes itself via glab api — watcher only handles lifecycle
|
|
547
|
-
|
|
642
|
+
// Look up MR URL for notification
|
|
643
|
+
const mr = issue.projectId ? await dependencies.gitlabClient.findMergeRequestByBranch(issue.projectId, worktree.branch).catch(() => undefined) : undefined;
|
|
644
|
+
await notifyCompleted(config.agentDefinition?.name ?? "agent", issue.iid, issue.title, mr?.webUrl || issue.webUrl || undefined, config.webhookUrl);
|
|
645
|
+
// Feishu: update card to completed state
|
|
646
|
+
if (dependencies.feishuClient && singleTaskFeishuMsgId) {
|
|
647
|
+
const card = buildProgressCard({
|
|
648
|
+
agentName: config.agentDefinition?.name ?? "agent",
|
|
649
|
+
issueIid: issue.iid,
|
|
650
|
+
issueTitle: issue.title,
|
|
651
|
+
issueUrl: issue.webUrl || undefined,
|
|
652
|
+
mrUrl: mr?.webUrl,
|
|
653
|
+
status: "completed",
|
|
654
|
+
summary: result.summary.slice(0, 300),
|
|
655
|
+
duration: `${durationSec}s`,
|
|
656
|
+
});
|
|
657
|
+
dependencies.feishuClient.updateCard(singleTaskFeishuMsgId, card).catch(() => {});
|
|
658
|
+
}
|
|
659
|
+
await replaceReactionOnTodo(dependencies.gitlabClient, candidate, "eyes", "white_check_mark", logger);
|
|
548
660
|
await updateAgentUserStatus(dependencies, "idle", undefined, config);
|
|
549
661
|
await finalizeTodo(config, dependencies, state, candidate, issue, {
|
|
550
662
|
status: "completed",
|
|
@@ -578,6 +690,20 @@ export async function runWatcherCycle(
|
|
|
578
690
|
`⚠️ Agent 执行失败。\n\n${summary}`
|
|
579
691
|
);
|
|
580
692
|
await notifyFailed(config.agentDefinition?.name ?? "agent", issue.iid, issue.title, summary, config.webhookUrl);
|
|
693
|
+
// Feishu: update card to failed state
|
|
694
|
+
if (dependencies.feishuClient && singleTaskFeishuMsgId) {
|
|
695
|
+
const card = buildProgressCard({
|
|
696
|
+
agentName: config.agentDefinition?.name ?? "agent",
|
|
697
|
+
issueIid: issue.iid,
|
|
698
|
+
issueTitle: issue.title,
|
|
699
|
+
issueUrl: issue.webUrl || undefined,
|
|
700
|
+
status: "failed",
|
|
701
|
+
summary: summary.slice(0, 300),
|
|
702
|
+
duration: `${durationSec}s`,
|
|
703
|
+
});
|
|
704
|
+
dependencies.feishuClient.updateCard(singleTaskFeishuMsgId, card).catch(() => {});
|
|
705
|
+
}
|
|
706
|
+
await replaceReactionOnTodo(dependencies.gitlabClient, candidate, "eyes", "x", logger);
|
|
581
707
|
await updateAgentUserStatus(dependencies, "idle", undefined, config);
|
|
582
708
|
await finalizeTodo(config, dependencies, state, candidate, issue, {
|
|
583
709
|
status: "failed",
|
|
@@ -594,6 +720,316 @@ export async function runWatcherCycle(
|
|
|
594
720
|
}
|
|
595
721
|
}
|
|
596
722
|
|
|
723
|
+
export async function runConcurrentWatcherCycle(
|
|
724
|
+
config: LocalAgentConfig,
|
|
725
|
+
dependencies: WatcherDependencies
|
|
726
|
+
): Promise<WatcherCycleResult> {
|
|
727
|
+
const logger = dependencies.logger ?? console;
|
|
728
|
+
const state = await dependencies.stateStore.load();
|
|
729
|
+
const concurrency = config.agentDefinition?.concurrency ?? 1;
|
|
730
|
+
|
|
731
|
+
// 1. Collect finished tasks
|
|
732
|
+
for (const [issueIid, spawned] of spawnedRuns) {
|
|
733
|
+
const result = await spawned.run.poll();
|
|
734
|
+
if (!result) continue; // still running
|
|
735
|
+
|
|
736
|
+
spawnedRuns.delete(issueIid);
|
|
737
|
+
removeActiveRun(state, issueIid);
|
|
738
|
+
|
|
739
|
+
const durationSec = Math.round((Date.now() - new Date(spawned.startedAt).getTime()) / 1000);
|
|
740
|
+
|
|
741
|
+
if (result.error) {
|
|
742
|
+
// Failed
|
|
743
|
+
logger.error(`Agent failed issue #${issueIid} after ${durationSec}s. Error: ${result.error.slice(0, 200)}`);
|
|
744
|
+
try {
|
|
745
|
+
await dependencies.gitlabClient.updateIssueLabels(
|
|
746
|
+
spawned.issue.projectId, issueIid,
|
|
747
|
+
transitionStatusLabels(spawned.issue.labels, getLabelConfig(config).error, getStatusLabelsList(config))
|
|
748
|
+
);
|
|
749
|
+
await dependencies.gitlabClient.addIssueNote(
|
|
750
|
+
spawned.issue.projectId, issueIid,
|
|
751
|
+
`⚠️ Agent 执行失败。\n\n${result.summary}`
|
|
752
|
+
);
|
|
753
|
+
} catch (e) { logger.warn(`Post-run cleanup failed: ${String(e).slice(0, 100)}`); }
|
|
754
|
+
await notifyFailed(config.agentDefinition?.name ?? "agent", issueIid, spawned.issue.title, result.summary, config.webhookUrl);
|
|
755
|
+
// Feishu: update card to failed state
|
|
756
|
+
if (dependencies.feishuClient && spawned.feishuMessageId) {
|
|
757
|
+
const card = buildProgressCard({
|
|
758
|
+
agentName: config.agentDefinition?.name ?? "agent",
|
|
759
|
+
issueIid,
|
|
760
|
+
issueTitle: spawned.issue.title,
|
|
761
|
+
issueUrl: spawned.issue.webUrl || undefined,
|
|
762
|
+
status: "failed",
|
|
763
|
+
progressLines: spawned.progressLines,
|
|
764
|
+
summary: result.summary.slice(0, 300),
|
|
765
|
+
duration: `${durationSec}s`,
|
|
766
|
+
});
|
|
767
|
+
dependencies.feishuClient.updateCard(spawned.feishuMessageId, card).catch(() => {});
|
|
768
|
+
}
|
|
769
|
+
await replaceReactionOnTodo(dependencies.gitlabClient, spawned.todo, "eyes", "x", logger);
|
|
770
|
+
await finalizeTodo(config, dependencies, state, spawned.todo, spawned.issue, {
|
|
771
|
+
status: "failed", summary: result.summary, branch: spawned.worktree.branch, worktreePath: spawned.worktree.worktreePath
|
|
772
|
+
});
|
|
773
|
+
} else {
|
|
774
|
+
// Completed
|
|
775
|
+
logger.info(`Agent completed issue #${issueIid} in ${durationSec}s. Summary: ${result.summary.slice(0, 200)}`);
|
|
776
|
+
const mr = spawned.issue.projectId ? await dependencies.gitlabClient.findMergeRequestByBranch(spawned.issue.projectId, spawned.worktree.branch).catch(() => undefined) : undefined;
|
|
777
|
+
await notifyCompleted(config.agentDefinition?.name ?? "agent", issueIid, spawned.issue.title, mr?.webUrl || spawned.issue.webUrl || undefined, config.webhookUrl);
|
|
778
|
+
// Feishu: update card to completed state
|
|
779
|
+
if (dependencies.feishuClient && spawned.feishuMessageId) {
|
|
780
|
+
const card = buildProgressCard({
|
|
781
|
+
agentName: config.agentDefinition?.name ?? "agent",
|
|
782
|
+
issueIid,
|
|
783
|
+
issueTitle: spawned.issue.title,
|
|
784
|
+
issueUrl: spawned.issue.webUrl || undefined,
|
|
785
|
+
mrUrl: mr?.webUrl,
|
|
786
|
+
status: "completed",
|
|
787
|
+
progressLines: spawned.progressLines,
|
|
788
|
+
summary: result.summary.slice(0, 300),
|
|
789
|
+
duration: `${durationSec}s`,
|
|
790
|
+
});
|
|
791
|
+
dependencies.feishuClient.updateCard(spawned.feishuMessageId, card).catch(() => {});
|
|
792
|
+
}
|
|
793
|
+
await replaceReactionOnTodo(dependencies.gitlabClient, spawned.todo, "eyes", "white_check_mark", logger);
|
|
794
|
+
await finalizeTodo(config, dependencies, state, spawned.todo, spawned.issue, {
|
|
795
|
+
status: "completed", summary: result.summary, branch: spawned.worktree.branch, worktreePath: spawned.worktree.worktreePath
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 2. Start new tasks if capacity available
|
|
801
|
+
const activeCount = spawnedRuns.size;
|
|
802
|
+
if (activeCount >= concurrency) {
|
|
803
|
+
// At capacity — handle busy notifications
|
|
804
|
+
logger.info(`All ${concurrency} slots busy (${Array.from(spawnedRuns.keys()).map(i => `#${i}`).join(", ")}).`);
|
|
805
|
+
// Update status to show all active issues
|
|
806
|
+
const activeIids = Array.from(spawnedRuns.keys());
|
|
807
|
+
const statusMsg = activeIids.length === 1
|
|
808
|
+
? `#${activeIids[0]}`
|
|
809
|
+
: `${activeIids.length} issues (${activeIids.map(i => `#${i}`).join(", ")})`;
|
|
810
|
+
await updateAgentUserStatus(dependencies, "busy", statusMsg);
|
|
811
|
+
await notifyPendingTodosWhileBusy(config, dependencies, state);
|
|
812
|
+
await dependencies.stateStore.save(state);
|
|
813
|
+
return { status: "busy", issueIid: activeIids[0] };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Poll todos
|
|
817
|
+
const todos = config.gitlabProjectIdExplicit && config.gitlabProjectId
|
|
818
|
+
? await dependencies.gitlabClient.listPendingTodos(config.gitlabProjectId)
|
|
819
|
+
: await dependencies.gitlabClient.listAllPendingTodos();
|
|
820
|
+
logger.info(`Polled ${todos.length} pending todo(s). Active: ${activeCount}/${concurrency}`);
|
|
821
|
+
|
|
822
|
+
const triggers = config.agentDefinition?.triggers;
|
|
823
|
+
const triggerActions = triggers ? new Set(triggers.actions) : undefined;
|
|
824
|
+
|
|
825
|
+
// Try to fill remaining slots
|
|
826
|
+
let started = 0;
|
|
827
|
+
const processedInThisCycle = new Set<number>();
|
|
828
|
+
|
|
829
|
+
while (spawnedRuns.size < concurrency) {
|
|
830
|
+
// Filter out todos for issues already being worked on
|
|
831
|
+
const activeIssueIids = new Set(spawnedRuns.keys());
|
|
832
|
+
const candidate = pickNextTodo(todos, state, triggerActions, logger, activeIssueIids, processedInThisCycle);
|
|
833
|
+
if (!candidate) break;
|
|
834
|
+
processedInThisCycle.add(candidate.id);
|
|
835
|
+
|
|
836
|
+
// Skip MR todos and contextual replies in concurrent mode — handle them synchronously
|
|
837
|
+
const isIssueTodo = candidate.targetType === "Issue";
|
|
838
|
+
if (!isIssueTodo) {
|
|
839
|
+
// Mark as processed, skip for now (MR handling stays synchronous)
|
|
840
|
+
state.processedTodoIds = rememberIds(state.processedTodoIds, candidate.id);
|
|
841
|
+
try { await dependencies.gitlabClient.markTodoDone(candidate.id); } catch { /* silent */ }
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const issue = await dependencies.gitlabClient.getIssue(candidate.projectId, candidate.issueIid);
|
|
846
|
+
|
|
847
|
+
// Check mention type — contextual replies still run synchronously
|
|
848
|
+
const mentionType = classifyMention(candidate.body ?? "", issue.title, issue.labels, issue.description);
|
|
849
|
+
if (mentionType === "contextual" && dependencies.agentRunner.runContextual) {
|
|
850
|
+
// Run contextual reply synchronously (quick, non-blocking conceptually)
|
|
851
|
+
try {
|
|
852
|
+
await dependencies.agentRunner.runContextual(issue, candidate.body ?? "", { todoId: candidate.id });
|
|
853
|
+
logger.info(`Contextual reply completed for #${issue.iid}`);
|
|
854
|
+
} catch (e) { logger.warn(`Contextual reply failed: ${String(e).slice(0, 100)}`); }
|
|
855
|
+
state.processedTodoIds = rememberIds(state.processedTodoIds, candidate.id);
|
|
856
|
+
try { await dependencies.gitlabClient.markTodoDone(candidate.id); } catch { /* silent */ }
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Label trigger check
|
|
861
|
+
if (triggers && !matchesTriggerLabels(issue.labels, triggers.labels, triggers.exclude_labels)) {
|
|
862
|
+
logger.info(`Issue #${issue.iid} does not match trigger labels, skipping.`);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Ensure spawn() is available
|
|
867
|
+
if (!dependencies.agentRunner.spawn) {
|
|
868
|
+
logger.warn(`AgentRunner does not support spawn(), falling back to single-task mode.`);
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Prepare and spawn
|
|
873
|
+
const worktree = await dependencies.worktreeManager.ensureWorktree(issue.iid, issue.title);
|
|
874
|
+
|
|
875
|
+
const skills = config.agentDefinition?.skills ?? [];
|
|
876
|
+
if (skills.length > 0) {
|
|
877
|
+
try {
|
|
878
|
+
const injected = await injectSkillFiles(worktree.worktreePath, config.agentProvider, skills, config.skillsDir);
|
|
879
|
+
logger.info(`Injected ${injected} skill file(s) for #${issue.iid}.`);
|
|
880
|
+
} catch (err) { logger.warn(`Failed to inject skill files: ${String(err)}`); }
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const spawnedRun = await dependencies.agentRunner.spawn(issue, worktree, { todoId: candidate.id });
|
|
884
|
+
const now = new Date().toISOString();
|
|
885
|
+
|
|
886
|
+
spawnedRuns.set(issue.iid, {
|
|
887
|
+
run: spawnedRun,
|
|
888
|
+
issue,
|
|
889
|
+
todo: candidate,
|
|
890
|
+
worktree,
|
|
891
|
+
startedAt: now,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// Feishu: send accepted card to the @mention author
|
|
895
|
+
if (dependencies.feishuClient && candidate.authorUsername && config.feishuEmailDomain) {
|
|
896
|
+
try {
|
|
897
|
+
const email = `${candidate.authorUsername}@${config.feishuEmailDomain}`;
|
|
898
|
+
{
|
|
899
|
+
const openId = await dependencies.feishuClient.getUserByEmail(email);
|
|
900
|
+
if (openId) {
|
|
901
|
+
const card = buildProgressCard({
|
|
902
|
+
agentName: config.agentDefinition?.name ?? "agent",
|
|
903
|
+
issueIid: issue.iid,
|
|
904
|
+
issueTitle: issue.title,
|
|
905
|
+
issueUrl: issue.webUrl || undefined,
|
|
906
|
+
status: "accepted",
|
|
907
|
+
});
|
|
908
|
+
const msgId = await dependencies.feishuClient.sendCard(openId, card);
|
|
909
|
+
const entry = spawnedRuns.get(issue.iid);
|
|
910
|
+
if (entry && msgId) {
|
|
911
|
+
entry.feishuMessageId = msgId;
|
|
912
|
+
entry.feishuOpenId = openId;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
} catch (e) {
|
|
917
|
+
logger.info?.(`Feishu card send skipped: ${String(e).slice(0, 100)}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Set up progress callback for webhook and/or feishu card updates
|
|
922
|
+
if ((config.webhookUrl || dependencies.feishuClient) && spawnedRun.onProgress !== undefined) {
|
|
923
|
+
const agentName = config.agentDefinition?.name ?? "agent";
|
|
924
|
+
const issueRef = `#${issue.iid}`;
|
|
925
|
+
const capturedIssueIid = issue.iid;
|
|
926
|
+
let lastProgressAt = 0;
|
|
927
|
+
const THROTTLE_MS = 15_000; // Max one progress update per 15s
|
|
928
|
+
let pendingReads: string[] = [];
|
|
929
|
+
|
|
930
|
+
spawnedRun.onProgress = (event) => {
|
|
931
|
+
const now2 = Date.now();
|
|
932
|
+
|
|
933
|
+
// Accumulate reads, flush every 3 or on throttle
|
|
934
|
+
if (event.type === "tool_use" && event.tool === "Read") {
|
|
935
|
+
pendingReads.push(event.detail ?? "");
|
|
936
|
+
if (pendingReads.length < 3 && now2 - lastProgressAt < THROTTLE_MS) return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Throttle non-read events
|
|
940
|
+
if (now2 - lastProgressAt < THROTTLE_MS && event.type !== "tool_use") return;
|
|
941
|
+
|
|
942
|
+
let message = "";
|
|
943
|
+
if (pendingReads.length > 0) {
|
|
944
|
+
message = `\u{1F4D6} 阅读 ${pendingReads.join(", ")}`;
|
|
945
|
+
pendingReads = [];
|
|
946
|
+
} else if (event.type === "tool_use") {
|
|
947
|
+
const icon = event.tool === "Edit" || event.tool === "Write" ? "\u270F\uFE0F"
|
|
948
|
+
: event.tool === "Bash" ? "\u{1F527}"
|
|
949
|
+
: event.tool === "Grep" || event.tool === "Glob" ? "\u{1F50D}"
|
|
950
|
+
: "\u{1F4CE}";
|
|
951
|
+
message = `${icon} ${event.tool}: ${event.detail ?? ""}`;
|
|
952
|
+
} else if (event.type === "text") {
|
|
953
|
+
message = event.detail?.slice(0, 100) ?? "";
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (!message) return;
|
|
957
|
+
lastProgressAt = now2;
|
|
958
|
+
|
|
959
|
+
const spawnedEntry = spawnedRuns.get(capturedIssueIid);
|
|
960
|
+
|
|
961
|
+
// Feishu card update (preferred when available)
|
|
962
|
+
if (dependencies.feishuClient && spawnedEntry?.feishuMessageId) {
|
|
963
|
+
if (!spawnedEntry.progressLines) spawnedEntry.progressLines = [];
|
|
964
|
+
spawnedEntry.progressLines.push(message);
|
|
965
|
+
// Keep last 20 lines to avoid card getting too long
|
|
966
|
+
if (spawnedEntry.progressLines.length > 20) {
|
|
967
|
+
spawnedEntry.progressLines = spawnedEntry.progressLines.slice(-20);
|
|
968
|
+
}
|
|
969
|
+
const elapsedSec = Math.round((Date.now() - new Date(spawnedEntry.startedAt).getTime()) / 1000);
|
|
970
|
+
const card = buildProgressCard({
|
|
971
|
+
agentName,
|
|
972
|
+
issueIid: capturedIssueIid,
|
|
973
|
+
issueTitle: spawnedEntry.issue.title,
|
|
974
|
+
issueUrl: spawnedEntry.issue.webUrl || undefined,
|
|
975
|
+
status: "running",
|
|
976
|
+
progressLines: spawnedEntry.progressLines,
|
|
977
|
+
duration: `${elapsedSec}s`,
|
|
978
|
+
});
|
|
979
|
+
dependencies.feishuClient.updateCard(spawnedEntry.feishuMessageId, card).catch(() => {});
|
|
980
|
+
} else if (config.webhookUrl) {
|
|
981
|
+
// Fallback to webhook when Feishu is not configured
|
|
982
|
+
sendWebhook(config.webhookUrl, {
|
|
983
|
+
title: `\u{1F527} ${agentName} ${issueRef}`,
|
|
984
|
+
message,
|
|
985
|
+
status: "accepted",
|
|
986
|
+
}).catch(() => {});
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
addActiveRun(state, {
|
|
992
|
+
pid: spawnedRun.pid,
|
|
993
|
+
todoId: candidate.id,
|
|
994
|
+
issueId: issue.id,
|
|
995
|
+
issueIid: issue.iid,
|
|
996
|
+
projectId: issue.projectId,
|
|
997
|
+
worktreePath: worktree.worktreePath,
|
|
998
|
+
branch: worktree.branch,
|
|
999
|
+
startedAt: now,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
state.notifiedBusyTodoIds = rememberIds(state.notifiedBusyTodoIds, candidate.id);
|
|
1003
|
+
await notifyAccepted(config.agentDefinition?.name ?? "agent", issue.iid, issue.title, issue.webUrl || undefined, config.webhookUrl);
|
|
1004
|
+
await addReactionToTodo(dependencies.gitlabClient, candidate, "eyes", logger);
|
|
1005
|
+
logger.info(`Spawned agent for issue #${issue.iid}: "${issue.title}" (PID ${spawnedRun.pid})`);
|
|
1006
|
+
started++;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Update status
|
|
1010
|
+
if (spawnedRuns.size > 0) {
|
|
1011
|
+
const activeIids = Array.from(spawnedRuns.keys());
|
|
1012
|
+
const activeStatusMsg = activeIids.length === 1
|
|
1013
|
+
? `#${activeIids[0]}`
|
|
1014
|
+
: `${activeIids.length} issues (${activeIids.map(i => `#${i}`).join(", ")})`;
|
|
1015
|
+
await updateAgentUserStatus(dependencies, "busy", activeStatusMsg);
|
|
1016
|
+
} else {
|
|
1017
|
+
await updateAgentUserStatus(dependencies, "idle", undefined, config);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
state.lastRun = {
|
|
1021
|
+
status: spawnedRuns.size > 0 ? "running" : "idle",
|
|
1022
|
+
at: new Date().toISOString(),
|
|
1023
|
+
summary: started > 0 ? `Started ${started} new task(s). Active: ${spawnedRuns.size}` : `Active: ${spawnedRuns.size}`
|
|
1024
|
+
};
|
|
1025
|
+
await dependencies.stateStore.save(state);
|
|
1026
|
+
|
|
1027
|
+
return {
|
|
1028
|
+
status: spawnedRuns.size > 0 ? "busy" : "idle",
|
|
1029
|
+
issueIid: spawnedRuns.size > 0 ? Array.from(spawnedRuns.keys())[0] : undefined
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
597
1033
|
async function finalizeTodo(
|
|
598
1034
|
_config: LocalAgentConfig,
|
|
599
1035
|
dependencies: WatcherDependencies,
|
|
@@ -737,8 +1173,17 @@ async function updateAgentUserStatus(
|
|
|
737
1173
|
try {
|
|
738
1174
|
switch (status) {
|
|
739
1175
|
case "idle": {
|
|
740
|
-
|
|
741
|
-
|
|
1176
|
+
let idleMsg: string;
|
|
1177
|
+
if (config?.agentDefinition?.description) {
|
|
1178
|
+
// P1: Agent = 员工 — show agent description as identity
|
|
1179
|
+
idleMsg = `Ready | ${config.agentDefinition.description}`;
|
|
1180
|
+
if (idleMsg.length > 100) {
|
|
1181
|
+
idleMsg = idleMsg.slice(0, 97) + "...";
|
|
1182
|
+
}
|
|
1183
|
+
} else {
|
|
1184
|
+
idleMsg = truncateMessage(`Ready${buildSkillsSuffix(config)}`);
|
|
1185
|
+
}
|
|
1186
|
+
await dependencies.gitlabClient.updateUserStatus("white_check_mark", idleMsg, "not_set");
|
|
742
1187
|
break;
|
|
743
1188
|
}
|
|
744
1189
|
case "busy":
|
|
@@ -1020,6 +1465,7 @@ async function notifyPendingTodosWhileBusy(
|
|
|
1020
1465
|
const position = pendingUnprocessed.indexOf(todo) + 1;
|
|
1021
1466
|
const note = formatBusyNote(config.agentProvider, activeRun, position, pendingUnprocessed.length);
|
|
1022
1467
|
await dependencies.gitlabClient.addIssueNote(todo.projectId, todo.issueIid, note);
|
|
1468
|
+
await notifyQueued(config.agentDefinition?.name ?? "agent", todo.issueIid, `#${todo.issueIid}`, position, todo.targetUrl, config.webhookUrl).catch(() => undefined);
|
|
1023
1469
|
state.notifiedBusyTodoIds = rememberIds(state.notifiedBusyTodoIds, todo.id);
|
|
1024
1470
|
stateChanged = true;
|
|
1025
1471
|
logger.info(`Posted busy notification on issue #${todo.issueIid}.`);
|
|
@@ -1057,7 +1503,9 @@ function pickNextTodo(
|
|
|
1057
1503
|
todos: GitlabTodoItem[],
|
|
1058
1504
|
state: LocalAgentState,
|
|
1059
1505
|
triggerActions?: Set<string>,
|
|
1060
|
-
logger?: Logger | typeof console
|
|
1506
|
+
logger?: Logger | typeof console,
|
|
1507
|
+
activeIssueIids?: Set<number>,
|
|
1508
|
+
skipTodoIds?: Set<number>
|
|
1061
1509
|
): GitlabTodoItem | undefined {
|
|
1062
1510
|
const processedTodos = new Set(state.processedTodoIds);
|
|
1063
1511
|
const acceptedActions = triggerActions ?? ACCEPTED_TODO_ACTIONS;
|
|
@@ -1077,6 +1525,15 @@ function pickNextTodo(
|
|
|
1077
1525
|
return false;
|
|
1078
1526
|
}
|
|
1079
1527
|
|
|
1528
|
+
if (activeIssueIids?.has(todo.issueIid)) {
|
|
1529
|
+
logger?.info?.(`Todo ${todo.id} (issue #${todo.issueIid}): skipped — already being processed`);
|
|
1530
|
+
return false;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
if (skipTodoIds?.has(todo.id)) {
|
|
1534
|
+
return false;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1080
1537
|
return true;
|
|
1081
1538
|
});
|
|
1082
1539
|
|
|
@@ -1220,7 +1677,10 @@ export function loadConfigFromAgentDefinition(
|
|
|
1220
1677
|
logPath: agentLogPath(agentRepoPath, def.name),
|
|
1221
1678
|
agentDefinition: def,
|
|
1222
1679
|
skillsDir: agentSkillsDir(agentRepoPath),
|
|
1223
|
-
webhookUrl: def.notifications?.webhook_url
|
|
1680
|
+
webhookUrl: def.notifications?.webhook_url,
|
|
1681
|
+
feishuAppId: def.notifications?.feishu_app_id_env ? env[def.notifications.feishu_app_id_env]?.trim() : undefined,
|
|
1682
|
+
feishuAppSecret: def.notifications?.feishu_app_secret_env ? env[def.notifications.feishu_app_secret_env]?.trim() : undefined,
|
|
1683
|
+
feishuEmailDomain: def.notifications?.feishu_email_domain,
|
|
1224
1684
|
};
|
|
1225
1685
|
}
|
|
1226
1686
|
|
|
@@ -1329,6 +1789,15 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1329
1789
|
const logger = createLogger("watcher", agentName, logFormat);
|
|
1330
1790
|
const dependencies = createDependencies(config, logger);
|
|
1331
1791
|
|
|
1792
|
+
// Create FeishuClient if feishu app credentials are configured
|
|
1793
|
+
if (config.feishuAppId && config.feishuAppSecret) {
|
|
1794
|
+
dependencies.feishuClient = new FeishuClient({
|
|
1795
|
+
appId: config.feishuAppId,
|
|
1796
|
+
appSecret: config.feishuAppSecret,
|
|
1797
|
+
});
|
|
1798
|
+
logger.info("Feishu app client initialized for targeted personal notifications.");
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1332
1801
|
// Validate token at startup and fetch bot identity (hard fail if token is invalid)
|
|
1333
1802
|
try {
|
|
1334
1803
|
const botUser = await dependencies.gitlabClient.getCurrentUser();
|
|
@@ -1350,7 +1819,18 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1350
1819
|
logger.info(`Watcher started. mode=${mode} provider=${config.agentProvider} host=${config.gitlabHost}${config.gitlabProjectId ? ` project=${config.gitlabProjectId}${config.gitlabProjectIdExplicit ? "" : " (inferred, todo polling unfiltered)"}` : " project=auto (from todos)"}`);
|
|
1351
1820
|
|
|
1352
1821
|
if (mode === "run-once") {
|
|
1353
|
-
|
|
1822
|
+
const concurrency = config.agentDefinition?.concurrency ?? 1;
|
|
1823
|
+
if (concurrency > 1) {
|
|
1824
|
+
// Run one concurrent cycle, then poll until all spawned tasks finish
|
|
1825
|
+
await runConcurrentWatcherCycle(config, dependencies);
|
|
1826
|
+
// Wait for spawned tasks to complete
|
|
1827
|
+
while (spawnedRuns.size > 0) {
|
|
1828
|
+
await sleep(2000);
|
|
1829
|
+
await runConcurrentWatcherCycle(config, dependencies);
|
|
1830
|
+
}
|
|
1831
|
+
} else {
|
|
1832
|
+
await runWatcherCycle(config, dependencies);
|
|
1833
|
+
}
|
|
1354
1834
|
return;
|
|
1355
1835
|
}
|
|
1356
1836
|
|
|
@@ -1438,7 +1918,10 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1438
1918
|
}
|
|
1439
1919
|
|
|
1440
1920
|
try {
|
|
1441
|
-
const
|
|
1921
|
+
const concurrency = config.agentDefinition?.concurrency ?? 1;
|
|
1922
|
+
const cycleResult = concurrency > 1
|
|
1923
|
+
? await runConcurrentWatcherCycle(config, dependencies)
|
|
1924
|
+
: await runWatcherCycle(config, dependencies);
|
|
1442
1925
|
circuitBreaker.recordSuccess();
|
|
1443
1926
|
cycleCount++;
|
|
1444
1927
|
healthState.cycleCount = cycleCount;
|
|
@@ -1456,7 +1939,8 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
|
|
1456
1939
|
if (config.webhookUrl) {
|
|
1457
1940
|
await sendWebhook(config.webhookUrl, {
|
|
1458
1941
|
title: `⚠️ ${agentName} Token 可能已过期`,
|
|
1459
|
-
message: `Token validation failed: ${tokenErrMsg.slice(0, 100)}
|
|
1942
|
+
message: `Token validation failed: ${tokenErrMsg.slice(0, 100)}`,
|
|
1943
|
+
status: "failed"
|
|
1460
1944
|
});
|
|
1461
1945
|
}
|
|
1462
1946
|
lastTokenCheckAt = Date.now(); // Don't spam notifications
|