@calltelemetry/openclaw-linear 0.8.2 → 0.8.4
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 +37 -4
- 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 +631 -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/intent-classify.test.ts +43 -0
- package/src/pipeline/intent-classify.ts +10 -0
- package/src/pipeline/webhook-dedup.test.ts +466 -0
- package/src/pipeline/webhook.ts +372 -112
- 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 {
|
|
@@ -186,7 +273,17 @@ export async function handleLinearWebhook(
|
|
|
186
273
|
return true;
|
|
187
274
|
}
|
|
188
275
|
|
|
189
|
-
//
|
|
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
|
-
//
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
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:
|
|
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
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
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:
|
|
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
|
|
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
|
|
763
|
+
await createCommentWithDedup(linearApi,
|
|
684
764
|
planSession.rootIssueId,
|
|
685
765
|
`Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
|
|
686
766
|
);
|
|
@@ -725,6 +805,14 @@ export async function handleLinearWebhook(
|
|
|
725
805
|
break;
|
|
726
806
|
}
|
|
727
807
|
|
|
808
|
+
case "close_issue": {
|
|
809
|
+
const closeAgent = resolveAgentId(api);
|
|
810
|
+
api.logger.info(`Comment intent close_issue: closing ${issue.identifier ?? issue.id} via ${closeAgent}`);
|
|
811
|
+
void handleCloseIssue(api, linearApi, profiles, closeAgent, issue, comment, commentBody, commentor, pluginConfig)
|
|
812
|
+
.catch((err) => api.logger.error(`Close issue error: ${err}`));
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
|
|
728
816
|
case "general":
|
|
729
817
|
default:
|
|
730
818
|
api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
|
|
@@ -740,6 +828,16 @@ export async function handleLinearWebhook(
|
|
|
740
828
|
res.end("ok");
|
|
741
829
|
|
|
742
830
|
const issue = payload.data;
|
|
831
|
+
|
|
832
|
+
// Guard: check activeRuns FIRST (synchronous, O(1)) before any async work.
|
|
833
|
+
// Linear can send duplicate Issue.update webhooks <20ms apart for the same
|
|
834
|
+
// assignment change. Without this sync guard, both pass through the async
|
|
835
|
+
// getViewerId() call before either registers with wasRecentlyProcessed().
|
|
836
|
+
if (activeRuns.has(issue?.id)) {
|
|
837
|
+
api.logger.info(`Issue.update ${issue?.identifier ?? issue?.id}: active run — skipping`);
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
|
|
743
841
|
const updatedFrom = payload.updatedFrom ?? {};
|
|
744
842
|
|
|
745
843
|
// Check both assigneeId and delegateId — Linear uses delegateId for agent delegation
|
|
@@ -777,7 +875,8 @@ export async function handleLinearWebhook(
|
|
|
777
875
|
const trigger = isDelegatedToUs ? "delegated" : "assigned";
|
|
778
876
|
api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
|
|
779
877
|
|
|
780
|
-
//
|
|
878
|
+
// Secondary dedup: catch duplicate webhooks that both passed the activeRuns
|
|
879
|
+
// check before either could register (belt-and-suspenders with the sync guard).
|
|
781
880
|
const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
|
|
782
881
|
if (wasRecentlyProcessed(dedupKey)) {
|
|
783
882
|
api.logger.info(`${trigger} ${issue.id} -> ${viewerId} already processed — skipping`);
|
|
@@ -895,7 +994,7 @@ export async function handleLinearWebhook(
|
|
|
895
994
|
if (agentSessionId) {
|
|
896
995
|
await linearApi.emitActivity(agentSessionId, {
|
|
897
996
|
type: "thought",
|
|
898
|
-
body:
|
|
997
|
+
body: `${label} is triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
899
998
|
}).catch(() => {});
|
|
900
999
|
}
|
|
901
1000
|
|
|
@@ -951,6 +1050,10 @@ export async function handleLinearWebhook(
|
|
|
951
1050
|
message,
|
|
952
1051
|
timeoutMs: 3 * 60_000,
|
|
953
1052
|
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
1053
|
+
// Triage is strictly read-only: the agent can read/search the
|
|
1054
|
+
// codebase but all write-capable tools are denied via config
|
|
1055
|
+
// policy. The only artifacts are a Linear comment + issue updates.
|
|
1056
|
+
readOnly: true,
|
|
954
1057
|
});
|
|
955
1058
|
|
|
956
1059
|
const responseBody = result.success
|
|
@@ -999,30 +1102,26 @@ export async function handleLinearWebhook(
|
|
|
999
1102
|
}
|
|
1000
1103
|
}
|
|
1001
1104
|
|
|
1002
|
-
//
|
|
1003
|
-
|
|
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
|
-
|
|
1105
|
+
// When a session exists, prefer emitActivity (avoids duplicate comment).
|
|
1106
|
+
// Otherwise, post as a regular comment.
|
|
1018
1107
|
if (agentSessionId) {
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
: commentBody;
|
|
1022
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
1108
|
+
const labeledComment = `**[${label}]** ${commentBody}`;
|
|
1109
|
+
const emitted = await linearApi.emitActivity(agentSessionId, {
|
|
1023
1110
|
type: "response",
|
|
1024
|
-
body:
|
|
1025
|
-
}).catch(() =>
|
|
1111
|
+
body: labeledComment,
|
|
1112
|
+
}).then(() => true).catch(() => false);
|
|
1113
|
+
|
|
1114
|
+
if (!emitted) {
|
|
1115
|
+
const agentOpts = avatarUrl
|
|
1116
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1117
|
+
: undefined;
|
|
1118
|
+
await postAgentComment(api, linearApi, issue.id, commentBody, label, agentOpts);
|
|
1119
|
+
}
|
|
1120
|
+
} else {
|
|
1121
|
+
const agentOpts = avatarUrl
|
|
1122
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1123
|
+
: undefined;
|
|
1124
|
+
await postAgentComment(api, linearApi, issue.id, commentBody, label, agentOpts);
|
|
1026
1125
|
}
|
|
1027
1126
|
|
|
1028
1127
|
api.logger.info(`Triage complete for ${enrichedIssue?.identifier ?? issue.id}`);
|
|
@@ -1136,7 +1235,7 @@ async function dispatchCommentToAgent(
|
|
|
1136
1235
|
if (agentSessionId) {
|
|
1137
1236
|
await linearApi.emitActivity(agentSessionId, {
|
|
1138
1237
|
type: "thought",
|
|
1139
|
-
body:
|
|
1238
|
+
body: `${label} is processing comment on ${issueRef}...`,
|
|
1140
1239
|
}).catch(() => {});
|
|
1141
1240
|
}
|
|
1142
1241
|
|
|
@@ -1156,40 +1255,201 @@ async function dispatchCommentToAgent(
|
|
|
1156
1255
|
? result.output
|
|
1157
1256
|
: `Something went wrong while processing this. The system will retry automatically if possible.`;
|
|
1158
1257
|
|
|
1159
|
-
//
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1258
|
+
// When a session exists, prefer emitActivity (avoids duplicate comment).
|
|
1259
|
+
// Otherwise, post as a regular comment.
|
|
1260
|
+
if (agentSessionId) {
|
|
1261
|
+
const labeledResponse = `**[${label}]** ${responseBody}`;
|
|
1262
|
+
const emitted = await linearApi.emitActivity(agentSessionId, {
|
|
1263
|
+
type: "response",
|
|
1264
|
+
body: labeledResponse,
|
|
1265
|
+
}).then(() => true).catch(() => false);
|
|
1163
1266
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
await linearApi
|
|
1267
|
+
if (!emitted) {
|
|
1268
|
+
const agentOpts = avatarUrl
|
|
1269
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1270
|
+
: undefined;
|
|
1271
|
+
await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
|
|
1169
1272
|
}
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1273
|
+
} else {
|
|
1274
|
+
const agentOpts = avatarUrl
|
|
1275
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1276
|
+
: undefined;
|
|
1277
|
+
await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
|
|
1173
1278
|
}
|
|
1174
1279
|
|
|
1175
|
-
|
|
1280
|
+
api.logger.info(`Posted ${agentId} response to ${issueRef}`);
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
api.logger.error(`dispatchCommentToAgent error: ${err}`);
|
|
1176
1283
|
if (agentSessionId) {
|
|
1177
|
-
const truncated = responseBody.length > 2000
|
|
1178
|
-
? responseBody.slice(0, 2000) + "…"
|
|
1179
|
-
: responseBody;
|
|
1180
1284
|
await linearApi.emitActivity(agentSessionId, {
|
|
1181
|
-
type: "
|
|
1182
|
-
body:
|
|
1285
|
+
type: "error",
|
|
1286
|
+
body: `Failed to process comment: ${String(err).slice(0, 500)}`,
|
|
1183
1287
|
}).catch(() => {});
|
|
1184
1288
|
}
|
|
1289
|
+
} finally {
|
|
1290
|
+
clearActiveSession(issue.id);
|
|
1291
|
+
activeRuns.delete(issue.id);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1185
1294
|
|
|
1186
|
-
|
|
1295
|
+
// ── Close issue handler ──────────────────────────────────────────
|
|
1296
|
+
//
|
|
1297
|
+
// Triggered by close_issue intent. Generates a closure report via agent,
|
|
1298
|
+
// transitions issue to completed state, and posts the report.
|
|
1299
|
+
|
|
1300
|
+
async function handleCloseIssue(
|
|
1301
|
+
api: OpenClawPluginApi,
|
|
1302
|
+
linearApi: LinearAgentApi,
|
|
1303
|
+
profiles: Record<string, AgentProfile>,
|
|
1304
|
+
agentId: string,
|
|
1305
|
+
issue: any,
|
|
1306
|
+
comment: any,
|
|
1307
|
+
commentBody: string,
|
|
1308
|
+
commentor: string,
|
|
1309
|
+
pluginConfig?: Record<string, unknown>,
|
|
1310
|
+
): Promise<void> {
|
|
1311
|
+
const profile = profiles[agentId];
|
|
1312
|
+
const label = profile?.label ?? agentId;
|
|
1313
|
+
const avatarUrl = profile?.avatarUrl;
|
|
1314
|
+
|
|
1315
|
+
if (activeRuns.has(issue.id)) {
|
|
1316
|
+
api.logger.info(`handleCloseIssue: ${issue.identifier ?? issue.id} has active run — skipping`);
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Fetch full issue details
|
|
1321
|
+
let enrichedIssue: any = issue;
|
|
1322
|
+
try {
|
|
1323
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
1187
1324
|
} catch (err) {
|
|
1188
|
-
api.logger.
|
|
1325
|
+
api.logger.warn(`Could not fetch issue details for close: ${err}`);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
1329
|
+
const teamId = enrichedIssue?.team?.id ?? issue.team?.id;
|
|
1330
|
+
|
|
1331
|
+
// Find completed state
|
|
1332
|
+
let completedStateId: string | null = null;
|
|
1333
|
+
if (teamId) {
|
|
1334
|
+
try {
|
|
1335
|
+
const states = await linearApi.getTeamStates(teamId);
|
|
1336
|
+
const completedState = states.find((s: any) => s.type === "completed");
|
|
1337
|
+
if (completedState) completedStateId = completedState.id;
|
|
1338
|
+
} catch (err) {
|
|
1339
|
+
api.logger.warn(`Could not fetch team states for close: ${err}`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Build closure report prompt
|
|
1344
|
+
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
1345
|
+
const comments = enrichedIssue?.comments?.nodes ?? [];
|
|
1346
|
+
const commentSummary = comments
|
|
1347
|
+
.slice(-10)
|
|
1348
|
+
.map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 300)}`)
|
|
1349
|
+
.join("\n");
|
|
1350
|
+
|
|
1351
|
+
const message = [
|
|
1352
|
+
`You are writing a closure report for a Linear issue that is being marked as done.`,
|
|
1353
|
+
`Your text output will be posted as the closing comment on the issue.`,
|
|
1354
|
+
``,
|
|
1355
|
+
`## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
1356
|
+
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
1357
|
+
``,
|
|
1358
|
+
`**Description:**`,
|
|
1359
|
+
description,
|
|
1360
|
+
commentSummary ? `\n**Comment history:**\n${commentSummary}` : "",
|
|
1361
|
+
`\n**${commentor} says (closure request):**\n> ${commentBody}`,
|
|
1362
|
+
``,
|
|
1363
|
+
`Write a concise closure report with:`,
|
|
1364
|
+
`- **Summary**: What was done (1-2 sentences)`,
|
|
1365
|
+
`- **Resolution**: How it was resolved`,
|
|
1366
|
+
`- **Notes**: Any follow-up items or caveats (if applicable)`,
|
|
1367
|
+
``,
|
|
1368
|
+
`Keep it brief and factual. Use markdown formatting.`,
|
|
1369
|
+
].filter(Boolean).join("\n");
|
|
1370
|
+
|
|
1371
|
+
// Execute with session lifecycle
|
|
1372
|
+
activeRuns.add(issue.id);
|
|
1373
|
+
let agentSessionId: string | null = null;
|
|
1374
|
+
|
|
1375
|
+
try {
|
|
1376
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
1377
|
+
agentSessionId = sessionResult.sessionId;
|
|
1378
|
+
if (agentSessionId) {
|
|
1379
|
+
wasRecentlyProcessed(`session:${agentSessionId}`);
|
|
1380
|
+
setActiveSession({
|
|
1381
|
+
agentSessionId,
|
|
1382
|
+
issueIdentifier: issueRef,
|
|
1383
|
+
issueId: issue.id,
|
|
1384
|
+
agentId,
|
|
1385
|
+
startedAt: Date.now(),
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
if (agentSessionId) {
|
|
1390
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1391
|
+
type: "thought",
|
|
1392
|
+
body: `${label} is preparing closure report for ${issueRef}...`,
|
|
1393
|
+
}).catch(() => {});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// Run agent for closure report
|
|
1397
|
+
const { runAgent } = await import("../agent/agent.js");
|
|
1398
|
+
const result = await runAgent({
|
|
1399
|
+
api,
|
|
1400
|
+
agentId,
|
|
1401
|
+
sessionId: `linear-close-${agentId}-${Date.now()}`,
|
|
1402
|
+
message,
|
|
1403
|
+
timeoutMs: 2 * 60_000,
|
|
1404
|
+
readOnly: true,
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
const closureReport = result.success
|
|
1408
|
+
? result.output
|
|
1409
|
+
: "Issue closed. (Closure report generation failed.)";
|
|
1410
|
+
|
|
1411
|
+
const fullReport = `## Closure Report\n\n${closureReport}`;
|
|
1412
|
+
|
|
1413
|
+
// Transition issue to completed state
|
|
1414
|
+
if (completedStateId) {
|
|
1415
|
+
try {
|
|
1416
|
+
await linearApi.updateIssue(issue.id, { stateId: completedStateId });
|
|
1417
|
+
api.logger.info(`Closed issue ${issueRef} (state → completed)`);
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
api.logger.error(`Failed to transition issue ${issueRef} to completed: ${err}`);
|
|
1420
|
+
}
|
|
1421
|
+
} else {
|
|
1422
|
+
api.logger.warn(`No completed state found for ${issueRef} — posting report without state change`);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Post closure report via emitActivity-first pattern
|
|
1426
|
+
if (agentSessionId) {
|
|
1427
|
+
const labeledReport = `**[${label}]** ${fullReport}`;
|
|
1428
|
+
const emitted = await linearApi.emitActivity(agentSessionId, {
|
|
1429
|
+
type: "response",
|
|
1430
|
+
body: labeledReport,
|
|
1431
|
+
}).then(() => true).catch(() => false);
|
|
1432
|
+
|
|
1433
|
+
if (!emitted) {
|
|
1434
|
+
const agentOpts = avatarUrl
|
|
1435
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1436
|
+
: undefined;
|
|
1437
|
+
await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
|
|
1438
|
+
}
|
|
1439
|
+
} else {
|
|
1440
|
+
const agentOpts = avatarUrl
|
|
1441
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1442
|
+
: undefined;
|
|
1443
|
+
await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
api.logger.info(`Posted closure report for ${issueRef}`);
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
api.logger.error(`handleCloseIssue error: ${err}`);
|
|
1189
1449
|
if (agentSessionId) {
|
|
1190
1450
|
await linearApi.emitActivity(agentSessionId, {
|
|
1191
1451
|
type: "error",
|
|
1192
|
-
body: `Failed to
|
|
1452
|
+
body: `Failed to close issue: ${String(err).slice(0, 500)}`,
|
|
1193
1453
|
}).catch(() => {});
|
|
1194
1454
|
}
|
|
1195
1455
|
} finally {
|
|
@@ -1226,7 +1486,7 @@ async function handleDispatch(
|
|
|
1226
1486
|
const planState = await readPlanningState(planStatePath);
|
|
1227
1487
|
if (isInPlanningMode(planState, planProjectId)) {
|
|
1228
1488
|
api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
|
|
1229
|
-
await linearApi
|
|
1489
|
+
await createCommentWithDedup(linearApi,
|
|
1230
1490
|
issue.id,
|
|
1231
1491
|
`**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
1492
|
);
|
|
@@ -1249,7 +1509,7 @@ async function handleDispatch(
|
|
|
1249
1509
|
if (!isStale && inMemory) {
|
|
1250
1510
|
// Truly still running in this gateway process
|
|
1251
1511
|
api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
|
|
1252
|
-
await linearApi
|
|
1512
|
+
await createCommentWithDedup(linearApi,
|
|
1253
1513
|
issue.id,
|
|
1254
1514
|
`**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
1515
|
);
|
|
@@ -1321,7 +1581,7 @@ async function handleDispatch(
|
|
|
1321
1581
|
}
|
|
1322
1582
|
} catch (err) {
|
|
1323
1583
|
api.logger.error(`@dispatch: worktree creation failed: ${err}`);
|
|
1324
|
-
await linearApi
|
|
1584
|
+
await createCommentWithDedup(linearApi,
|
|
1325
1585
|
issue.id,
|
|
1326
1586
|
`**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
1587
|
);
|
|
@@ -1425,7 +1685,7 @@ async function handleDispatch(
|
|
|
1425
1685
|
`- All dispatches: \`/dispatch list\``,
|
|
1426
1686
|
].join("\n");
|
|
1427
1687
|
|
|
1428
|
-
await linearApi
|
|
1688
|
+
await createCommentWithDedup(linearApi, issue.id, statusComment);
|
|
1429
1689
|
|
|
1430
1690
|
if (agentSessionId) {
|
|
1431
1691
|
await linearApi.emitActivity(agentSessionId, {
|