@calltelemetry/openclaw-linear 0.8.0 → 0.8.2
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 +152 -34
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/prompts.yaml +27 -1
- package/src/agent/agent.test.ts +49 -0
- package/src/agent/agent.ts +26 -1
- package/src/infra/doctor.ts +2 -2
- package/src/pipeline/e2e-planning.test.ts +77 -54
- package/src/pipeline/intent-classify.test.ts +285 -0
- package/src/pipeline/intent-classify.ts +259 -0
- package/src/pipeline/planner.test.ts +159 -40
- package/src/pipeline/planner.ts +98 -32
- package/src/pipeline/webhook.ts +340 -376
- package/src/tools/claude-tool.ts +6 -0
- package/src/tools/code-tool.test.ts +3 -3
- package/src/tools/code-tool.ts +2 -2
- package/src/tools/planner-tools.test.ts +1 -1
- package/src/tools/planner-tools.ts +10 -2
package/src/pipeline/webhook.ts
CHANGED
|
@@ -11,10 +11,11 @@ import { assessTier } from "./tier-assess.js";
|
|
|
11
11
|
import { createWorktree, createMultiWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
|
|
12
12
|
import { resolveRepos, isMultiRepo } from "../infra/multi-repo.js";
|
|
13
13
|
import { ensureClawDir, writeManifest, writeDispatchMemory, resolveOrchestratorWorkspace } from "./artifacts.js";
|
|
14
|
-
import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
|
|
15
|
-
import { initiatePlanningSession, handlePlannerTurn } from "./planner.js";
|
|
14
|
+
import { readPlanningState, isInPlanningMode, getPlanningSession, endPlanningSession } from "./planning-state.js";
|
|
15
|
+
import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
|
|
16
16
|
import { startProjectDispatch } from "./dag-dispatch.js";
|
|
17
17
|
import { emitDiagnostic } from "../infra/observability.js";
|
|
18
|
+
import { classifyIntent } from "./intent-classify.js";
|
|
18
19
|
|
|
19
20
|
// ── Agent profiles (loaded from config, no hardcoded names) ───────
|
|
20
21
|
interface AgentProfile {
|
|
@@ -154,158 +155,14 @@ export async function handleLinearWebhook(
|
|
|
154
155
|
emitDiagnostic(api, { event: "webhook_received", webhookType: payload.type, webhookAction: payload.action });
|
|
155
156
|
|
|
156
157
|
|
|
157
|
-
// ── AppUserNotification —
|
|
158
|
+
// ── AppUserNotification — IGNORED ─────────────────────────────────
|
|
159
|
+
// AppUserNotification duplicates events already handled by the workspace
|
|
160
|
+
// webhook (Comment.create for mentions, Issue.update for assignments).
|
|
161
|
+
// Processing both causes double agent runs. Ack and discard.
|
|
158
162
|
if (payload.type === "AppUserNotification") {
|
|
163
|
+
api.logger.info(`AppUserNotification ignored (duplicate of workspace webhook): ${payload.notification?.type} appUserId=${payload.appUserId}`);
|
|
159
164
|
res.statusCode = 200;
|
|
160
165
|
res.end("ok");
|
|
161
|
-
|
|
162
|
-
const notification = payload.notification;
|
|
163
|
-
const notifType = notification?.type;
|
|
164
|
-
api.logger.info(`AppUserNotification: ${notifType} appUserId=${payload.appUserId}`);
|
|
165
|
-
|
|
166
|
-
const issue = notification?.issue;
|
|
167
|
-
const comment = notification?.comment ?? notification?.parentComment;
|
|
168
|
-
|
|
169
|
-
if (!issue?.id) {
|
|
170
|
-
api.logger.error("AppUserNotification missing issue data");
|
|
171
|
-
return true;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const linearApi = createLinearApi(api);
|
|
175
|
-
if (!linearApi) {
|
|
176
|
-
api.logger.error("No Linear access token — cannot process agent notification");
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const agentId = resolveAgentId(api);
|
|
181
|
-
|
|
182
|
-
// Fetch full issue details
|
|
183
|
-
let enrichedIssue: any = issue;
|
|
184
|
-
try {
|
|
185
|
-
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
186
|
-
} catch (err) {
|
|
187
|
-
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
191
|
-
const comments = enrichedIssue?.comments?.nodes ?? [];
|
|
192
|
-
const commentSummary = comments
|
|
193
|
-
.slice(-5)
|
|
194
|
-
.map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
|
|
195
|
-
.join("\n");
|
|
196
|
-
|
|
197
|
-
const notifIssueRef = enrichedIssue?.identifier ?? issue.id;
|
|
198
|
-
const message = [
|
|
199
|
-
`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).`,
|
|
200
|
-
``,
|
|
201
|
-
`**Tool access:**`,
|
|
202
|
-
`- \`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.`,
|
|
203
|
-
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
204
|
-
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
205
|
-
``,
|
|
206
|
-
`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
|
|
207
|
-
``,
|
|
208
|
-
`## Issue: ${notifIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
209
|
-
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
210
|
-
``,
|
|
211
|
-
`**Description:**`,
|
|
212
|
-
description,
|
|
213
|
-
commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
|
|
214
|
-
comment?.body ? `\n**Triggering comment:**\n> ${comment.body}` : "",
|
|
215
|
-
``,
|
|
216
|
-
`Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
|
|
217
|
-
].filter(Boolean).join("\n");
|
|
218
|
-
|
|
219
|
-
// Dispatch agent with session lifecycle (non-blocking)
|
|
220
|
-
void (async () => {
|
|
221
|
-
const profiles = loadAgentProfiles();
|
|
222
|
-
const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
223
|
-
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
224
|
-
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
225
|
-
let agentSessionId: string | null = null;
|
|
226
|
-
|
|
227
|
-
try {
|
|
228
|
-
// 1. Create agent session (non-fatal)
|
|
229
|
-
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
230
|
-
agentSessionId = sessionResult.sessionId;
|
|
231
|
-
if (agentSessionId) {
|
|
232
|
-
api.logger.info(`Created agent session ${agentSessionId} for notification`);
|
|
233
|
-
setActiveSession({
|
|
234
|
-
agentSessionId,
|
|
235
|
-
issueIdentifier: enrichedIssue?.identifier ?? issue.id,
|
|
236
|
-
issueId: issue.id,
|
|
237
|
-
agentId,
|
|
238
|
-
startedAt: Date.now(),
|
|
239
|
-
});
|
|
240
|
-
} else {
|
|
241
|
-
api.logger.warn(`Could not create agent session for notification: ${sessionResult.error ?? "unknown"}`);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// 2. Emit thought
|
|
245
|
-
if (agentSessionId) {
|
|
246
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
247
|
-
type: "thought",
|
|
248
|
-
body: `Reviewing ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
249
|
-
}).catch(() => {});
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// 3. Run agent with streaming
|
|
253
|
-
const sessionId = `linear-notif-${notification?.type ?? "unknown"}-${Date.now()}`;
|
|
254
|
-
const { runAgent } = await import("../agent/agent.js");
|
|
255
|
-
const result = await runAgent({
|
|
256
|
-
api,
|
|
257
|
-
agentId,
|
|
258
|
-
sessionId,
|
|
259
|
-
message,
|
|
260
|
-
timeoutMs: 3 * 60_000,
|
|
261
|
-
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
const responseBody = result.success
|
|
265
|
-
? result.output
|
|
266
|
-
: `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.`;
|
|
267
|
-
|
|
268
|
-
// 5. Post branded comment (fallback to prefix)
|
|
269
|
-
const brandingOpts = avatarUrl
|
|
270
|
-
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
271
|
-
: undefined;
|
|
272
|
-
|
|
273
|
-
try {
|
|
274
|
-
if (brandingOpts) {
|
|
275
|
-
await linearApi.createComment(issue.id, responseBody, brandingOpts);
|
|
276
|
-
} else {
|
|
277
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
278
|
-
}
|
|
279
|
-
} catch (brandErr) {
|
|
280
|
-
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
281
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// 6. Emit response (closes session)
|
|
285
|
-
if (agentSessionId) {
|
|
286
|
-
const truncated = responseBody.length > 2000
|
|
287
|
-
? responseBody.slice(0, 2000) + "…"
|
|
288
|
-
: responseBody;
|
|
289
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
290
|
-
type: "response",
|
|
291
|
-
body: truncated,
|
|
292
|
-
}).catch(() => {});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id}`);
|
|
296
|
-
} catch (err) {
|
|
297
|
-
api.logger.error(`AppUserNotification handler error: ${err}`);
|
|
298
|
-
if (agentSessionId) {
|
|
299
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
300
|
-
type: "error",
|
|
301
|
-
body: `Failed to process notification: ${String(err).slice(0, 500)}`,
|
|
302
|
-
}).catch(() => {});
|
|
303
|
-
}
|
|
304
|
-
} finally {
|
|
305
|
-
clearActiveSession(issue.id);
|
|
306
|
-
}
|
|
307
|
-
})();
|
|
308
|
-
|
|
309
166
|
return true;
|
|
310
167
|
}
|
|
311
168
|
|
|
@@ -659,7 +516,7 @@ export async function handleLinearWebhook(
|
|
|
659
516
|
return true;
|
|
660
517
|
}
|
|
661
518
|
|
|
662
|
-
// ── Comment.create —
|
|
519
|
+
// ── Comment.create — intent-based routing ────────────────────────
|
|
663
520
|
if (payload.type === "Comment" && payload.action === "create") {
|
|
664
521
|
res.statusCode = 200;
|
|
665
522
|
res.end("ok");
|
|
@@ -668,265 +525,211 @@ export async function handleLinearWebhook(
|
|
|
668
525
|
const commentBody = comment?.body ?? "";
|
|
669
526
|
const commentor = comment?.user?.name ?? "Unknown";
|
|
670
527
|
const issue = comment?.issue ?? payload.issue;
|
|
671
|
-
|
|
672
|
-
// ── Planning mode intercept ──────────────────────────────────
|
|
673
528
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
674
529
|
|
|
675
|
-
if (issue?.id) {
|
|
676
|
-
const linearApiForPlanning = createLinearApi(api);
|
|
677
|
-
if (linearApiForPlanning) {
|
|
678
|
-
try {
|
|
679
|
-
const enriched = await linearApiForPlanning.getIssueDetails(issue.id);
|
|
680
|
-
const projectId = enriched?.project?.id;
|
|
681
|
-
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
682
|
-
|
|
683
|
-
if (projectId) {
|
|
684
|
-
const planState = await readPlanningState(planStatePath);
|
|
685
|
-
|
|
686
|
-
// Check if this is a plan initiation request
|
|
687
|
-
const isPlanRequest = /\b(plan|planning)\s+(this\s+)(project|out)\b/i.test(commentBody) || /\bplan\s+this\s+out\b/i.test(commentBody);
|
|
688
|
-
if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
|
|
689
|
-
api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
|
|
690
|
-
void initiatePlanningSession(
|
|
691
|
-
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
692
|
-
projectId,
|
|
693
|
-
{ id: issue.id, identifier: enriched.identifier, title: enriched.title, team: enriched.team },
|
|
694
|
-
).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
|
|
695
|
-
return true;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Route to planner if project is in planning mode
|
|
699
|
-
if (isInPlanningMode(planState, projectId)) {
|
|
700
|
-
const session = getPlanningSession(planState, projectId);
|
|
701
|
-
if (!session) {
|
|
702
|
-
api.logger.error(`Planning: project ${projectId} in planning mode but no session found — state may be corrupted`);
|
|
703
|
-
await linearApiForPlanning.createComment(
|
|
704
|
-
issue.id,
|
|
705
|
-
`**Planning mode is active** for this project, but the session state appears corrupted.\n\n**To fix:** Say **"abandon planning"** to exit planning mode, then start fresh with **"plan this project"**.`,
|
|
706
|
-
).catch(() => {});
|
|
707
|
-
return true;
|
|
708
|
-
}
|
|
709
|
-
if (comment?.id && !wasRecentlyProcessed(`plan-comment:${comment.id}`)) {
|
|
710
|
-
api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
|
|
711
|
-
void handlePlannerTurn(
|
|
712
|
-
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
713
|
-
session,
|
|
714
|
-
{ issueId: issue.id, commentBody, commentorName: commentor },
|
|
715
|
-
{
|
|
716
|
-
onApproved: (approvedProjectId) => {
|
|
717
|
-
const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
718
|
-
const hookCtx: HookContext = {
|
|
719
|
-
api,
|
|
720
|
-
linearApi: linearApiForPlanning,
|
|
721
|
-
notify,
|
|
722
|
-
pluginConfig,
|
|
723
|
-
configPath: pluginConfig?.dispatchStatePath as string | undefined,
|
|
724
|
-
};
|
|
725
|
-
void startProjectDispatch(hookCtx, approvedProjectId)
|
|
726
|
-
.catch((err) => api.logger.error(`Project dispatch start error: ${err}`));
|
|
727
|
-
},
|
|
728
|
-
},
|
|
729
|
-
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
730
|
-
}
|
|
731
|
-
return true;
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
} catch (err) {
|
|
735
|
-
api.logger.warn(`Planning mode check failed: ${err}`);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Load agent profiles and build mention pattern dynamically.
|
|
741
|
-
// Default agent (app mentions) is handled by AgentSessionEvent — never here.
|
|
742
|
-
const profiles = loadAgentProfiles();
|
|
743
|
-
const mentionPattern = buildMentionPattern(profiles);
|
|
744
|
-
if (!mentionPattern) {
|
|
745
|
-
api.logger.info("Comment webhook: no sub-agent profiles configured, ignoring");
|
|
746
|
-
return true;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
const matches = commentBody.match(mentionPattern);
|
|
750
|
-
if (!matches || matches.length === 0) {
|
|
751
|
-
api.logger.info("Comment webhook: no sub-agent mentions found, ignoring");
|
|
752
|
-
return true;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
const alias = matches[0].replace("@", "");
|
|
756
|
-
const resolved = resolveAgentFromAlias(alias, profiles);
|
|
757
|
-
if (!resolved) {
|
|
758
|
-
api.logger.info(`Comment webhook: alias "${alias}" not found in profiles, ignoring`);
|
|
759
|
-
return true;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
const mentionedAgent = resolved.agentId;
|
|
763
|
-
|
|
764
530
|
if (!issue?.id) {
|
|
765
531
|
api.logger.error("Comment webhook: missing issue data");
|
|
766
532
|
return true;
|
|
767
533
|
}
|
|
768
534
|
|
|
769
|
-
|
|
770
|
-
if (!linearApi) {
|
|
771
|
-
api.logger.error("No Linear access token — cannot process comment mention");
|
|
772
|
-
return true;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Dedup on comment ID — prevent processing same comment twice
|
|
535
|
+
// Dedup on comment ID
|
|
776
536
|
if (comment?.id && wasRecentlyProcessed(`comment:${comment.id}`)) {
|
|
777
537
|
api.logger.info(`Comment ${comment.id} already processed — skipping`);
|
|
778
538
|
return true;
|
|
779
539
|
}
|
|
780
540
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
// (prevents dual-dispatch when both Comment.create and AgentSessionEvent fire)
|
|
785
|
-
if (activeRuns.has(issue.id)) {
|
|
786
|
-
api.logger.info(`Comment mention: agent already running for ${issue.identifier ?? issue.id} — skipping`);
|
|
541
|
+
const linearApi = createLinearApi(api);
|
|
542
|
+
if (!linearApi) {
|
|
543
|
+
api.logger.error("No Linear access token — cannot process comment");
|
|
787
544
|
return true;
|
|
788
545
|
}
|
|
789
|
-
activeRuns.add(issue.id);
|
|
790
546
|
|
|
791
|
-
//
|
|
792
|
-
|
|
793
|
-
linearApi.
|
|
547
|
+
// Skip bot's own comments
|
|
548
|
+
try {
|
|
549
|
+
const viewerId = await linearApi.getViewerId();
|
|
550
|
+
if (viewerId && comment?.user?.id === viewerId) {
|
|
551
|
+
api.logger.info(`Comment webhook: skipping our own comment on ${issue.identifier ?? issue.id}`);
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
} catch { /* proceed if viewerId check fails */ }
|
|
555
|
+
|
|
556
|
+
// Load agent profiles
|
|
557
|
+
const profiles = loadAgentProfiles();
|
|
558
|
+
const agentNames = Object.keys(profiles);
|
|
559
|
+
|
|
560
|
+
// ── @mention fast path — skip classifier ────────────────────
|
|
561
|
+
const mentionPattern = buildMentionPattern(profiles);
|
|
562
|
+
const mentionMatches = mentionPattern ? commentBody.match(mentionPattern) : null;
|
|
563
|
+
if (mentionMatches && mentionMatches.length > 0) {
|
|
564
|
+
const alias = mentionMatches[0].replace("@", "");
|
|
565
|
+
const resolved = resolveAgentFromAlias(alias, profiles);
|
|
566
|
+
if (resolved) {
|
|
567
|
+
api.logger.info(`Comment @mention fast path: @${resolved.agentId} on ${issue.identifier ?? issue.id}`);
|
|
568
|
+
void dispatchCommentToAgent(api, linearApi, profiles, resolved.agentId, issue, comment, commentBody, commentor, pluginConfig)
|
|
569
|
+
.catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
794
572
|
}
|
|
795
573
|
|
|
796
|
-
//
|
|
797
|
-
|
|
798
|
-
let
|
|
574
|
+
// ── Intent classification ─────────────────────────────────────
|
|
575
|
+
// Fetch issue details for context
|
|
576
|
+
let enrichedIssue: any = issue;
|
|
799
577
|
try {
|
|
800
|
-
|
|
801
|
-
enrichedIssue = { ...issue, ...full };
|
|
802
|
-
// Include last few comments for context (excluding the triggering comment)
|
|
803
|
-
const comments = full.comments?.nodes ?? [];
|
|
804
|
-
const relevant = comments
|
|
805
|
-
.filter((c: any) => c.body !== commentBody)
|
|
806
|
-
.slice(-3)
|
|
807
|
-
.map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body.slice(0, 200)}`)
|
|
808
|
-
.join("\n");
|
|
809
|
-
if (relevant) recentComments = `\n**Recent Comments:**\n${relevant}\n`;
|
|
578
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
810
579
|
} catch (err) {
|
|
811
580
|
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
812
581
|
}
|
|
813
582
|
|
|
814
|
-
const
|
|
815
|
-
const
|
|
816
|
-
const state = enrichedIssue.state?.name ?? "Unknown";
|
|
817
|
-
const assignee = enrichedIssue.assignee?.name ?? "Unassigned";
|
|
818
|
-
|
|
819
|
-
const taskMessage = [
|
|
820
|
-
`You are an orchestrator responding to a Linear issue comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
|
|
821
|
-
``,
|
|
822
|
-
`**Tool access:**`,
|
|
823
|
-
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${enrichedIssue.identifier ?? "API-XXX"}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
824
|
-
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
825
|
-
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
826
|
-
``,
|
|
827
|
-
`**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post linearis comments — the audit system handles lifecycle transitions.`,
|
|
828
|
-
``,
|
|
829
|
-
`**Issue:** ${enrichedIssue.identifier ?? enrichedIssue.id} — ${enrichedIssue.title ?? "(untitled)"}`,
|
|
830
|
-
`**Status:** ${state} | **Priority:** ${priority} | **Assignee:** ${assignee} | **Labels:** ${labels}`,
|
|
831
|
-
`**URL:** ${enrichedIssue.url ?? "N/A"}`,
|
|
832
|
-
``,
|
|
833
|
-
enrichedIssue.description ? `**Description:**\n${enrichedIssue.description}\n` : "",
|
|
834
|
-
recentComments,
|
|
835
|
-
`**${commentor} wrote:**`,
|
|
836
|
-
`> ${commentBody}`,
|
|
837
|
-
``,
|
|
838
|
-
`Respond to their message. Be concise and direct. For work requests, dispatch via \`code_run\` and summarize the result.`,
|
|
839
|
-
].filter(Boolean).join("\n");
|
|
840
|
-
|
|
841
|
-
// Dispatch to agent with full session lifecycle (non-blocking)
|
|
842
|
-
void (async () => {
|
|
843
|
-
const label = resolved.label;
|
|
844
|
-
const profile = profiles[mentionedAgent];
|
|
845
|
-
let agentSessionId: string | null = null;
|
|
583
|
+
const projectId = enrichedIssue?.project?.id;
|
|
584
|
+
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
846
585
|
|
|
586
|
+
// Determine planning state
|
|
587
|
+
let isPlanning = false;
|
|
588
|
+
let planSession: any = null;
|
|
589
|
+
if (projectId) {
|
|
847
590
|
try {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
api.logger.info(`Created agent session ${agentSessionId} for @${mentionedAgent}`);
|
|
853
|
-
// Register active session so code_run can resolve it automatically
|
|
854
|
-
setActiveSession({
|
|
855
|
-
agentSessionId,
|
|
856
|
-
issueIdentifier: enrichedIssue.identifier ?? issue.id,
|
|
857
|
-
issueId: issue.id,
|
|
858
|
-
agentId: mentionedAgent,
|
|
859
|
-
startedAt: Date.now(),
|
|
860
|
-
});
|
|
861
|
-
} else {
|
|
862
|
-
api.logger.warn(`Could not create agent session for @${mentionedAgent}: ${sessionResult.error ?? "unknown"} — falling back to flat comment`);
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
// 2. Emit thought
|
|
866
|
-
if (agentSessionId) {
|
|
867
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
868
|
-
type: "thought",
|
|
869
|
-
body: `Reviewing ${enrichedIssue.identifier ?? issue.id}...`,
|
|
870
|
-
}).catch(() => {});
|
|
591
|
+
const planState = await readPlanningState(planStatePath);
|
|
592
|
+
isPlanning = isInPlanningMode(planState, projectId);
|
|
593
|
+
if (isPlanning) {
|
|
594
|
+
planSession = getPlanningSession(planState, projectId);
|
|
871
595
|
}
|
|
596
|
+
} catch { /* proceed without planning context */ }
|
|
597
|
+
}
|
|
872
598
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
timeoutMs: 3 * 60_000,
|
|
882
|
-
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
883
|
-
});
|
|
599
|
+
const intentResult = await classifyIntent(api, {
|
|
600
|
+
commentBody,
|
|
601
|
+
issueTitle: enrichedIssue?.title ?? "(untitled)",
|
|
602
|
+
issueStatus: enrichedIssue?.state?.name,
|
|
603
|
+
isPlanning,
|
|
604
|
+
agentNames,
|
|
605
|
+
hasProject: !!projectId,
|
|
606
|
+
}, pluginConfig);
|
|
884
607
|
|
|
885
|
-
|
|
886
|
-
? result.output
|
|
887
|
-
: `Something went wrong while processing this. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
|
|
608
|
+
api.logger.info(`Comment intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning} (fallback: ${intentResult.fromFallback})`);
|
|
888
609
|
|
|
889
|
-
|
|
890
|
-
const brandingOpts = profile?.avatarUrl
|
|
891
|
-
? { createAsUser: label, displayIconUrl: profile.avatarUrl }
|
|
892
|
-
: undefined;
|
|
610
|
+
// ── Route by intent ────────────────────────────────────────────
|
|
893
611
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
612
|
+
switch (intentResult.intent) {
|
|
613
|
+
case "plan_start": {
|
|
614
|
+
if (!projectId) {
|
|
615
|
+
api.logger.info("Comment intent plan_start but no project — ignoring");
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
if (isPlanning) {
|
|
619
|
+
api.logger.info("Comment intent plan_start but already planning — treating as plan_continue");
|
|
620
|
+
// Fall through to plan_continue
|
|
621
|
+
if (planSession) {
|
|
622
|
+
void handlePlannerTurn(
|
|
623
|
+
{ api, linearApi, pluginConfig },
|
|
624
|
+
planSession,
|
|
625
|
+
{ issueId: issue.id, commentBody, commentorName: commentor },
|
|
626
|
+
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
899
627
|
}
|
|
900
|
-
|
|
901
|
-
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
902
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
628
|
+
break;
|
|
903
629
|
}
|
|
630
|
+
api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
|
|
631
|
+
void initiatePlanningSession(
|
|
632
|
+
{ api, linearApi, pluginConfig },
|
|
633
|
+
projectId,
|
|
634
|
+
{ id: issue.id, identifier: enrichedIssue.identifier, title: enrichedIssue.title, team: enrichedIssue.team },
|
|
635
|
+
).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
904
638
|
|
|
905
|
-
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
: responseBody;
|
|
910
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
911
|
-
type: "response",
|
|
912
|
-
body: truncated,
|
|
913
|
-
}).catch(() => {});
|
|
639
|
+
case "plan_finalize": {
|
|
640
|
+
if (!isPlanning || !planSession) {
|
|
641
|
+
api.logger.info("Comment intent plan_finalize but not in planning mode — ignoring");
|
|
642
|
+
break;
|
|
914
643
|
}
|
|
644
|
+
if (planSession.status === "plan_review") {
|
|
645
|
+
// Already passed audit + cross-model review — approve directly
|
|
646
|
+
api.logger.info(`Planning: approving plan for ${planSession.projectName} (from plan_review)`);
|
|
647
|
+
void (async () => {
|
|
648
|
+
try {
|
|
649
|
+
await endPlanningSession(planSession.projectId, "approved", planStatePath);
|
|
650
|
+
await linearApi.createComment(
|
|
651
|
+
planSession.rootIssueId,
|
|
652
|
+
`## Plan Approved\n\nPlan for **${planSession.projectName}** has been approved. Dispatching to workers.`,
|
|
653
|
+
);
|
|
654
|
+
// Trigger DAG dispatch
|
|
655
|
+
const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
656
|
+
const hookCtx: HookContext = {
|
|
657
|
+
api, linearApi, notify, pluginConfig,
|
|
658
|
+
configPath: pluginConfig?.dispatchStatePath as string | undefined,
|
|
659
|
+
};
|
|
660
|
+
await startProjectDispatch(hookCtx, planSession.projectId);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
api.logger.error(`Plan approval error: ${err}`);
|
|
663
|
+
}
|
|
664
|
+
})();
|
|
665
|
+
} else {
|
|
666
|
+
// Still interviewing — run audit (which transitions to plan_review)
|
|
667
|
+
void runPlanAudit(
|
|
668
|
+
{ api, linearApi, pluginConfig },
|
|
669
|
+
planSession,
|
|
670
|
+
).catch((err) => api.logger.error(`Plan audit error: ${err}`));
|
|
671
|
+
}
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
915
674
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
921
|
-
type: "error",
|
|
922
|
-
body: `Failed to process mention: ${String(err).slice(0, 500)}`,
|
|
923
|
-
}).catch(() => {});
|
|
675
|
+
case "plan_abandon": {
|
|
676
|
+
if (!isPlanning || !planSession) {
|
|
677
|
+
api.logger.info("Comment intent plan_abandon but not in planning mode — ignoring");
|
|
678
|
+
break;
|
|
924
679
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
680
|
+
void (async () => {
|
|
681
|
+
try {
|
|
682
|
+
await endPlanningSession(planSession.projectId, "abandoned", planStatePath);
|
|
683
|
+
await linearApi.createComment(
|
|
684
|
+
planSession.rootIssueId,
|
|
685
|
+
`Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
|
|
686
|
+
);
|
|
687
|
+
api.logger.info(`Planning: session abandoned for ${planSession.projectName}`);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
api.logger.error(`Plan abandon error: ${err}`);
|
|
690
|
+
}
|
|
691
|
+
})();
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
case "plan_continue": {
|
|
696
|
+
if (!isPlanning || !planSession) {
|
|
697
|
+
// Not in planning mode — treat as general
|
|
698
|
+
api.logger.info("Comment intent plan_continue but not in planning mode — dispatching to default agent");
|
|
699
|
+
void dispatchCommentToAgent(api, linearApi, profiles, resolveAgentId(api), issue, comment, commentBody, commentor, pluginConfig)
|
|
700
|
+
.catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
void handlePlannerTurn(
|
|
704
|
+
{ api, linearApi, pluginConfig },
|
|
705
|
+
planSession,
|
|
706
|
+
{ issueId: issue.id, commentBody, commentorName: commentor },
|
|
707
|
+
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
708
|
+
break;
|
|
928
709
|
}
|
|
929
|
-
|
|
710
|
+
|
|
711
|
+
case "ask_agent": {
|
|
712
|
+
const targetAgent = intentResult.agentId ?? resolveAgentId(api);
|
|
713
|
+
api.logger.info(`Comment intent ask_agent: routing to ${targetAgent}`);
|
|
714
|
+
void dispatchCommentToAgent(api, linearApi, profiles, targetAgent, issue, comment, commentBody, commentor, pluginConfig)
|
|
715
|
+
.catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
case "request_work":
|
|
720
|
+
case "question": {
|
|
721
|
+
const defaultAgent = resolveAgentId(api);
|
|
722
|
+
api.logger.info(`Comment intent ${intentResult.intent}: routing to default agent ${defaultAgent}`);
|
|
723
|
+
void dispatchCommentToAgent(api, linearApi, profiles, defaultAgent, issue, comment, commentBody, commentor, pluginConfig)
|
|
724
|
+
.catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
case "general":
|
|
729
|
+
default:
|
|
730
|
+
api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
930
733
|
|
|
931
734
|
return true;
|
|
932
735
|
}
|
|
@@ -1009,6 +812,7 @@ export async function handleLinearWebhook(
|
|
|
1009
812
|
|
|
1010
813
|
api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} — ${issue.title ?? "(untitled)"}`);
|
|
1011
814
|
|
|
815
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
1012
816
|
const linearApi = createLinearApi(api);
|
|
1013
817
|
if (!linearApi) {
|
|
1014
818
|
api.logger.error("No Linear access token — cannot triage new issue");
|
|
@@ -1017,6 +821,14 @@ export async function handleLinearWebhook(
|
|
|
1017
821
|
|
|
1018
822
|
const agentId = resolveAgentId(api);
|
|
1019
823
|
|
|
824
|
+
// Guard: prevent duplicate runs on same issue (also blocks AgentSessionEvent
|
|
825
|
+
// webhooks that arrive from sessions we create during triage)
|
|
826
|
+
if (activeRuns.has(issue.id)) {
|
|
827
|
+
api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} already has active run — skipping triage`);
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
activeRuns.add(issue.id);
|
|
831
|
+
|
|
1020
832
|
// Dispatch triage (non-blocking)
|
|
1021
833
|
void (async () => {
|
|
1022
834
|
const profiles = loadAgentProfiles();
|
|
@@ -1224,6 +1036,7 @@ export async function handleLinearWebhook(
|
|
|
1224
1036
|
}
|
|
1225
1037
|
} finally {
|
|
1226
1038
|
clearActiveSession(issue.id);
|
|
1039
|
+
activeRuns.delete(issue.id);
|
|
1227
1040
|
}
|
|
1228
1041
|
})();
|
|
1229
1042
|
|
|
@@ -1237,6 +1050,154 @@ export async function handleLinearWebhook(
|
|
|
1237
1050
|
return true;
|
|
1238
1051
|
}
|
|
1239
1052
|
|
|
1053
|
+
// ── Comment dispatch helper ───────────────────────────────────────
|
|
1054
|
+
//
|
|
1055
|
+
// Dispatches a comment to a specific agent. Used by intent-based routing
|
|
1056
|
+
// and @mention fast path.
|
|
1057
|
+
|
|
1058
|
+
async function dispatchCommentToAgent(
|
|
1059
|
+
api: OpenClawPluginApi,
|
|
1060
|
+
linearApi: LinearAgentApi,
|
|
1061
|
+
profiles: Record<string, AgentProfile>,
|
|
1062
|
+
agentId: string,
|
|
1063
|
+
issue: any,
|
|
1064
|
+
comment: any,
|
|
1065
|
+
commentBody: string,
|
|
1066
|
+
commentor: string,
|
|
1067
|
+
pluginConfig?: Record<string, unknown>,
|
|
1068
|
+
): Promise<void> {
|
|
1069
|
+
const profile = profiles[agentId];
|
|
1070
|
+
const label = profile?.label ?? agentId;
|
|
1071
|
+
const avatarUrl = profile?.avatarUrl;
|
|
1072
|
+
|
|
1073
|
+
// Guard: prevent concurrent runs on same issue
|
|
1074
|
+
if (activeRuns.has(issue.id)) {
|
|
1075
|
+
api.logger.info(`dispatchCommentToAgent: ${issue.identifier ?? issue.id} has active run — skipping`);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Fetch full issue details
|
|
1080
|
+
let enrichedIssue: any = issue;
|
|
1081
|
+
try {
|
|
1082
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
1088
|
+
const comments = enrichedIssue?.comments?.nodes ?? [];
|
|
1089
|
+
const commentSummary = comments
|
|
1090
|
+
.slice(-5)
|
|
1091
|
+
.map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 200)}`)
|
|
1092
|
+
.join("\n");
|
|
1093
|
+
|
|
1094
|
+
const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
|
|
1095
|
+
const message = [
|
|
1096
|
+
`You are an orchestrator responding to a Linear comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
|
|
1097
|
+
``,
|
|
1098
|
+
`**Tool access:**`,
|
|
1099
|
+
`- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
|
|
1100
|
+
`- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
|
|
1101
|
+
`- Standard tools: exec, read, edit, write, web_search, etc.`,
|
|
1102
|
+
``,
|
|
1103
|
+
`**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
|
|
1104
|
+
``,
|
|
1105
|
+
`## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
1106
|
+
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
1107
|
+
``,
|
|
1108
|
+
`**Description:**`,
|
|
1109
|
+
description,
|
|
1110
|
+
commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
|
|
1111
|
+
`\n**${commentor} says:**\n> ${commentBody}`,
|
|
1112
|
+
``,
|
|
1113
|
+
`Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
|
|
1114
|
+
].filter(Boolean).join("\n");
|
|
1115
|
+
|
|
1116
|
+
// Dispatch with session lifecycle
|
|
1117
|
+
activeRuns.add(issue.id);
|
|
1118
|
+
let agentSessionId: string | null = null;
|
|
1119
|
+
|
|
1120
|
+
try {
|
|
1121
|
+
// Create agent session (non-fatal)
|
|
1122
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
1123
|
+
agentSessionId = sessionResult.sessionId;
|
|
1124
|
+
if (agentSessionId) {
|
|
1125
|
+
wasRecentlyProcessed(`session:${agentSessionId}`);
|
|
1126
|
+
setActiveSession({
|
|
1127
|
+
agentSessionId,
|
|
1128
|
+
issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
|
|
1129
|
+
issueId: issue.id,
|
|
1130
|
+
agentId,
|
|
1131
|
+
startedAt: Date.now(),
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Emit thought
|
|
1136
|
+
if (agentSessionId) {
|
|
1137
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1138
|
+
type: "thought",
|
|
1139
|
+
body: `Processing comment on ${issueRef}...`,
|
|
1140
|
+
}).catch(() => {});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Run agent
|
|
1144
|
+
const sessionId = `linear-comment-${agentId}-${Date.now()}`;
|
|
1145
|
+
const { runAgent } = await import("../agent/agent.js");
|
|
1146
|
+
const result = await runAgent({
|
|
1147
|
+
api,
|
|
1148
|
+
agentId,
|
|
1149
|
+
sessionId,
|
|
1150
|
+
message,
|
|
1151
|
+
timeoutMs: 3 * 60_000,
|
|
1152
|
+
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
const responseBody = result.success
|
|
1156
|
+
? result.output
|
|
1157
|
+
: `Something went wrong while processing this. The system will retry automatically if possible.`;
|
|
1158
|
+
|
|
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)
|
|
1176
|
+
if (agentSessionId) {
|
|
1177
|
+
const truncated = responseBody.length > 2000
|
|
1178
|
+
? responseBody.slice(0, 2000) + "…"
|
|
1179
|
+
: responseBody;
|
|
1180
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1181
|
+
type: "response",
|
|
1182
|
+
body: truncated,
|
|
1183
|
+
}).catch(() => {});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
api.logger.info(`Posted ${agentId} response to ${issueRef}`);
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
api.logger.error(`dispatchCommentToAgent error: ${err}`);
|
|
1189
|
+
if (agentSessionId) {
|
|
1190
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1191
|
+
type: "error",
|
|
1192
|
+
body: `Failed to process comment: ${String(err).slice(0, 500)}`,
|
|
1193
|
+
}).catch(() => {});
|
|
1194
|
+
}
|
|
1195
|
+
} finally {
|
|
1196
|
+
clearActiveSession(issue.id);
|
|
1197
|
+
activeRuns.delete(issue.id);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1240
1201
|
// ── @dispatch handler ─────────────────────────────────────────────
|
|
1241
1202
|
//
|
|
1242
1203
|
// Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
|
|
@@ -1385,6 +1346,9 @@ async function handleDispatch(
|
|
|
1385
1346
|
}
|
|
1386
1347
|
|
|
1387
1348
|
// 6. Create agent session on Linear
|
|
1349
|
+
// Mark active BEFORE session creation so that any AgentSessionEvent.created
|
|
1350
|
+
// webhook arriving from this call is blocked by the activeRuns guard.
|
|
1351
|
+
activeRuns.add(issue.id);
|
|
1388
1352
|
let agentSessionId: string | undefined;
|
|
1389
1353
|
try {
|
|
1390
1354
|
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
@@ -1487,7 +1451,7 @@ async function handleDispatch(
|
|
|
1487
1451
|
}
|
|
1488
1452
|
|
|
1489
1453
|
// 11. Run v2 pipeline: worker → audit → verdict (non-blocking)
|
|
1490
|
-
activeRuns
|
|
1454
|
+
// (activeRuns already set in step 6 above)
|
|
1491
1455
|
|
|
1492
1456
|
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
1493
1457
|
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
|