glab-agent 0.2.10 → 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 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.10",
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
 
@@ -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
- // NT-4: Three-tier routing classify @mention before trigger label check
478
- const mentionType = classifyMention(candidate.body ?? "", issue.title, issue.labels, issue.description);
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
- logger.info(`Issue #${issue.iid} does not match trigger labels for agent "${config.agentDefinition?.name}", skipping.`);
503
- return { status: "idle" };
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
- 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)}`); }
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
- 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);
825
+ const targetIid = isIssueTodo ? candidate.issueIid : (candidate.targetIid ?? 0);
846
826
 
847
- // Check mention typecontextual 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;
827
+ // Build issue objectreal 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 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;
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 worktree = await dependencies.worktreeManager.ensureWorktree(issue.iid, issue.title);
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, {
@@ -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
- if (result.status === "completed") {
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 issue (history visible in 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
- try {
1093
- const durationStr = durationMs < 60000
1094
- ? `${Math.round(durationMs / 1000)}s`
1095
- : `${Math.round(durationMs / 60000)}m ${Math.round((durationMs % 60000) / 1000)}s`;
1096
- const statusEmoji = result.status === "completed" ? "✅" : "❌";
1097
- const noteBody = [
1098
- `${statusEmoji} **Agent run ${result.status}** (${durationStr})`,
1099
- "",
1100
- result.summary.slice(0, 500),
1101
- "",
1102
- `<!-- agent-run-log ${JSON.stringify({ agent: _config.agentDefinition?.name, status: result.status, startedAt, finishedAt, durationMs, branch: result.branch })} -->`
1103
- ].join("\n");
1104
- await dependencies.gitlabClient.addIssueNote(issue.projectId, issue.iid, noteBody);
1105
- } catch (err) {
1106
- (dependencies.logger ?? console).warn(`Failed to post run summary note: ${String(err)}`);
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} (issue #${todo.issueIid}): skipped — already processed`);
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} (issue #${todo.issueIid}): skipped — action "${todo.actionName}" not in triggers`);
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
- if (activeIssueIids?.has(todo.issueIid)) {
1529
- logger?.info?.(`Todo ${todo.id} (issue #${todo.issueIid}): skipped already being processed`);
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