@calltelemetry/openclaw-linear 0.8.2 → 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 {
@@ -186,7 +273,17 @@ export async function handleLinearWebhook(
186
273
  return true;
187
274
  }
188
275
 
189
- // 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
190
287
  if (wasRecentlyProcessed(`session:${session.id}`)) {
191
288
  api.logger.info(`AgentSession ${session.id} already handled — skipping`);
192
289
  return true;
@@ -202,13 +299,7 @@ export async function handleLinearWebhook(
202
299
  const previousComments = payload.previousComments ?? [];
203
300
  const guidance = payload.guidance;
204
301
 
205
- api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`);
206
-
207
- // Guard: skip if an agent run is already active for this issue
208
- if (activeRuns.has(issue.id)) {
209
- api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
210
- return true;
211
- }
302
+ api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`)
212
303
 
213
304
  // Extract the user's latest message from previousComments
214
305
  // The last comment is the most recent user message
@@ -298,31 +389,21 @@ export async function handleLinearWebhook(
298
389
  ? result.output
299
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.`;
300
391
 
301
- // Post as comment
302
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
303
- const brandingOpts = avatarUrl
304
- ? { createAsUser: label, displayIconUrl: avatarUrl }
305
- : undefined;
306
-
307
- try {
308
- if (brandingOpts) {
309
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
310
- } else {
311
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
312
- }
313
- } catch (brandErr) {
314
- api.logger.warn(`Branded comment failed: ${brandErr}`);
315
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
316
- }
317
-
318
- // Emit response (closes session)
319
- const truncated = responseBody.length > 2000
320
- ? responseBody.slice(0, 2000) + "\u2026"
321
- : responseBody;
322
- 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, {
323
396
  type: "response",
324
- body: truncated,
325
- }).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
+ }
326
407
 
327
408
  api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
328
409
  } catch (err) {
@@ -476,29 +557,20 @@ export async function handleLinearWebhook(
476
557
  ? result.output
477
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.`;
478
559
 
479
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
480
- const brandingOpts = avatarUrl
481
- ? { createAsUser: label, displayIconUrl: avatarUrl }
482
- : undefined;
483
-
484
- try {
485
- if (brandingOpts) {
486
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
487
- } else {
488
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
489
- }
490
- } catch (brandErr) {
491
- api.logger.warn(`Branded comment failed: ${brandErr}`);
492
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
493
- }
494
-
495
- const truncated = responseBody.length > 2000
496
- ? responseBody.slice(0, 2000) + "\u2026"
497
- : responseBody;
498
- 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, {
499
563
  type: "response",
500
- body: truncated,
501
- }).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
+ }
502
574
 
503
575
  api.logger.info(`Posted follow-up response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
504
576
  } catch (err) {
@@ -553,6 +625,14 @@ export async function handleLinearWebhook(
553
625
  }
554
626
  } catch { /* proceed if viewerId check fails */ }
555
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
+
556
636
  // Load agent profiles
557
637
  const profiles = loadAgentProfiles();
558
638
  const agentNames = Object.keys(profiles);
@@ -647,7 +727,7 @@ export async function handleLinearWebhook(
647
727
  void (async () => {
648
728
  try {
649
729
  await endPlanningSession(planSession.projectId, "approved", planStatePath);
650
- await linearApi.createComment(
730
+ await createCommentWithDedup(linearApi,
651
731
  planSession.rootIssueId,
652
732
  `## Plan Approved\n\nPlan for **${planSession.projectName}** has been approved. Dispatching to workers.`,
653
733
  );
@@ -680,7 +760,7 @@ export async function handleLinearWebhook(
680
760
  void (async () => {
681
761
  try {
682
762
  await endPlanningSession(planSession.projectId, "abandoned", planStatePath);
683
- await linearApi.createComment(
763
+ await createCommentWithDedup(linearApi,
684
764
  planSession.rootIssueId,
685
765
  `Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
686
766
  );
@@ -740,6 +820,16 @@ export async function handleLinearWebhook(
740
820
  res.end("ok");
741
821
 
742
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
+
743
833
  const updatedFrom = payload.updatedFrom ?? {};
744
834
 
745
835
  // Check both assigneeId and delegateId — Linear uses delegateId for agent delegation
@@ -777,7 +867,8 @@ export async function handleLinearWebhook(
777
867
  const trigger = isDelegatedToUs ? "delegated" : "assigned";
778
868
  api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
779
869
 
780
- // 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).
781
872
  const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
782
873
  if (wasRecentlyProcessed(dedupKey)) {
783
874
  api.logger.info(`${trigger} ${issue.id} -> ${viewerId} already processed — skipping`);
@@ -895,7 +986,7 @@ export async function handleLinearWebhook(
895
986
  if (agentSessionId) {
896
987
  await linearApi.emitActivity(agentSessionId, {
897
988
  type: "thought",
898
- body: `Triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
989
+ body: `${label} is triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
899
990
  }).catch(() => {});
900
991
  }
901
992
 
@@ -951,6 +1042,10 @@ export async function handleLinearWebhook(
951
1042
  message,
952
1043
  timeoutMs: 3 * 60_000,
953
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,
954
1049
  });
955
1050
 
956
1051
  const responseBody = result.success
@@ -999,30 +1094,26 @@ export async function handleLinearWebhook(
999
1094
  }
1000
1095
  }
1001
1096
 
1002
- // Post branded triage comment
1003
- const brandingOpts = avatarUrl
1004
- ? { createAsUser: label, displayIconUrl: avatarUrl }
1005
- : undefined;
1006
-
1007
- try {
1008
- if (brandingOpts) {
1009
- await linearApi.createComment(issue.id, commentBody, brandingOpts);
1010
- } else {
1011
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1012
- }
1013
- } catch (brandErr) {
1014
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1015
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1016
- }
1017
-
1097
+ // When a session exists, prefer emitActivity (avoids duplicate comment).
1098
+ // Otherwise, post as a regular comment.
1018
1099
  if (agentSessionId) {
1019
- const truncated = commentBody.length > 2000
1020
- ? commentBody.slice(0, 2000) + "…"
1021
- : commentBody;
1022
- await linearApi.emitActivity(agentSessionId, {
1100
+ const labeledComment = `**[${label}]** ${commentBody}`;
1101
+ const emitted = await linearApi.emitActivity(agentSessionId, {
1023
1102
  type: "response",
1024
- body: truncated,
1025
- }).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);
1026
1117
  }
1027
1118
 
1028
1119
  api.logger.info(`Triage complete for ${enrichedIssue?.identifier ?? issue.id}`);
@@ -1136,7 +1227,7 @@ async function dispatchCommentToAgent(
1136
1227
  if (agentSessionId) {
1137
1228
  await linearApi.emitActivity(agentSessionId, {
1138
1229
  type: "thought",
1139
- body: `Processing comment on ${issueRef}...`,
1230
+ body: `${label} is processing comment on ${issueRef}...`,
1140
1231
  }).catch(() => {});
1141
1232
  }
1142
1233
 
@@ -1156,31 +1247,26 @@ async function dispatchCommentToAgent(
1156
1247
  ? result.output
1157
1248
  : `Something went wrong while processing this. The system will retry automatically if possible.`;
1158
1249
 
1159
- // Post branded comment
1160
- const brandingOpts = avatarUrl
1161
- ? { createAsUser: label, displayIconUrl: avatarUrl }
1162
- : undefined;
1163
-
1164
- try {
1165
- if (brandingOpts) {
1166
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
1167
- } else {
1168
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1169
- }
1170
- } catch (brandErr) {
1171
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1172
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1173
- }
1174
-
1175
- // Emit response (closes session)
1250
+ // When a session exists, prefer emitActivity (avoids duplicate comment).
1251
+ // Otherwise, post as a regular comment.
1176
1252
  if (agentSessionId) {
1177
- const truncated = responseBody.length > 2000
1178
- ? responseBody.slice(0, 2000) + "…"
1179
- : responseBody;
1180
- await linearApi.emitActivity(agentSessionId, {
1253
+ const labeledResponse = `**[${label}]** ${responseBody}`;
1254
+ const emitted = await linearApi.emitActivity(agentSessionId, {
1181
1255
  type: "response",
1182
- body: truncated,
1183
- }).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);
1184
1270
  }
1185
1271
 
1186
1272
  api.logger.info(`Posted ${agentId} response to ${issueRef}`);
@@ -1226,7 +1312,7 @@ async function handleDispatch(
1226
1312
  const planState = await readPlanningState(planStatePath);
1227
1313
  if (isInPlanningMode(planState, planProjectId)) {
1228
1314
  api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
1229
- await linearApi.createComment(
1315
+ await createCommentWithDedup(linearApi,
1230
1316
  issue.id,
1231
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.`,
1232
1318
  );
@@ -1249,7 +1335,7 @@ async function handleDispatch(
1249
1335
  if (!isStale && inMemory) {
1250
1336
  // Truly still running in this gateway process
1251
1337
  api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
1252
- await linearApi.createComment(
1338
+ await createCommentWithDedup(linearApi,
1253
1339
  issue.id,
1254
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"\``,
1255
1341
  );
@@ -1321,7 +1407,7 @@ async function handleDispatch(
1321
1407
  }
1322
1408
  } catch (err) {
1323
1409
  api.logger.error(`@dispatch: worktree creation failed: ${err}`);
1324
- await linearApi.createComment(
1410
+ await createCommentWithDedup(linearApi,
1325
1411
  issue.id,
1326
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"\``,
1327
1413
  );
@@ -1425,7 +1511,7 @@ async function handleDispatch(
1425
1511
  `- All dispatches: \`/dispatch list\``,
1426
1512
  ].join("\n");
1427
1513
 
1428
- await linearApi.createComment(issue.id, statusComment);
1514
+ await createCommentWithDedup(linearApi, issue.id, statusComment);
1429
1515
 
1430
1516
  if (agentSessionId) {
1431
1517
  await linearApi.emitActivity(agentSessionId, {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * tools.test.ts — Integration tests for tool registration.
3
+ *
4
+ * Verifies createLinearTools() returns expected tools and handles
5
+ * configuration flags and graceful failure scenarios.
6
+ */
7
+ import { describe, expect, it, vi } from "vitest";
8
+
9
+ vi.mock("./code-tool.js", () => ({
10
+ createCodeTool: vi.fn(() => ({ name: "code_run", execute: vi.fn() })),
11
+ }));
12
+
13
+ vi.mock("./orchestration-tools.js", () => ({
14
+ createOrchestrationTools: vi.fn(() => [
15
+ { name: "spawn_agent", execute: vi.fn() },
16
+ { name: "ask_agent", execute: vi.fn() },
17
+ ]),
18
+ }));
19
+
20
+ import { createLinearTools } from "./tools.js";
21
+ import { createCodeTool } from "./code-tool.js";
22
+ import { createOrchestrationTools } from "./orchestration-tools.js";
23
+
24
+ // ── Helpers ────────────────────────────────────────────────────────
25
+
26
+ function makeApi(pluginConfig?: Record<string, unknown>) {
27
+ return {
28
+ logger: {
29
+ info: vi.fn(),
30
+ warn: vi.fn(),
31
+ error: vi.fn(),
32
+ debug: vi.fn(),
33
+ },
34
+ pluginConfig: pluginConfig ?? {},
35
+ } as any;
36
+ }
37
+
38
+ // ── Tests ──────────────────────────────────────────────────────────
39
+
40
+ describe("createLinearTools", () => {
41
+ it("returns code_run, spawn_agent, and ask_agent tools", () => {
42
+ const api = makeApi();
43
+ const tools = createLinearTools(api, {});
44
+
45
+ expect(tools).toHaveLength(3);
46
+ const names = tools.map((t: any) => t.name);
47
+ expect(names).toContain("code_run");
48
+ expect(names).toContain("spawn_agent");
49
+ expect(names).toContain("ask_agent");
50
+ });
51
+
52
+ it("includes orchestration tools by default", () => {
53
+ const api = makeApi();
54
+ createLinearTools(api, {});
55
+
56
+ expect(createOrchestrationTools).toHaveBeenCalled();
57
+ });
58
+
59
+ it("excludes orchestration tools when enableOrchestration is false", () => {
60
+ vi.mocked(createOrchestrationTools).mockClear();
61
+ const api = makeApi({ enableOrchestration: false });
62
+ const tools = createLinearTools(api, {});
63
+
64
+ expect(tools).toHaveLength(1);
65
+ expect(tools[0].name).toBe("code_run");
66
+ expect(createOrchestrationTools).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it("handles code_run creation failure gracefully", () => {
70
+ vi.mocked(createCodeTool).mockImplementationOnce(() => {
71
+ throw new Error("CLI not found");
72
+ });
73
+
74
+ const api = makeApi();
75
+ const tools = createLinearTools(api, {});
76
+
77
+ expect(tools).toHaveLength(2);
78
+ const names = tools.map((t: any) => t.name);
79
+ expect(names).toContain("spawn_agent");
80
+ expect(names).toContain("ask_agent");
81
+ expect(api.logger.warn).toHaveBeenCalledWith(
82
+ expect.stringContaining("code_run tool not available"),
83
+ );
84
+ });
85
+
86
+ it("handles orchestration tools creation failure gracefully", () => {
87
+ vi.mocked(createOrchestrationTools).mockImplementationOnce(() => {
88
+ throw new Error("orchestration init failed");
89
+ });
90
+
91
+ const api = makeApi();
92
+ const tools = createLinearTools(api, {});
93
+
94
+ expect(tools).toHaveLength(1);
95
+ expect(tools[0].name).toBe("code_run");
96
+ expect(api.logger.warn).toHaveBeenCalledWith(
97
+ expect.stringContaining("Orchestration tools not available"),
98
+ );
99
+ });
100
+ });