@calltelemetry/openclaw-linear 0.8.1 → 0.8.3

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.
@@ -29,10 +29,20 @@ interface AgentProfile {
29
29
 
30
30
  const PROFILES_PATH = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
31
31
 
32
+ // ── Cached profile loader (5s TTL) ─────────────────────────────────
33
+ let profilesCache: { data: Record<string, AgentProfile>; loadedAt: number } | null = null;
34
+ const PROFILES_CACHE_TTL_MS = 5_000;
35
+
32
36
  function loadAgentProfiles(): Record<string, AgentProfile> {
37
+ const now = Date.now();
38
+ if (profilesCache && now - profilesCache.loadedAt < PROFILES_CACHE_TTL_MS) {
39
+ return profilesCache.data;
40
+ }
33
41
  try {
34
42
  const raw = readFileSync(PROFILES_PATH, "utf8");
35
- return JSON.parse(raw).agents ?? {};
43
+ const data = JSON.parse(raw).agents ?? {};
44
+ profilesCache = { data, loadedAt: now };
45
+ return data;
36
46
  } catch {
37
47
  return {};
38
48
  }
@@ -64,19 +74,45 @@ function resolveAgentFromAlias(alias: string, profiles: Record<string, AgentProf
64
74
  // Track issues with active agent runs to prevent concurrent duplicate runs.
65
75
  const activeRuns = new Set<string>();
66
76
 
67
- // Dedup: track recently processed keys to avoid double-handling
77
+ // Dedup: track recently processed keys to avoid double-handling.
78
+ // Periodic sweep (every 10s) instead of O(n) scan on every call.
68
79
  const recentlyProcessed = new Map<string, number>();
80
+ const DEDUP_TTL_MS = 60_000;
81
+ const SWEEP_INTERVAL_MS = 10_000;
82
+ let lastSweep = Date.now();
83
+
69
84
  function wasRecentlyProcessed(key: string): boolean {
70
85
  const now = Date.now();
71
- // Clean old entries
72
- for (const [k, ts] of recentlyProcessed) {
73
- if (now - ts > 60_000) recentlyProcessed.delete(k);
86
+ if (now - lastSweep > SWEEP_INTERVAL_MS) {
87
+ for (const [k, ts] of recentlyProcessed) {
88
+ if (now - ts > DEDUP_TTL_MS) recentlyProcessed.delete(k);
89
+ }
90
+ lastSweep = now;
74
91
  }
75
92
  if (recentlyProcessed.has(key)) return true;
76
93
  recentlyProcessed.set(key, now);
77
94
  return false;
78
95
  }
79
96
 
97
+ /** @internal — test-only; clears all in-memory dedup state. */
98
+ export function _resetForTesting(): void {
99
+ activeRuns.clear();
100
+ recentlyProcessed.clear();
101
+ profilesCache = null;
102
+ linearApiCache = null;
103
+ lastSweep = Date.now();
104
+ }
105
+
106
+ /** @internal — test-only; add an issue ID to the activeRuns set. */
107
+ export function _addActiveRunForTesting(issueId: string): void {
108
+ activeRuns.add(issueId);
109
+ }
110
+
111
+ /** @internal — test-only; pre-registers a key as recently processed. */
112
+ export function _markAsProcessedForTesting(key: string): void {
113
+ wasRecentlyProcessed(key);
114
+ }
115
+
80
116
  async function readJsonBody(req: IncomingMessage, maxBytes: number) {
81
117
  const chunks: Buffer[] = [];
82
118
  let total = 0;
@@ -101,7 +137,16 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
101
137
  });
102
138
  }
103
139
 
140
+ // ── Cached LinearApi instance (30s TTL) ────────────────────────────
141
+ let linearApiCache: { instance: LinearAgentApi; createdAt: number } | null = null;
142
+ const LINEAR_API_CACHE_TTL_MS = 30_000;
143
+
104
144
  function createLinearApi(api: OpenClawPluginApi): LinearAgentApi | null {
145
+ const now = Date.now();
146
+ if (linearApiCache && now - linearApiCache.createdAt < LINEAR_API_CACHE_TTL_MS) {
147
+ return linearApiCache.instance;
148
+ }
149
+
105
150
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
106
151
  const resolved = resolveLinearToken(pluginConfig);
107
152
 
@@ -110,12 +155,54 @@ function createLinearApi(api: OpenClawPluginApi): LinearAgentApi | null {
110
155
  const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
111
156
  const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
112
157
 
113
- return new LinearAgentApi(resolved.accessToken, {
158
+ const instance = new LinearAgentApi(resolved.accessToken, {
114
159
  refreshToken: resolved.refreshToken,
115
160
  expiresAt: resolved.expiresAt,
116
161
  clientId: clientId ?? undefined,
117
162
  clientSecret: clientSecret ?? undefined,
118
163
  });
164
+ linearApiCache = { instance, createdAt: now };
165
+ return instance;
166
+ }
167
+
168
+ // ── Comment wrapper that pre-registers comment ID for dedup ────────
169
+ // When we create a comment, Linear fires Comment.create webhook back to us.
170
+ // Register the comment ID immediately so the webhook handler skips it.
171
+ // The `opts` parameter posts as a named OpenClaw agent identity (e.g.
172
+ // createAsUser: "Mal" with avatar) — requires OAuth actor=app scope.
173
+ async function createCommentWithDedup(
174
+ linearApi: LinearAgentApi,
175
+ issueId: string,
176
+ body: string,
177
+ opts?: { createAsUser?: string; displayIconUrl?: string },
178
+ ): Promise<string> {
179
+ const commentId = await linearApi.createComment(issueId, body, opts);
180
+ wasRecentlyProcessed(`comment:${commentId}`);
181
+ return commentId;
182
+ }
183
+
184
+ /**
185
+ * Post a comment as agent identity with prefix fallback.
186
+ * With gql() partial-success fix, the catch only fires for real failures.
187
+ */
188
+ async function postAgentComment(
189
+ api: OpenClawPluginApi,
190
+ linearApi: LinearAgentApi,
191
+ issueId: string,
192
+ body: string,
193
+ label: string,
194
+ agentOpts?: { createAsUser: string; displayIconUrl: string },
195
+ ): Promise<void> {
196
+ if (!agentOpts) {
197
+ await createCommentWithDedup(linearApi, issueId, `**[${label}]** ${body}`);
198
+ return;
199
+ }
200
+ try {
201
+ await createCommentWithDedup(linearApi, issueId, body, agentOpts);
202
+ } catch (identityErr) {
203
+ api.logger.warn(`Agent identity comment failed: ${identityErr}`);
204
+ await createCommentWithDedup(linearApi, issueId, `**[${label}]** ${body}`);
205
+ }
119
206
  }
120
207
 
121
208
  function resolveAgentId(api: OpenClawPluginApi): string {
@@ -155,158 +242,14 @@ export async function handleLinearWebhook(
155
242
  emitDiagnostic(api, { event: "webhook_received", webhookType: payload.type, webhookAction: payload.action });
156
243
 
157
244
 
158
- // ── AppUserNotification — OAuth app webhook for agent mentions/assignments
245
+ // ── AppUserNotification — IGNORED ─────────────────────────────────
246
+ // AppUserNotification duplicates events already handled by the workspace
247
+ // webhook (Comment.create for mentions, Issue.update for assignments).
248
+ // Processing both causes double agent runs. Ack and discard.
159
249
  if (payload.type === "AppUserNotification") {
250
+ api.logger.info(`AppUserNotification ignored (duplicate of workspace webhook): ${payload.notification?.type} appUserId=${payload.appUserId}`);
160
251
  res.statusCode = 200;
161
252
  res.end("ok");
162
-
163
- const notification = payload.notification;
164
- const notifType = notification?.type;
165
- api.logger.info(`AppUserNotification: ${notifType} appUserId=${payload.appUserId}`);
166
-
167
- const issue = notification?.issue;
168
- const comment = notification?.comment ?? notification?.parentComment;
169
-
170
- if (!issue?.id) {
171
- api.logger.error("AppUserNotification missing issue data");
172
- return true;
173
- }
174
-
175
- const linearApi = createLinearApi(api);
176
- if (!linearApi) {
177
- api.logger.error("No Linear access token — cannot process agent notification");
178
- return true;
179
- }
180
-
181
- const agentId = resolveAgentId(api);
182
-
183
- // Fetch full issue details
184
- let enrichedIssue: any = issue;
185
- try {
186
- enrichedIssue = await linearApi.getIssueDetails(issue.id);
187
- } catch (err) {
188
- api.logger.warn(`Could not fetch issue details: ${err}`);
189
- }
190
-
191
- const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
192
- const comments = enrichedIssue?.comments?.nodes ?? [];
193
- const commentSummary = comments
194
- .slice(-5)
195
- .map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
196
- .join("\n");
197
-
198
- const notifIssueRef = enrichedIssue?.identifier ?? issue.id;
199
- const message = [
200
- `You are an orchestrator responding to a Linear issue notification. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
201
- ``,
202
- `**Tool access:**`,
203
- `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${notifIssueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
204
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
205
- `- Standard tools: exec, read, edit, write, web_search, etc.`,
206
- ``,
207
- `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
208
- ``,
209
- `## Issue: ${notifIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
210
- `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
211
- ``,
212
- `**Description:**`,
213
- description,
214
- commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
215
- comment?.body ? `\n**Triggering comment:**\n> ${comment.body}` : "",
216
- ``,
217
- `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
218
- ].filter(Boolean).join("\n");
219
-
220
- // Dispatch agent with session lifecycle (non-blocking)
221
- void (async () => {
222
- const profiles = loadAgentProfiles();
223
- const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
224
- const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
225
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
226
- let agentSessionId: string | null = null;
227
-
228
- try {
229
- // 1. Create agent session (non-fatal)
230
- const sessionResult = await linearApi.createSessionOnIssue(issue.id);
231
- agentSessionId = sessionResult.sessionId;
232
- if (agentSessionId) {
233
- api.logger.info(`Created agent session ${agentSessionId} for notification`);
234
- setActiveSession({
235
- agentSessionId,
236
- issueIdentifier: enrichedIssue?.identifier ?? issue.id,
237
- issueId: issue.id,
238
- agentId,
239
- startedAt: Date.now(),
240
- });
241
- } else {
242
- api.logger.warn(`Could not create agent session for notification: ${sessionResult.error ?? "unknown"}`);
243
- }
244
-
245
- // 2. Emit thought
246
- if (agentSessionId) {
247
- await linearApi.emitActivity(agentSessionId, {
248
- type: "thought",
249
- body: `Reviewing ${enrichedIssue?.identifier ?? issue.id}...`,
250
- }).catch(() => {});
251
- }
252
-
253
- // 3. Run agent with streaming
254
- const sessionId = `linear-notif-${notification?.type ?? "unknown"}-${Date.now()}`;
255
- const { runAgent } = await import("../agent/agent.js");
256
- const result = await runAgent({
257
- api,
258
- agentId,
259
- sessionId,
260
- message,
261
- timeoutMs: 3 * 60_000,
262
- streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
263
- });
264
-
265
- const responseBody = result.success
266
- ? result.output
267
- : `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
268
-
269
- // 5. Post branded comment (fallback to prefix)
270
- const brandingOpts = avatarUrl
271
- ? { createAsUser: label, displayIconUrl: avatarUrl }
272
- : undefined;
273
-
274
- try {
275
- if (brandingOpts) {
276
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
277
- } else {
278
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
279
- }
280
- } catch (brandErr) {
281
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
282
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
283
- }
284
-
285
- // 6. Emit response (closes session)
286
- if (agentSessionId) {
287
- const truncated = responseBody.length > 2000
288
- ? responseBody.slice(0, 2000) + "…"
289
- : responseBody;
290
- await linearApi.emitActivity(agentSessionId, {
291
- type: "response",
292
- body: truncated,
293
- }).catch(() => {});
294
- }
295
-
296
- api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id}`);
297
- } catch (err) {
298
- api.logger.error(`AppUserNotification handler error: ${err}`);
299
- if (agentSessionId) {
300
- await linearApi.emitActivity(agentSessionId, {
301
- type: "error",
302
- body: `Failed to process notification: ${String(err).slice(0, 500)}`,
303
- }).catch(() => {});
304
- }
305
- } finally {
306
- clearActiveSession(issue.id);
307
- }
308
- })();
309
-
310
253
  return true;
311
254
  }
312
255
 
@@ -330,7 +273,17 @@ export async function handleLinearWebhook(
330
273
  return true;
331
274
  }
332
275
 
333
- // Dedup: skip if we already handled this session
276
+ // Guard: check activeRuns FIRST (O(1), no side effects).
277
+ // This catches sessions created by our own handlers (Comment dispatch,
278
+ // Issue triage, handleDispatch) which all set activeRuns BEFORE calling
279
+ // createSessionOnIssue(). Checking this first prevents the race condition
280
+ // where the webhook arrives before wasRecentlyProcessed is registered.
281
+ if (activeRuns.has(issue.id)) {
282
+ api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
283
+ return true;
284
+ }
285
+
286
+ // Secondary dedup: skip if we already handled this exact session ID
334
287
  if (wasRecentlyProcessed(`session:${session.id}`)) {
335
288
  api.logger.info(`AgentSession ${session.id} already handled — skipping`);
336
289
  return true;
@@ -346,13 +299,7 @@ export async function handleLinearWebhook(
346
299
  const previousComments = payload.previousComments ?? [];
347
300
  const guidance = payload.guidance;
348
301
 
349
- api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`);
350
-
351
- // Guard: skip if an agent run is already active for this issue
352
- if (activeRuns.has(issue.id)) {
353
- api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
354
- return true;
355
- }
302
+ api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`)
356
303
 
357
304
  // Extract the user's latest message from previousComments
358
305
  // The last comment is the most recent user message
@@ -442,31 +389,21 @@ export async function handleLinearWebhook(
442
389
  ? result.output
443
390
  : `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
444
391
 
445
- // Post as comment
446
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
447
- const brandingOpts = avatarUrl
448
- ? { createAsUser: label, displayIconUrl: avatarUrl }
449
- : undefined;
450
-
451
- try {
452
- if (brandingOpts) {
453
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
454
- } else {
455
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
456
- }
457
- } catch (brandErr) {
458
- api.logger.warn(`Branded comment failed: ${brandErr}`);
459
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
460
- }
461
-
462
- // Emit response (closes session)
463
- const truncated = responseBody.length > 2000
464
- ? responseBody.slice(0, 2000) + "\u2026"
465
- : responseBody;
466
- await linearApi.emitActivity(session.id, {
392
+ // Emit response via session (preferred — avoids duplicate comment).
393
+ // Fall back to a regular comment only if emitActivity fails.
394
+ const labeledResponse = `**[${label}]** ${responseBody}`;
395
+ const emitted = await linearApi.emitActivity(session.id, {
467
396
  type: "response",
468
- body: truncated,
469
- }).catch(() => {});
397
+ body: labeledResponse,
398
+ }).then(() => true).catch(() => false);
399
+
400
+ if (!emitted) {
401
+ const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
402
+ const agentOpts = avatarUrl
403
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
404
+ : undefined;
405
+ await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
406
+ }
470
407
 
471
408
  api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
472
409
  } catch (err) {
@@ -620,29 +557,20 @@ export async function handleLinearWebhook(
620
557
  ? result.output
621
558
  : `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
622
559
 
623
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
624
- const brandingOpts = avatarUrl
625
- ? { createAsUser: label, displayIconUrl: avatarUrl }
626
- : undefined;
627
-
628
- try {
629
- if (brandingOpts) {
630
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
631
- } else {
632
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
633
- }
634
- } catch (brandErr) {
635
- api.logger.warn(`Branded comment failed: ${brandErr}`);
636
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
637
- }
638
-
639
- const truncated = responseBody.length > 2000
640
- ? responseBody.slice(0, 2000) + "\u2026"
641
- : responseBody;
642
- await linearApi.emitActivity(session.id, {
560
+ // Emit response via session (preferred). Fall back to comment if it fails.
561
+ const labeledResponse = `**[${label}]** ${responseBody}`;
562
+ const emitted = await linearApi.emitActivity(session.id, {
643
563
  type: "response",
644
- body: truncated,
645
- }).catch(() => {});
564
+ body: labeledResponse,
565
+ }).then(() => true).catch(() => false);
566
+
567
+ if (!emitted) {
568
+ const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
569
+ const agentOpts = avatarUrl
570
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
571
+ : undefined;
572
+ await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
573
+ }
646
574
 
647
575
  api.logger.info(`Posted follow-up response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
648
576
  } catch (err) {
@@ -697,6 +625,14 @@ export async function handleLinearWebhook(
697
625
  }
698
626
  } catch { /* proceed if viewerId check fails */ }
699
627
 
628
+ // Early guard: skip if an agent run is already active for this issue.
629
+ // Avoids wasted LLM intent classification (~2-5s) when result would
630
+ // be discarded anyway by activeRuns check in dispatchCommentToAgent().
631
+ if (activeRuns.has(issue.id)) {
632
+ api.logger.info(`Comment on ${issue.identifier ?? issue.id}: active run — skipping`);
633
+ return true;
634
+ }
635
+
700
636
  // Load agent profiles
701
637
  const profiles = loadAgentProfiles();
702
638
  const agentNames = Object.keys(profiles);
@@ -791,7 +727,7 @@ export async function handleLinearWebhook(
791
727
  void (async () => {
792
728
  try {
793
729
  await endPlanningSession(planSession.projectId, "approved", planStatePath);
794
- await linearApi.createComment(
730
+ await createCommentWithDedup(linearApi,
795
731
  planSession.rootIssueId,
796
732
  `## Plan Approved\n\nPlan for **${planSession.projectName}** has been approved. Dispatching to workers.`,
797
733
  );
@@ -824,7 +760,7 @@ export async function handleLinearWebhook(
824
760
  void (async () => {
825
761
  try {
826
762
  await endPlanningSession(planSession.projectId, "abandoned", planStatePath);
827
- await linearApi.createComment(
763
+ await createCommentWithDedup(linearApi,
828
764
  planSession.rootIssueId,
829
765
  `Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
830
766
  );
@@ -884,6 +820,16 @@ export async function handleLinearWebhook(
884
820
  res.end("ok");
885
821
 
886
822
  const issue = payload.data;
823
+
824
+ // Guard: check activeRuns FIRST (synchronous, O(1)) before any async work.
825
+ // Linear can send duplicate Issue.update webhooks <20ms apart for the same
826
+ // assignment change. Without this sync guard, both pass through the async
827
+ // getViewerId() call before either registers with wasRecentlyProcessed().
828
+ if (activeRuns.has(issue?.id)) {
829
+ api.logger.info(`Issue.update ${issue?.identifier ?? issue?.id}: active run — skipping`);
830
+ return true;
831
+ }
832
+
887
833
  const updatedFrom = payload.updatedFrom ?? {};
888
834
 
889
835
  // Check both assigneeId and delegateId — Linear uses delegateId for agent delegation
@@ -921,7 +867,8 @@ export async function handleLinearWebhook(
921
867
  const trigger = isDelegatedToUs ? "delegated" : "assigned";
922
868
  api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
923
869
 
924
- // Dedup on assignment/delegation
870
+ // Secondary dedup: catch duplicate webhooks that both passed the activeRuns
871
+ // check before either could register (belt-and-suspenders with the sync guard).
925
872
  const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
926
873
  if (wasRecentlyProcessed(dedupKey)) {
927
874
  api.logger.info(`${trigger} ${issue.id} -> ${viewerId} already processed — skipping`);
@@ -965,6 +912,14 @@ export async function handleLinearWebhook(
965
912
 
966
913
  const agentId = resolveAgentId(api);
967
914
 
915
+ // Guard: prevent duplicate runs on same issue (also blocks AgentSessionEvent
916
+ // webhooks that arrive from sessions we create during triage)
917
+ if (activeRuns.has(issue.id)) {
918
+ api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} already has active run — skipping triage`);
919
+ return true;
920
+ }
921
+ activeRuns.add(issue.id);
922
+
968
923
  // Dispatch triage (non-blocking)
969
924
  void (async () => {
970
925
  const profiles = loadAgentProfiles();
@@ -1031,7 +986,7 @@ export async function handleLinearWebhook(
1031
986
  if (agentSessionId) {
1032
987
  await linearApi.emitActivity(agentSessionId, {
1033
988
  type: "thought",
1034
- body: `Triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
989
+ body: `${label} is triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
1035
990
  }).catch(() => {});
1036
991
  }
1037
992
 
@@ -1087,6 +1042,10 @@ export async function handleLinearWebhook(
1087
1042
  message,
1088
1043
  timeoutMs: 3 * 60_000,
1089
1044
  streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
1045
+ // Triage is strictly read-only: the agent can read/search the
1046
+ // codebase but all write-capable tools are denied via config
1047
+ // policy. The only artifacts are a Linear comment + issue updates.
1048
+ readOnly: true,
1090
1049
  });
1091
1050
 
1092
1051
  const responseBody = result.success
@@ -1135,30 +1094,26 @@ export async function handleLinearWebhook(
1135
1094
  }
1136
1095
  }
1137
1096
 
1138
- // Post branded triage comment
1139
- const brandingOpts = avatarUrl
1140
- ? { createAsUser: label, displayIconUrl: avatarUrl }
1141
- : undefined;
1142
-
1143
- try {
1144
- if (brandingOpts) {
1145
- await linearApi.createComment(issue.id, commentBody, brandingOpts);
1146
- } else {
1147
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1148
- }
1149
- } catch (brandErr) {
1150
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1151
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1152
- }
1153
-
1097
+ // When a session exists, prefer emitActivity (avoids duplicate comment).
1098
+ // Otherwise, post as a regular comment.
1154
1099
  if (agentSessionId) {
1155
- const truncated = commentBody.length > 2000
1156
- ? commentBody.slice(0, 2000) + "…"
1157
- : commentBody;
1158
- await linearApi.emitActivity(agentSessionId, {
1100
+ const labeledComment = `**[${label}]** ${commentBody}`;
1101
+ const emitted = await linearApi.emitActivity(agentSessionId, {
1159
1102
  type: "response",
1160
- body: truncated,
1161
- }).catch(() => {});
1103
+ body: labeledComment,
1104
+ }).then(() => true).catch(() => false);
1105
+
1106
+ if (!emitted) {
1107
+ const agentOpts = avatarUrl
1108
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1109
+ : undefined;
1110
+ await postAgentComment(api, linearApi, issue.id, commentBody, label, agentOpts);
1111
+ }
1112
+ } else {
1113
+ const agentOpts = avatarUrl
1114
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1115
+ : undefined;
1116
+ await postAgentComment(api, linearApi, issue.id, commentBody, label, agentOpts);
1162
1117
  }
1163
1118
 
1164
1119
  api.logger.info(`Triage complete for ${enrichedIssue?.identifier ?? issue.id}`);
@@ -1172,6 +1127,7 @@ export async function handleLinearWebhook(
1172
1127
  }
1173
1128
  } finally {
1174
1129
  clearActiveSession(issue.id);
1130
+ activeRuns.delete(issue.id);
1175
1131
  }
1176
1132
  })();
1177
1133
 
@@ -1271,7 +1227,7 @@ async function dispatchCommentToAgent(
1271
1227
  if (agentSessionId) {
1272
1228
  await linearApi.emitActivity(agentSessionId, {
1273
1229
  type: "thought",
1274
- body: `Processing comment on ${issueRef}...`,
1230
+ body: `${label} is processing comment on ${issueRef}...`,
1275
1231
  }).catch(() => {});
1276
1232
  }
1277
1233
 
@@ -1291,31 +1247,26 @@ async function dispatchCommentToAgent(
1291
1247
  ? result.output
1292
1248
  : `Something went wrong while processing this. The system will retry automatically if possible.`;
1293
1249
 
1294
- // Post branded comment
1295
- const brandingOpts = avatarUrl
1296
- ? { createAsUser: label, displayIconUrl: avatarUrl }
1297
- : undefined;
1298
-
1299
- try {
1300
- if (brandingOpts) {
1301
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
1302
- } else {
1303
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1304
- }
1305
- } catch (brandErr) {
1306
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1307
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1308
- }
1309
-
1310
- // Emit response (closes session)
1250
+ // When a session exists, prefer emitActivity (avoids duplicate comment).
1251
+ // Otherwise, post as a regular comment.
1311
1252
  if (agentSessionId) {
1312
- const truncated = responseBody.length > 2000
1313
- ? responseBody.slice(0, 2000) + "…"
1314
- : responseBody;
1315
- await linearApi.emitActivity(agentSessionId, {
1253
+ const labeledResponse = `**[${label}]** ${responseBody}`;
1254
+ const emitted = await linearApi.emitActivity(agentSessionId, {
1316
1255
  type: "response",
1317
- body: truncated,
1318
- }).catch(() => {});
1256
+ body: labeledResponse,
1257
+ }).then(() => true).catch(() => false);
1258
+
1259
+ if (!emitted) {
1260
+ const agentOpts = avatarUrl
1261
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1262
+ : undefined;
1263
+ await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
1264
+ }
1265
+ } else {
1266
+ const agentOpts = avatarUrl
1267
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1268
+ : undefined;
1269
+ await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
1319
1270
  }
1320
1271
 
1321
1272
  api.logger.info(`Posted ${agentId} response to ${issueRef}`);
@@ -1361,7 +1312,7 @@ async function handleDispatch(
1361
1312
  const planState = await readPlanningState(planStatePath);
1362
1313
  if (isInPlanningMode(planState, planProjectId)) {
1363
1314
  api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
1364
- await linearApi.createComment(
1315
+ await createCommentWithDedup(linearApi,
1365
1316
  issue.id,
1366
1317
  `**Can't dispatch yet** — this project is in planning mode.\n\n**To continue:** Comment on the planning issue with your requirements, then say **"finalize plan"** when ready.\n\n**To cancel planning:** Comment **"abandon"** on the planning issue.`,
1367
1318
  );
@@ -1384,7 +1335,7 @@ async function handleDispatch(
1384
1335
  if (!isStale && inMemory) {
1385
1336
  // Truly still running in this gateway process
1386
1337
  api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
1387
- await linearApi.createComment(
1338
+ await createCommentWithDedup(linearApi,
1388
1339
  issue.id,
1389
1340
  `**Already running** as **${existing.tier}** — status: **${existing.status}**, started ${Math.round(ageMs / 60_000)}m ago.\n\nWorktree: \`${existing.worktreePath}\`\n\n**Options:**\n- Check progress: \`/dispatch status ${identifier}\`\n- Force restart: \`/dispatch retry ${identifier}\` (only works when stuck)\n- Escalate: \`/dispatch escalate ${identifier} "reason"\``,
1390
1341
  );
@@ -1456,7 +1407,7 @@ async function handleDispatch(
1456
1407
  }
1457
1408
  } catch (err) {
1458
1409
  api.logger.error(`@dispatch: worktree creation failed: ${err}`);
1459
- await linearApi.createComment(
1410
+ await createCommentWithDedup(linearApi,
1460
1411
  issue.id,
1461
1412
  `**Dispatch failed** — couldn't create the worktree.\n\n> ${String(err).slice(0, 200)}\n\n**What to try:**\n- Check that the base repo exists\n- Re-assign this issue to try again\n- Check logs: \`journalctl --user -u openclaw-gateway --since "5 min ago"\``,
1462
1413
  );
@@ -1481,6 +1432,9 @@ async function handleDispatch(
1481
1432
  }
1482
1433
 
1483
1434
  // 6. Create agent session on Linear
1435
+ // Mark active BEFORE session creation so that any AgentSessionEvent.created
1436
+ // webhook arriving from this call is blocked by the activeRuns guard.
1437
+ activeRuns.add(issue.id);
1484
1438
  let agentSessionId: string | undefined;
1485
1439
  try {
1486
1440
  const sessionResult = await linearApi.createSessionOnIssue(issue.id);
@@ -1557,7 +1511,7 @@ async function handleDispatch(
1557
1511
  `- All dispatches: \`/dispatch list\``,
1558
1512
  ].join("\n");
1559
1513
 
1560
- await linearApi.createComment(issue.id, statusComment);
1514
+ await createCommentWithDedup(linearApi, issue.id, statusComment);
1561
1515
 
1562
1516
  if (agentSessionId) {
1563
1517
  await linearApi.emitActivity(agentSessionId, {
@@ -1583,7 +1537,7 @@ async function handleDispatch(
1583
1537
  }
1584
1538
 
1585
1539
  // 11. Run v2 pipeline: worker → audit → verdict (non-blocking)
1586
- activeRuns.add(issue.id);
1540
+ // (activeRuns already set in step 6 above)
1587
1541
 
1588
1542
  // Instantiate notifier (Discord, Slack, or both — config-driven)
1589
1543
  const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);