@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.
- package/README.md +28 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/__test__/fixtures/webhook-payloads.ts +93 -0
- package/src/__test__/smoke-linear-api.test.ts +352 -0
- package/src/__test__/webhook-scenarios.test.ts +570 -0
- package/src/agent/agent.ts +69 -5
- package/src/api/linear-api.test.ts +37 -0
- package/src/api/linear-api.ts +96 -5
- package/src/infra/cli.ts +150 -0
- package/src/infra/doctor.test.ts +17 -2
- package/src/infra/doctor.ts +70 -1
- package/src/infra/webhook-provision.test.ts +162 -0
- package/src/infra/webhook-provision.ts +152 -0
- package/src/pipeline/webhook-dedup.test.ts +466 -0
- package/src/pipeline/webhook.ts +218 -264
- package/src/tools/tools.test.ts +100 -0
package/src/pipeline/webhook.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
//
|
|
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
|
-
//
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
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:
|
|
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
|
-
|
|
624
|
-
const
|
|
625
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
1139
|
-
|
|
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
|
|
1156
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
1295
|
-
|
|
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
|
|
1313
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|