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.
@@ -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
- await notifyCompleted(config.agentDefinition?.name ?? "agent", issue.iid, issue.title, undefined, config.webhookUrl);
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
- const msg = truncateMessage(`Ready${buildSkillsSuffix(config)}`);
741
- await dependencies.gitlabClient.updateUserStatus("white_check_mark", msg, "not_set");
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
- await runWatcherCycle(config, dependencies);
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 cycleResult = await runWatcherCycle(config, dependencies);
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