glab-agent 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +3 -1
- package/src/local-agent/agent-runner.ts +5 -0
- package/src/local-agent/claude-runner.ts +2 -0
- package/src/local-agent/cli.ts +111 -0
- package/src/local-agent/codex-runner.ts +2 -0
- package/src/local-agent/report.ts +66 -1
- package/src/local-agent/watcher.ts +86 -86
package/README.md
CHANGED
|
@@ -153,6 +153,16 @@ No extra dashboards needed. glab-agent maps to GitLab's native features:
|
|
|
153
153
|
- **Metrics** — Append-only JSONL per agent (`.glab-agent/metrics/`)
|
|
154
154
|
- **Heartbeat** — Cycle count and last error in `.glab-agent/heartbeat/`
|
|
155
155
|
|
|
156
|
+
## Documentation
|
|
157
|
+
|
|
158
|
+
Development documentation lives in [`docs/`](docs/index.md):
|
|
159
|
+
|
|
160
|
+
- **[Design Principles](docs/design-principles.md)** — P1-P17, architecture decision criteria
|
|
161
|
+
- **[Test Strategy](docs/test-strategy.md)** — coverage policy, key modules, known gaps
|
|
162
|
+
- **[Harness Engineering](docs/harness-engineering.md)** — human + AI collaboration methodology
|
|
163
|
+
|
|
164
|
+
Run `glab-agent doctor` for environment diagnostics.
|
|
165
|
+
|
|
156
166
|
## Requirements
|
|
157
167
|
|
|
158
168
|
- Node.js >= 20
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glab-agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Multi-agent GitLab To-Do watcher with YAML-defined agents, skills, and GitLab registry.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
"prepare": "git config core.hooksPath scripts/hooks",
|
|
35
35
|
"build": "tsc -p tsconfig.build.json",
|
|
36
36
|
"test": "vitest run",
|
|
37
|
+
"test:coverage": "vitest run --coverage",
|
|
37
38
|
"agent": "tsx src/local-agent/cli.ts",
|
|
39
|
+
"check:deliver": "zsh scripts/delivery-check.sh",
|
|
38
40
|
"smoke-test": "zsh scripts/smoke-test.sh",
|
|
39
41
|
"smoke-test:full": "zsh scripts/smoke-test.sh --wait-finish",
|
|
40
42
|
"docs-lint": "zsh scripts/docs-lint.sh",
|
|
@@ -22,6 +22,8 @@ export interface AgentRunResult {
|
|
|
22
22
|
export interface AgentRunContext {
|
|
23
23
|
todoId?: number;
|
|
24
24
|
signal?: AbortSignal;
|
|
25
|
+
/** Hint when issue labels don't match trigger config. Passed to agent prompt. */
|
|
26
|
+
labelMismatchHint?: string;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export interface SpawnedRunResult {
|
|
@@ -116,6 +118,8 @@ export interface AgentPromptContext {
|
|
|
116
118
|
append?: string;
|
|
117
119
|
templatePath?: string;
|
|
118
120
|
labels?: AgentLabelConfig;
|
|
121
|
+
/** Hint when issue labels don't match trigger config. Agent decides how to respond. */
|
|
122
|
+
labelMismatchHint?: string;
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
export interface ContextualPromptContext {
|
|
@@ -227,6 +231,7 @@ export function buildBasePrompt(ctx: AgentPromptContext): string {
|
|
|
227
231
|
"## 当前环境",
|
|
228
232
|
`- 分支: ${ctx.branch}`,
|
|
229
233
|
"- 工作目录: 项目的独立工作副本",
|
|
234
|
+
ctx.labelMismatchHint ? `\n## 注意\n${ctx.labelMismatchHint}` : "",
|
|
230
235
|
"",
|
|
231
236
|
"## 最终输出",
|
|
232
237
|
"标准输出不超过 5 行的简短总结(不是 GitLab 评论)。"
|
|
@@ -75,6 +75,7 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
75
75
|
preamble: this.agentDefinition?.prompt.preamble,
|
|
76
76
|
append: this.agentDefinition?.prompt.append,
|
|
77
77
|
labels: this.agentDefinition?.labels,
|
|
78
|
+
labelMismatchHint: context?.labelMismatchHint,
|
|
78
79
|
// skills injected via .claude/skills/*.md files, not in prompt
|
|
79
80
|
});
|
|
80
81
|
const claudeCommand = resolveClaudeCommand(this.env);
|
|
@@ -158,6 +159,7 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
158
159
|
preamble: this.agentDefinition?.prompt.preamble,
|
|
159
160
|
append: this.agentDefinition?.prompt.append,
|
|
160
161
|
labels: this.agentDefinition?.labels,
|
|
162
|
+
labelMismatchHint: context?.labelMismatchHint,
|
|
161
163
|
});
|
|
162
164
|
const claudeCommand = resolveClaudeCommand(this.env);
|
|
163
165
|
|
package/src/local-agent/cli.ts
CHANGED
|
@@ -97,6 +97,7 @@ Commands:
|
|
|
97
97
|
watch <name> Watch agent status in real-time (refreshes every 5s)
|
|
98
98
|
history <name> Show agent execution history (--last N, default 20)
|
|
99
99
|
report <name> Show execution report (--since <N>d, default 7d) [--publish]
|
|
100
|
+
dashboard Multi-agent overview (status + recent runs + alerts)
|
|
100
101
|
sweep <name|--all> Move merged In Review issues to Done
|
|
101
102
|
gc <name|--all> Remove worktrees for closed/merged issues
|
|
102
103
|
install <name|--all> Generate launchd plist for agent(s)
|
|
@@ -2214,6 +2215,113 @@ async function cmdWikiSetup(): Promise<void> {
|
|
|
2214
2215
|
console.log(` ${wikiUrl(remote.host, remote.projectPath, "home")}`);
|
|
2215
2216
|
}
|
|
2216
2217
|
|
|
2218
|
+
// ── Dashboard command ─────────────────────────────────────────────────────
|
|
2219
|
+
|
|
2220
|
+
async function cmdDashboard(): Promise<void> {
|
|
2221
|
+
const agents = await discoverAgents(AGENTS_DIR).catch(() => []);
|
|
2222
|
+
if (agents.length === 0) {
|
|
2223
|
+
console.log("No agents found. Run 'glab-agent init <name>' first.");
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// ── Agent Status Table ──
|
|
2228
|
+
console.log("AGENTS");
|
|
2229
|
+
console.log("-".repeat(80));
|
|
2230
|
+
const nameW = 20;
|
|
2231
|
+
const statusW = 10;
|
|
2232
|
+
const taskW = 35;
|
|
2233
|
+
const lastW = 15;
|
|
2234
|
+
console.log(
|
|
2235
|
+
`${"Name".padEnd(nameW)}${"Status".padEnd(statusW)}${"Current Task".padEnd(taskW)}${"Last Run".padEnd(lastW)}`
|
|
2236
|
+
);
|
|
2237
|
+
console.log("-".repeat(80));
|
|
2238
|
+
|
|
2239
|
+
const alerts: string[] = [];
|
|
2240
|
+
|
|
2241
|
+
for (const agent of agents) {
|
|
2242
|
+
const status = await getAgentStatus(PROJECT_DIR, agent.name);
|
|
2243
|
+
const statePath = agentStatePath(PROJECT_DIR, agent.name);
|
|
2244
|
+
const store = new FileStateStore(statePath);
|
|
2245
|
+
const state = await store.load();
|
|
2246
|
+
|
|
2247
|
+
// Status
|
|
2248
|
+
const statusStr = status.alive ? "busy" : "idle";
|
|
2249
|
+
|
|
2250
|
+
// Current task
|
|
2251
|
+
let taskStr = "\u2014";
|
|
2252
|
+
if (state.activeRun) {
|
|
2253
|
+
taskStr = `#${state.activeRun.issueIid}`;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// Last run info
|
|
2257
|
+
const history = state.runHistory ?? [];
|
|
2258
|
+
let lastRunStr = "\u2014";
|
|
2259
|
+
if (history.length > 0) {
|
|
2260
|
+
const last = history[history.length - 1];
|
|
2261
|
+
const ago = Math.round((Date.now() - new Date(last.finishedAt).getTime()) / 60000);
|
|
2262
|
+
if (ago < 60) lastRunStr = `${ago}min ago`;
|
|
2263
|
+
else if (ago < 1440) lastRunStr = `${Math.round(ago / 60)}hr ago`;
|
|
2264
|
+
else lastRunStr = `${Math.round(ago / 1440)}d ago`;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
console.log(
|
|
2268
|
+
`${agent.name.padEnd(nameW)}${statusStr.padEnd(statusW)}${taskStr.slice(0, taskW - 1).padEnd(taskW)}${lastRunStr.padEnd(lastW)}`
|
|
2269
|
+
);
|
|
2270
|
+
|
|
2271
|
+
// Collect alerts
|
|
2272
|
+
if (history.length > 0) {
|
|
2273
|
+
const last = history[history.length - 1];
|
|
2274
|
+
if (last.status === "failed") {
|
|
2275
|
+
alerts.push(`${agent.name} #${last.issueIid} failed — check: pnpm agent logs ${agent.name}`);
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// Check heartbeat staleness
|
|
2280
|
+
const hbPath = agentHeartbeatPath(PROJECT_DIR, agent.name);
|
|
2281
|
+
const hb = await readHeartbeat(hbPath);
|
|
2282
|
+
if (hb && isHeartbeatStale(hb, 300)) {
|
|
2283
|
+
alerts.push(`${agent.name} heartbeat stale (>5min) — may need restart`);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// ── Recent Runs ──
|
|
2288
|
+
console.log("\nRECENT (last 10 across all agents)");
|
|
2289
|
+
console.log("-".repeat(80));
|
|
2290
|
+
|
|
2291
|
+
const allRuns: Array<{ agentName: string; entry: RunHistoryEntry }> = [];
|
|
2292
|
+
for (const agent of agents) {
|
|
2293
|
+
const statePath = agentStatePath(PROJECT_DIR, agent.name);
|
|
2294
|
+
const store = new FileStateStore(statePath);
|
|
2295
|
+
const state = await store.load();
|
|
2296
|
+
for (const entry of state.runHistory ?? []) {
|
|
2297
|
+
allRuns.push({ agentName: agent.name, entry });
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
allRuns.sort((a, b) => new Date(b.entry.finishedAt).getTime() - new Date(a.entry.finishedAt).getTime());
|
|
2302
|
+
|
|
2303
|
+
const recentRuns = allRuns.slice(0, 10);
|
|
2304
|
+
if (recentRuns.length === 0) {
|
|
2305
|
+
console.log(" No execution history yet.");
|
|
2306
|
+
} else {
|
|
2307
|
+
for (const { agentName, entry } of recentRuns) {
|
|
2308
|
+
const status = entry.status === "completed" ? "done" : entry.status;
|
|
2309
|
+
const duration = formatDuration(entry.startedAt, entry.finishedAt);
|
|
2310
|
+
const title = (entry.issueTitle ?? "").slice(0, 30);
|
|
2311
|
+
console.log(` ${agentName.padEnd(16)} #${String(entry.issueIid).padEnd(6)} ${status.padEnd(10)} ${duration.padEnd(8)} ${title}`);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// ── Alerts ──
|
|
2316
|
+
if (alerts.length > 0) {
|
|
2317
|
+
console.log("\nALERTS");
|
|
2318
|
+
console.log("-".repeat(80));
|
|
2319
|
+
for (const alert of alerts) {
|
|
2320
|
+
console.log(` ${alert}`);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2217
2325
|
async function main(): Promise<void> {
|
|
2218
2326
|
const args = stripProjectFlag(process.argv.slice(2));
|
|
2219
2327
|
const command = args[0];
|
|
@@ -2350,6 +2458,9 @@ Examples:
|
|
|
2350
2458
|
case "doctor":
|
|
2351
2459
|
await cmdDoctor();
|
|
2352
2460
|
break;
|
|
2461
|
+
case "dashboard":
|
|
2462
|
+
await cmdDashboard();
|
|
2463
|
+
break;
|
|
2353
2464
|
case "wiki": {
|
|
2354
2465
|
const wikiSub = args[1];
|
|
2355
2466
|
if (wikiSub === "setup") {
|
|
@@ -75,6 +75,7 @@ export class CodexRunner implements AgentRunner {
|
|
|
75
75
|
preamble: this.agentDefinition?.prompt.preamble,
|
|
76
76
|
append: this.agentDefinition?.prompt.append,
|
|
77
77
|
labels: this.agentDefinition?.labels,
|
|
78
|
+
labelMismatchHint: context?.labelMismatchHint,
|
|
78
79
|
// skills injected via .agent_context/skills/*.md files, not in prompt
|
|
79
80
|
});
|
|
80
81
|
const codexCommand = resolveCodexCommand(this.env);
|
|
@@ -161,6 +162,7 @@ export class CodexRunner implements AgentRunner {
|
|
|
161
162
|
preamble: this.agentDefinition?.prompt.preamble,
|
|
162
163
|
append: this.agentDefinition?.prompt.append,
|
|
163
164
|
labels: this.agentDefinition?.labels,
|
|
165
|
+
labelMismatchHint: context?.labelMismatchHint,
|
|
164
166
|
});
|
|
165
167
|
const codexCommand = resolveCodexCommand(this.env);
|
|
166
168
|
|
|
@@ -25,6 +25,10 @@ export interface ReportResult {
|
|
|
25
25
|
timestamp: string;
|
|
26
26
|
error?: string;
|
|
27
27
|
}[];
|
|
28
|
+
insights?: {
|
|
29
|
+
frequentFiles: { file: string; count: number }[];
|
|
30
|
+
retryHotspots: { issueIid: number; retryCount: number }[];
|
|
31
|
+
};
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export async function generateReport(
|
|
@@ -95,6 +99,45 @@ export async function generateReport(
|
|
|
95
99
|
error: e.error
|
|
96
100
|
}));
|
|
97
101
|
|
|
102
|
+
// Pattern insights (HX-3)
|
|
103
|
+
// Frequent files: from git log
|
|
104
|
+
const frequentFiles: { file: string; count: number }[] = [];
|
|
105
|
+
try {
|
|
106
|
+
const { execFile: execFileCb } = await import("node:child_process");
|
|
107
|
+
const { promisify } = await import("node:util");
|
|
108
|
+
const execFileAsync = promisify(execFileCb);
|
|
109
|
+
const sinceStr = `${sinceDays} days ago`;
|
|
110
|
+
const { stdout } = await execFileAsync("git", [
|
|
111
|
+
"log", "--since", sinceStr, "--name-only", "--pretty=format:", "--diff-filter=M"
|
|
112
|
+
], { maxBuffer: 1024 * 1024 });
|
|
113
|
+
const fileCounts = new Map<string, number>();
|
|
114
|
+
for (const line of stdout.toString().split("\n")) {
|
|
115
|
+
const f = line.trim();
|
|
116
|
+
if (f && f.startsWith("src/")) {
|
|
117
|
+
fileCounts.set(f, (fileCounts.get(f) ?? 0) + 1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const sorted = [...fileCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
121
|
+
for (const [file, count] of sorted) {
|
|
122
|
+
frequentFiles.push({ file, count });
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// git not available or not a git repo — skip
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Retry hotspots: issues that appear multiple times in events
|
|
129
|
+
const issueOccurrences = new Map<number, number>();
|
|
130
|
+
for (const e of events) {
|
|
131
|
+
if (e.issueIid) {
|
|
132
|
+
issueOccurrences.set(e.issueIid, (issueOccurrences.get(e.issueIid) ?? 0) + 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const retryHotspots = [...issueOccurrences.entries()]
|
|
136
|
+
.filter(([_, count]) => count > 1)
|
|
137
|
+
.sort((a, b) => b[1] - a[1])
|
|
138
|
+
.slice(0, 5)
|
|
139
|
+
.map(([issueIid, retryCount]) => ({ issueIid, retryCount }));
|
|
140
|
+
|
|
98
141
|
return {
|
|
99
142
|
agentName,
|
|
100
143
|
period: { from: sinceDate, to: now },
|
|
@@ -106,7 +149,8 @@ export async function generateReport(
|
|
|
106
149
|
successRate,
|
|
107
150
|
avgDurationMs,
|
|
108
151
|
topFailures,
|
|
109
|
-
recentRuns
|
|
152
|
+
recentRuns,
|
|
153
|
+
insights: { frequentFiles, retryHotspots }
|
|
110
154
|
};
|
|
111
155
|
}
|
|
112
156
|
|
|
@@ -180,6 +224,27 @@ export function formatReport(result: ReportResult): string {
|
|
|
180
224
|
}
|
|
181
225
|
}
|
|
182
226
|
|
|
227
|
+
// Pattern insights (HX-3)
|
|
228
|
+
const insights = result.insights;
|
|
229
|
+
if (insights && (insights.frequentFiles.length > 0 || insights.retryHotspots.length > 0)) {
|
|
230
|
+
lines.push("", "PATTERN INSIGHTS");
|
|
231
|
+
lines.push("-".repeat(40));
|
|
232
|
+
|
|
233
|
+
if (insights.frequentFiles.length > 0) {
|
|
234
|
+
lines.push("Frequently modified:");
|
|
235
|
+
for (const f of insights.frequentFiles) {
|
|
236
|
+
lines.push(` ${f.file} (${f.count}x)`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (insights.retryHotspots.length > 0) {
|
|
241
|
+
lines.push("Retry hotspots:");
|
|
242
|
+
for (const h of insights.retryHotspots) {
|
|
243
|
+
lines.push(` #${h.issueIid} (${h.retryCount} runs)`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
183
248
|
return lines.join("\n");
|
|
184
249
|
}
|
|
185
250
|
|
|
@@ -474,34 +474,18 @@ export async function runWatcherCycle(
|
|
|
474
474
|
|
|
475
475
|
const issue = await dependencies.gitlabClient.getIssue(candidate.projectId, candidate.issueIid);
|
|
476
476
|
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (mentionType === "contextual" && dependencies.agentRunner.runContextual) {
|
|
481
|
-
logger.info(`NT-4 contextual reply for issue #${issue.iid}: "${issue.title}"`);
|
|
482
|
-
await updateAgentUserStatus(dependencies, "busy", `#${issue.iid}`);
|
|
483
|
-
|
|
484
|
-
try {
|
|
485
|
-
const result = await dependencies.agentRunner.runContextual(issue, candidate.body ?? "", { todoId: candidate.id });
|
|
486
|
-
logger.info(`Contextual reply completed for #${issue.iid}: ${result.summary.slice(0, 100)}`);
|
|
487
|
-
} catch (error) {
|
|
488
|
-
logger.warn(`Contextual reply failed for #${issue.iid}: ${String(error).slice(0, 200)}`);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
await updateAgentUserStatus(dependencies, "idle", undefined, config);
|
|
492
|
-
state.processedTodoIds = rememberIds(state.processedTodoIds, candidate.id);
|
|
493
|
-
state.activeRun = undefined;
|
|
494
|
-
await dependencies.stateStore.save(state);
|
|
495
|
-
try { await dependencies.gitlabClient.markTodoDone(candidate.id); } catch { /* silent */ }
|
|
496
|
-
|
|
497
|
-
return { status: "completed", issueIid: issue.iid, summary: `Contextual reply for #${issue.iid}` };
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Label-based trigger filtering (full_work path only)
|
|
477
|
+
// Label mismatch: don't skip (P1: @mention must always get a response),
|
|
478
|
+
// but pass hint to agent so it can decide how to respond (P3: agent autonomy)
|
|
479
|
+
let labelMismatchHint: string | undefined;
|
|
501
480
|
if (triggers && !matchesTriggerLabels(issue.labels, triggers.labels, triggers.exclude_labels)) {
|
|
502
|
-
|
|
503
|
-
|
|
481
|
+
const agentName = config.agentDefinition?.name ?? "agent";
|
|
482
|
+
const required = triggers.labels?.join(", ") || "(无)";
|
|
483
|
+
const excluded = triggers.exclude_labels?.join(", ") || "(无)";
|
|
484
|
+
const actual = issue.labels.join(", ") || "(无)";
|
|
485
|
+
labelMismatchHint = `此 issue 的 label [${actual}] 不在 ${agentName} 的触发范围内(要求: [${required}],排除: [${excluded}])。你仍然需要回复这个 @mention(P1),但可以选择只回复说明而不执行代码修改。`;
|
|
486
|
+
logger.info(`Issue #${issue.iid}: label mismatch, passing hint to agent`);
|
|
504
487
|
}
|
|
488
|
+
|
|
505
489
|
const worktree = await dependencies.worktreeManager.ensureWorktree(issue.iid, issue.title);
|
|
506
490
|
|
|
507
491
|
state.activeRun = {
|
|
@@ -582,7 +566,7 @@ export async function runWatcherCycle(
|
|
|
582
566
|
|
|
583
567
|
try {
|
|
584
568
|
const raceResult = await Promise.race([
|
|
585
|
-
dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id, signal: abortController.signal }).then(r => ({ type: "completed" as const, result: r })),
|
|
569
|
+
dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id, signal: abortController.signal, labelMismatchHint }).then(r => ({ type: "completed" as const, result: r })),
|
|
586
570
|
closeDetection.then(() => ({ type: "closed" as const }))
|
|
587
571
|
]);
|
|
588
572
|
|
|
@@ -740,17 +724,21 @@ export async function runConcurrentWatcherCycle(
|
|
|
740
724
|
|
|
741
725
|
if (result.error) {
|
|
742
726
|
// Failed
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
727
|
+
const isMr = spawned.todo.targetType === "MergeRequest";
|
|
728
|
+
const targetLabel = isMr ? `MR !${issueIid}` : `issue #${issueIid}`;
|
|
729
|
+
logger.error(`Agent failed ${targetLabel} after ${durationSec}s. Error: ${result.error.slice(0, 200)}`);
|
|
730
|
+
if (!isMr) {
|
|
731
|
+
try {
|
|
732
|
+
await dependencies.gitlabClient.updateIssueLabels(
|
|
733
|
+
spawned.issue.projectId, issueIid,
|
|
734
|
+
transitionStatusLabels(spawned.issue.labels, getLabelConfig(config).error, getStatusLabelsList(config))
|
|
735
|
+
);
|
|
736
|
+
await dependencies.gitlabClient.addIssueNote(
|
|
737
|
+
spawned.issue.projectId, issueIid,
|
|
738
|
+
`⚠️ Agent 执行失败。\n\n${result.summary}`
|
|
739
|
+
);
|
|
740
|
+
} catch (e) { logger.warn(`Post-run cleanup failed: ${String(e).slice(0, 100)}`); }
|
|
741
|
+
}
|
|
754
742
|
await notifyFailed(config.agentDefinition?.name ?? "agent", issueIid, spawned.issue.title, result.summary, config.webhookUrl);
|
|
755
743
|
// Feishu: update card to failed state
|
|
756
744
|
if (dependencies.feishuClient && spawned.feishuMessageId) {
|
|
@@ -833,34 +821,35 @@ export async function runConcurrentWatcherCycle(
|
|
|
833
821
|
if (!candidate) break;
|
|
834
822
|
processedInThisCycle.add(candidate.id);
|
|
835
823
|
|
|
836
|
-
// Skip MR todos and contextual replies in concurrent mode — handle them synchronously
|
|
837
824
|
const isIssueTodo = candidate.targetType === "Issue";
|
|
838
|
-
|
|
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);
|
|
825
|
+
const targetIid = isIssueTodo ? candidate.issueIid : (candidate.targetIid ?? 0);
|
|
846
826
|
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
if (
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
827
|
+
// Build issue object — real for Issues, synthetic for MRs
|
|
828
|
+
let issue: GitlabIssue;
|
|
829
|
+
if (isIssueTodo) {
|
|
830
|
+
issue = await dependencies.gitlabClient.getIssue(candidate.projectId, candidate.issueIid);
|
|
831
|
+
} else {
|
|
832
|
+
issue = {
|
|
833
|
+
id: 0,
|
|
834
|
+
iid: targetIid,
|
|
835
|
+
projectId: candidate.projectId,
|
|
836
|
+
title: `MR !${targetIid} @mention`,
|
|
837
|
+
description: candidate.body ?? "",
|
|
838
|
+
labels: [],
|
|
839
|
+
webUrl: candidate.targetUrl ?? ""
|
|
840
|
+
};
|
|
841
|
+
logger.info(`MR todo !${targetIid}: spawning into slot`);
|
|
858
842
|
}
|
|
859
843
|
|
|
860
|
-
// Label
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
844
|
+
// Label mismatch: pass hint to agent (P1: always respond, P3: agent decides)
|
|
845
|
+
let labelMismatchHint: string | undefined;
|
|
846
|
+
if (isIssueTodo && triggers && !matchesTriggerLabels(issue.labels, triggers.labels, triggers.exclude_labels)) {
|
|
847
|
+
const agentName = config.agentDefinition?.name ?? "agent";
|
|
848
|
+
const required = triggers.labels?.join(", ") || "(无)";
|
|
849
|
+
const excluded = triggers.exclude_labels?.join(", ") || "(无)";
|
|
850
|
+
const actual = issue.labels.join(", ") || "(无)";
|
|
851
|
+
labelMismatchHint = `此 issue 的 label [${actual}] 不在 ${agentName} 的触发范围内(要求: [${required}],排除: [${excluded}])。你仍然需要回复这个 @mention(P1),但可以选择只回复说明而不执行代码修改。`;
|
|
852
|
+
logger.info(`Issue #${issue.iid}: label mismatch, passing hint to agent`);
|
|
864
853
|
}
|
|
865
854
|
|
|
866
855
|
// Ensure spawn() is available
|
|
@@ -870,7 +859,8 @@ export async function runConcurrentWatcherCycle(
|
|
|
870
859
|
}
|
|
871
860
|
|
|
872
861
|
// Prepare and spawn
|
|
873
|
-
const
|
|
862
|
+
const worktreeTitle = isIssueTodo ? issue.title : `mr-${targetIid}`;
|
|
863
|
+
const worktree = await dependencies.worktreeManager.ensureWorktree(targetIid, worktreeTitle);
|
|
874
864
|
|
|
875
865
|
const skills = config.agentDefinition?.skills ?? [];
|
|
876
866
|
if (skills.length > 0) {
|
|
@@ -880,7 +870,7 @@ export async function runConcurrentWatcherCycle(
|
|
|
880
870
|
} catch (err) { logger.warn(`Failed to inject skill files: ${String(err)}`); }
|
|
881
871
|
}
|
|
882
872
|
|
|
883
|
-
const spawnedRun = await dependencies.agentRunner.spawn(issue, worktree, { todoId: candidate.id });
|
|
873
|
+
const spawnedRun = await dependencies.agentRunner.spawn(issue, worktree, { todoId: candidate.id, labelMismatchHint });
|
|
884
874
|
const now = new Date().toISOString();
|
|
885
875
|
|
|
886
876
|
spawnedRuns.set(issue.iid, {
|
|
@@ -919,7 +909,7 @@ export async function runConcurrentWatcherCycle(
|
|
|
919
909
|
}
|
|
920
910
|
|
|
921
911
|
// Set up progress callback for webhook and/or feishu card updates
|
|
922
|
-
if (
|
|
912
|
+
if (config.webhookUrl || dependencies.feishuClient) {
|
|
923
913
|
const agentName = config.agentDefinition?.name ?? "agent";
|
|
924
914
|
const issueRef = `#${issue.iid}`;
|
|
925
915
|
const capturedIssueIid = issue.iid;
|
|
@@ -1057,7 +1047,9 @@ async function finalizeTodo(
|
|
|
1057
1047
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
1058
1048
|
|
|
1059
1049
|
// P11 post-run compensation: agent is probabilistic, verify critical state transitions
|
|
1060
|
-
|
|
1050
|
+
// Skip for MR todos — MRs don't have issue labels/notes to compensate
|
|
1051
|
+
const isMrTodo = todo.targetType === "MergeRequest";
|
|
1052
|
+
if (result.status === "completed" && !isMrTodo) {
|
|
1061
1053
|
try {
|
|
1062
1054
|
const freshIssue = await dependencies.gitlabClient.getIssue(issue.projectId, issue.iid);
|
|
1063
1055
|
const logger = dependencies.logger ?? console;
|
|
@@ -1087,23 +1079,25 @@ async function finalizeTodo(
|
|
|
1087
1079
|
}
|
|
1088
1080
|
}
|
|
1089
1081
|
|
|
1090
|
-
// NT-1: Post structured run summary note to GitLab
|
|
1082
|
+
// NT-1: Post structured run summary note to GitLab (history visible in GitLab)
|
|
1091
1083
|
// Posted AFTER agent reply and P11 compensation so it appears last in the timeline
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1084
|
+
if (!isMrTodo) {
|
|
1085
|
+
try {
|
|
1086
|
+
const durationStr = durationMs < 60000
|
|
1087
|
+
? `${Math.round(durationMs / 1000)}s`
|
|
1088
|
+
: `${Math.round(durationMs / 60000)}m ${Math.round((durationMs % 60000) / 1000)}s`;
|
|
1089
|
+
const statusEmoji = result.status === "completed" ? "✅" : "❌";
|
|
1090
|
+
const noteBody = [
|
|
1091
|
+
`${statusEmoji} **Agent run ${result.status}** (${durationStr})`,
|
|
1092
|
+
"",
|
|
1093
|
+
result.summary.slice(0, 500),
|
|
1094
|
+
"",
|
|
1095
|
+
`<!-- agent-run-log ${JSON.stringify({ agent: _config.agentDefinition?.name, status: result.status, startedAt, finishedAt, durationMs, branch: result.branch })} -->`
|
|
1096
|
+
].join("\n");
|
|
1097
|
+
await dependencies.gitlabClient.addIssueNote(issue.projectId, issue.iid, noteBody);
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
(dependencies.logger ?? console).warn(`Failed to post run summary note: ${String(err)}`);
|
|
1100
|
+
}
|
|
1107
1101
|
}
|
|
1108
1102
|
|
|
1109
1103
|
appendRunHistory(state, {
|
|
@@ -1511,13 +1505,17 @@ function pickNextTodo(
|
|
|
1511
1505
|
const acceptedActions = triggerActions ?? ACCEPTED_TODO_ACTIONS;
|
|
1512
1506
|
|
|
1513
1507
|
const candidates = todos.filter((todo) => {
|
|
1508
|
+
const todoLabel = todo.targetType === "MergeRequest"
|
|
1509
|
+
? `MR !${todo.targetIid ?? 0}`
|
|
1510
|
+
: `issue #${todo.issueIid}`;
|
|
1511
|
+
|
|
1514
1512
|
if (processedTodos.has(todo.id)) {
|
|
1515
|
-
logger?.info?.(`Todo ${todo.id} (
|
|
1513
|
+
logger?.info?.(`Todo ${todo.id} (${todoLabel}): skipped — already processed`);
|
|
1516
1514
|
return false;
|
|
1517
1515
|
}
|
|
1518
1516
|
|
|
1519
1517
|
if (!acceptedActions.has(todo.actionName)) {
|
|
1520
|
-
logger?.info?.(`Todo ${todo.id} (
|
|
1518
|
+
logger?.info?.(`Todo ${todo.id} (${todoLabel}): skipped — action "${todo.actionName}" not in triggers`);
|
|
1521
1519
|
return false;
|
|
1522
1520
|
}
|
|
1523
1521
|
|
|
@@ -1525,8 +1523,10 @@ function pickNextTodo(
|
|
|
1525
1523
|
return false;
|
|
1526
1524
|
}
|
|
1527
1525
|
|
|
1528
|
-
|
|
1529
|
-
|
|
1526
|
+
// For MR todos, check by targetIid; for Issues, check by issueIid
|
|
1527
|
+
const effectiveIid = todo.targetType === "MergeRequest" ? (todo.targetIid ?? 0) : todo.issueIid;
|
|
1528
|
+
if (activeIssueIids?.has(effectiveIid)) {
|
|
1529
|
+
logger?.info?.(`Todo ${todo.id} (${todoLabel}): skipped — already being processed`);
|
|
1530
1530
|
return false;
|
|
1531
1531
|
}
|
|
1532
1532
|
|