@calltelemetry/openclaw-linear 0.3.1 → 0.4.1
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 +322 -386
- package/index.ts +54 -2
- package/openclaw.plugin.json +9 -1
- package/package.json +3 -2
- package/src/active-session.ts +106 -0
- package/src/agent.ts +173 -1
- package/src/auth.ts +6 -2
- package/src/claude-tool.ts +280 -0
- package/src/cli-shared.ts +75 -0
- package/src/cli.ts +39 -0
- package/src/client.ts +1 -0
- package/src/code-tool.ts +202 -0
- package/src/codex-tool.ts +240 -0
- package/src/codex-worktree.ts +390 -0
- package/src/dispatch-service.ts +113 -0
- package/src/dispatch-state.ts +265 -0
- package/src/gemini-tool.ts +238 -0
- package/src/orchestration-tools.ts +134 -0
- package/src/pipeline.ts +343 -56
- package/src/tier-assess.ts +157 -0
- package/src/tools.ts +29 -79
- package/src/webhook.ts +532 -275
package/src/webhook.ts
CHANGED
|
@@ -3,7 +3,11 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
5
5
|
import { LinearAgentApi, resolveLinearToken } from "./linear-api.js";
|
|
6
|
-
import { runFullPipeline,
|
|
6
|
+
import { runFullPipeline, type PipelineContext } from "./pipeline.js";
|
|
7
|
+
import { setActiveSession, clearActiveSession } from "./active-session.js";
|
|
8
|
+
import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
|
|
9
|
+
import { assessTier } from "./tier-assess.js";
|
|
10
|
+
import { createWorktree, prepareWorkspace } from "./codex-worktree.js";
|
|
7
11
|
|
|
8
12
|
// ── Agent profiles (loaded from config, no hardcoded names) ───────
|
|
9
13
|
interface AgentProfile {
|
|
@@ -49,8 +53,8 @@ function resolveAgentFromAlias(alias: string, profiles: Record<string, AgentProf
|
|
|
49
53
|
return null;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
//
|
|
53
|
-
const
|
|
56
|
+
// Track issues with active agent runs to prevent concurrent duplicate runs.
|
|
57
|
+
const activeRuns = new Set<string>();
|
|
54
58
|
|
|
55
59
|
// Dedup: track recently processed keys to avoid double-handling
|
|
56
60
|
const recentlyProcessed = new Map<string, number>();
|
|
@@ -212,6 +216,13 @@ export async function handleLinearWebhook(
|
|
|
212
216
|
agentSessionId = sessionResult.sessionId;
|
|
213
217
|
if (agentSessionId) {
|
|
214
218
|
api.logger.info(`Created agent session ${agentSessionId} for notification`);
|
|
219
|
+
setActiveSession({
|
|
220
|
+
agentSessionId,
|
|
221
|
+
issueIdentifier: enrichedIssue?.identifier ?? issue.id,
|
|
222
|
+
issueId: issue.id,
|
|
223
|
+
agentId,
|
|
224
|
+
startedAt: Date.now(),
|
|
225
|
+
});
|
|
215
226
|
} else {
|
|
216
227
|
api.logger.warn(`Could not create agent session for notification: ${sessionResult.error ?? "unknown"}`);
|
|
217
228
|
}
|
|
@@ -220,20 +231,11 @@ export async function handleLinearWebhook(
|
|
|
220
231
|
if (agentSessionId) {
|
|
221
232
|
await linearApi.emitActivity(agentSessionId, {
|
|
222
233
|
type: "thought",
|
|
223
|
-
body: `Reviewing
|
|
234
|
+
body: `Reviewing ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
224
235
|
}).catch(() => {});
|
|
225
236
|
}
|
|
226
237
|
|
|
227
|
-
// 3.
|
|
228
|
-
if (agentSessionId) {
|
|
229
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
230
|
-
type: "action",
|
|
231
|
-
action: "Processing notification",
|
|
232
|
-
parameter: notifType ?? "unknown",
|
|
233
|
-
}).catch(() => {});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// 4. Run agent
|
|
238
|
+
// 3. Run agent with streaming
|
|
237
239
|
const sessionId = `linear-notif-${notification?.type ?? "unknown"}-${Date.now()}`;
|
|
238
240
|
const { runAgent } = await import("./agent.js");
|
|
239
241
|
const result = await runAgent({
|
|
@@ -242,6 +244,7 @@ export async function handleLinearWebhook(
|
|
|
242
244
|
sessionId,
|
|
243
245
|
message,
|
|
244
246
|
timeoutMs: 3 * 60_000,
|
|
247
|
+
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
245
248
|
});
|
|
246
249
|
|
|
247
250
|
const responseBody = result.success
|
|
@@ -284,13 +287,18 @@ export async function handleLinearWebhook(
|
|
|
284
287
|
body: `Failed to process notification: ${String(err).slice(0, 500)}`,
|
|
285
288
|
}).catch(() => {});
|
|
286
289
|
}
|
|
290
|
+
} finally {
|
|
291
|
+
clearActiveSession(issue.id);
|
|
287
292
|
}
|
|
288
293
|
})();
|
|
289
294
|
|
|
290
295
|
return true;
|
|
291
296
|
}
|
|
292
297
|
|
|
293
|
-
// ── AgentSessionEvent.created —
|
|
298
|
+
// ── AgentSessionEvent.created — direct agent run ─────────────────
|
|
299
|
+
// User chatted with @ctclaw in Linear's agent session. Run the agent
|
|
300
|
+
// DIRECTLY with the user's message. The plan→implement→audit pipeline
|
|
301
|
+
// is only triggered from Issue.update delegation, not from chat.
|
|
294
302
|
if (
|
|
295
303
|
(payload.type === "AgentSessionEvent" && payload.action === "created") ||
|
|
296
304
|
(payload.type === "AgentSession" && payload.action === "create")
|
|
@@ -307,7 +315,7 @@ export async function handleLinearWebhook(
|
|
|
307
315
|
return true;
|
|
308
316
|
}
|
|
309
317
|
|
|
310
|
-
// Dedup: skip if we already handled this session
|
|
318
|
+
// Dedup: skip if we already handled this session
|
|
311
319
|
if (wasRecentlyProcessed(`session:${session.id}`)) {
|
|
312
320
|
api.logger.info(`AgentSession ${session.id} already handled — skipping`);
|
|
313
321
|
return true;
|
|
@@ -315,47 +323,147 @@ export async function handleLinearWebhook(
|
|
|
315
323
|
|
|
316
324
|
const linearApi = createLinearApi(api);
|
|
317
325
|
if (!linearApi) {
|
|
318
|
-
api.logger.error("No Linear access token configured
|
|
326
|
+
api.logger.error("No Linear access token configured");
|
|
319
327
|
return true;
|
|
320
328
|
}
|
|
321
329
|
|
|
322
330
|
const agentId = resolveAgentId(api);
|
|
323
|
-
|
|
324
331
|
const previousComments = payload.previousComments ?? [];
|
|
325
332
|
const guidance = payload.guidance;
|
|
326
333
|
|
|
327
334
|
api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`);
|
|
328
335
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
336
|
+
// Guard: skip if an agent run is already active for this issue
|
|
337
|
+
if (activeRuns.has(issue.id)) {
|
|
338
|
+
api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Extract the user's latest message from previousComments
|
|
343
|
+
// The last comment is the most recent user message
|
|
344
|
+
const lastComment = previousComments.length > 0
|
|
345
|
+
? previousComments[previousComments.length - 1]
|
|
346
|
+
: null;
|
|
347
|
+
const userMessage = lastComment?.body ?? guidance ?? "";
|
|
348
|
+
|
|
349
|
+
// Fetch full issue details
|
|
350
|
+
let enrichedIssue: any = issue;
|
|
351
|
+
try {
|
|
352
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
358
|
+
|
|
359
|
+
// Build conversation context from previous comments
|
|
360
|
+
const commentContext = previousComments
|
|
361
|
+
.slice(-5)
|
|
362
|
+
.map((c: any) => `**${c.user?.name ?? c.actorName ?? "User"}**: ${(c.body ?? "").slice(0, 300)}`)
|
|
363
|
+
.join("\n\n");
|
|
364
|
+
|
|
365
|
+
const message = [
|
|
366
|
+
`You are an AI agent responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
|
|
367
|
+
`You have access to tools including \`code_run\` for coding tasks, \`spawn_agent\`/\`ask_agent\` for delegation, and standard tools (exec, read, edit, write, web_search, etc.). To manage Linear issues (update status, close, assign, comment, etc.) use the \`linearis\` CLI via exec — e.g. \`linearis issues update API-123 --status done\` to close an issue, \`linearis issues update API-123 --status "In Progress"\` to change status, \`linearis issues list\` to list issues. Run \`linearis usage\` for full command reference.`,
|
|
368
|
+
``,
|
|
369
|
+
`## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
370
|
+
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
371
|
+
``,
|
|
372
|
+
`**Description:**`,
|
|
373
|
+
description,
|
|
374
|
+
commentContext ? `\n**Conversation:**\n${commentContext}` : "",
|
|
375
|
+
userMessage ? `\n**Latest message:**\n> ${userMessage}` : "",
|
|
376
|
+
``,
|
|
377
|
+
`Respond to the user's request. If they ask you to write code or make changes, use the \`code_run\` tool. Be concise and action-oriented.`,
|
|
378
|
+
].filter(Boolean).join("\n");
|
|
379
|
+
|
|
380
|
+
// Run agent directly (non-blocking)
|
|
381
|
+
activeRuns.add(issue.id);
|
|
344
382
|
void (async () => {
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
383
|
+
const profiles = loadAgentProfiles();
|
|
384
|
+
const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
385
|
+
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
386
|
+
|
|
387
|
+
// Register active session for tool resolution (code_run, etc.)
|
|
388
|
+
setActiveSession({
|
|
389
|
+
agentSessionId: session.id,
|
|
390
|
+
issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
|
|
391
|
+
issueId: issue.id,
|
|
392
|
+
agentId,
|
|
393
|
+
startedAt: Date.now(),
|
|
349
394
|
});
|
|
350
|
-
|
|
351
|
-
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
// Emit initial thought
|
|
398
|
+
await linearApi.emitActivity(session.id, {
|
|
399
|
+
type: "thought",
|
|
400
|
+
body: `Processing request for ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
401
|
+
}).catch(() => {});
|
|
402
|
+
|
|
403
|
+
// Run agent with streaming to Linear
|
|
404
|
+
const sessionId = `linear-session-${session.id}`;
|
|
405
|
+
const { runAgent } = await import("./agent.js");
|
|
406
|
+
const result = await runAgent({
|
|
407
|
+
api,
|
|
408
|
+
agentId,
|
|
409
|
+
sessionId,
|
|
410
|
+
message,
|
|
411
|
+
timeoutMs: 5 * 60_000,
|
|
412
|
+
streaming: {
|
|
413
|
+
linearApi,
|
|
414
|
+
agentSessionId: session.id,
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const responseBody = result.success
|
|
419
|
+
? result.output
|
|
420
|
+
: `I encountered an error processing this request. Please try again.`;
|
|
421
|
+
|
|
422
|
+
// Post as comment
|
|
423
|
+
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
424
|
+
const brandingOpts = avatarUrl
|
|
425
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
426
|
+
: undefined;
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
if (brandingOpts) {
|
|
430
|
+
await linearApi.createComment(issue.id, responseBody, brandingOpts);
|
|
431
|
+
} else {
|
|
432
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
433
|
+
}
|
|
434
|
+
} catch (brandErr) {
|
|
435
|
+
api.logger.warn(`Branded comment failed: ${brandErr}`);
|
|
436
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Emit response (closes session)
|
|
440
|
+
const truncated = responseBody.length > 2000
|
|
441
|
+
? responseBody.slice(0, 2000) + "\u2026"
|
|
442
|
+
: responseBody;
|
|
443
|
+
await linearApi.emitActivity(session.id, {
|
|
444
|
+
type: "response",
|
|
445
|
+
body: truncated,
|
|
446
|
+
}).catch(() => {});
|
|
447
|
+
|
|
448
|
+
api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
api.logger.error(`AgentSession handler error: ${err}`);
|
|
451
|
+
await linearApi.emitActivity(session.id, {
|
|
452
|
+
type: "error",
|
|
453
|
+
body: `Failed: ${String(err).slice(0, 500)}`,
|
|
454
|
+
}).catch(() => {});
|
|
455
|
+
} finally {
|
|
456
|
+
clearActiveSession(issue.id);
|
|
457
|
+
activeRuns.delete(issue.id);
|
|
352
458
|
}
|
|
353
459
|
})();
|
|
354
460
|
|
|
355
461
|
return true;
|
|
356
462
|
}
|
|
357
463
|
|
|
358
|
-
// ── AgentSession.prompted — user
|
|
464
|
+
// ── AgentSession.prompted — follow-up user messages in existing sessions
|
|
465
|
+
// Also fires when we emit activities (feedback loop). Use activeRuns guard
|
|
466
|
+
// and webhookId dedup to distinguish user follow-ups from our own emissions.
|
|
359
467
|
if (
|
|
360
468
|
(payload.type === "AgentSessionEvent" && payload.action === "prompted") ||
|
|
361
469
|
(payload.type === "AgentSession" && payload.action === "prompted")
|
|
@@ -364,55 +472,160 @@ export async function handleLinearWebhook(
|
|
|
364
472
|
res.end("ok");
|
|
365
473
|
|
|
366
474
|
const session = payload.agentSession ?? payload.data;
|
|
367
|
-
|
|
368
|
-
|
|
475
|
+
const issue = session?.issue ?? payload.issue;
|
|
476
|
+
const activity = payload.agentActivity;
|
|
477
|
+
|
|
478
|
+
if (!session?.id || !issue?.id) {
|
|
479
|
+
api.logger.info(`AgentSession prompted: missing session or issue — ignoring`);
|
|
369
480
|
return true;
|
|
370
481
|
}
|
|
371
482
|
|
|
372
|
-
|
|
483
|
+
// If an agent run is already active for this issue, this is feedback from
|
|
484
|
+
// our own activity emissions — ignore to prevent loops.
|
|
485
|
+
if (activeRuns.has(issue.id)) {
|
|
486
|
+
api.logger.info(`AgentSession prompted: ${session.id} issue=${issue?.identifier ?? issue?.id} — agent active, ignoring (feedback)`);
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
373
489
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
490
|
+
// Dedup by webhookId
|
|
491
|
+
const webhookId = payload.webhookId;
|
|
492
|
+
if (webhookId && wasRecentlyProcessed(`webhook:${webhookId}`)) {
|
|
493
|
+
api.logger.info(`AgentSession prompted: webhook ${webhookId} already processed — skipping`);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
377
496
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
497
|
+
// Extract user message from the activity or prompt context
|
|
498
|
+
const promptContext = payload.promptContext;
|
|
499
|
+
const userMessage =
|
|
500
|
+
activity?.content?.body ??
|
|
501
|
+
activity?.body ??
|
|
502
|
+
promptContext?.message ??
|
|
503
|
+
promptContext ??
|
|
504
|
+
"";
|
|
505
|
+
|
|
506
|
+
if (!userMessage || typeof userMessage !== "string" || userMessage.trim().length === 0) {
|
|
507
|
+
api.logger.info(`AgentSession prompted: ${session.id} — no user message found, ignoring`);
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
385
510
|
|
|
386
|
-
|
|
511
|
+
const linearApi = createLinearApi(api);
|
|
512
|
+
if (!linearApi) {
|
|
513
|
+
api.logger.error("No Linear access token configured");
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
api.logger.info(`AgentSession prompted (follow-up): ${session.id} issue=${issue?.identifier ?? issue?.id} message="${userMessage.slice(0, 80)}..."`);
|
|
518
|
+
|
|
519
|
+
const agentId = resolveAgentId(api);
|
|
520
|
+
|
|
521
|
+
// Run agent for follow-up (non-blocking)
|
|
522
|
+
activeRuns.add(issue.id);
|
|
523
|
+
void (async () => {
|
|
524
|
+
const profiles = loadAgentProfiles();
|
|
525
|
+
const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
526
|
+
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
387
527
|
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
528
|
+
// Fetch full issue details for context
|
|
529
|
+
let enrichedIssue: any = issue;
|
|
530
|
+
try {
|
|
531
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
534
|
+
}
|
|
391
535
|
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
536
|
+
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
537
|
+
|
|
538
|
+
// Build context from recent comments
|
|
539
|
+
const recentComments = enrichedIssue?.comments?.nodes ?? [];
|
|
540
|
+
const commentContext = recentComments
|
|
541
|
+
.slice(-5)
|
|
542
|
+
.map((c: any) => `**${c.user?.name ?? "User"}**: ${(c.body ?? "").slice(0, 300)}`)
|
|
543
|
+
.join("\n\n");
|
|
544
|
+
|
|
545
|
+
const message = [
|
|
546
|
+
`You are an AI agent responding in a Linear issue session. Your text output will be posted as activities visible to the user.`,
|
|
547
|
+
`You have access to tools including \`code_run\` for coding tasks, \`spawn_agent\`/\`ask_agent\` for delegation, and standard tools (exec, read, edit, write, web_search, etc.). To manage Linear issues (update status, close, assign, comment, etc.) use the \`linearis\` CLI via exec — e.g. \`linearis issues update API-123 --status done\` to close an issue, \`linearis issues update API-123 --status "In Progress"\` to change status, \`linearis issues list\` to list issues. Run \`linearis usage\` for full command reference.`,
|
|
548
|
+
``,
|
|
549
|
+
`## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
550
|
+
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
|
|
551
|
+
``,
|
|
552
|
+
`**Description:**`,
|
|
553
|
+
description,
|
|
554
|
+
commentContext ? `\n**Recent conversation:**\n${commentContext}` : "",
|
|
555
|
+
`\n**User's follow-up message:**\n> ${userMessage}`,
|
|
556
|
+
``,
|
|
557
|
+
`Respond to the user's follow-up. Be concise and action-oriented.`,
|
|
558
|
+
].filter(Boolean).join("\n");
|
|
559
|
+
|
|
560
|
+
setActiveSession({
|
|
395
561
|
agentSessionId: session.id,
|
|
562
|
+
issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
|
|
563
|
+
issueId: issue.id,
|
|
396
564
|
agentId,
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
565
|
+
startedAt: Date.now(),
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
await linearApi.emitActivity(session.id, {
|
|
570
|
+
type: "thought",
|
|
571
|
+
body: `Processing follow-up for ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
572
|
+
}).catch(() => {});
|
|
573
|
+
|
|
574
|
+
const sessionId = `linear-session-${session.id}`;
|
|
575
|
+
const { runAgent } = await import("./agent.js");
|
|
576
|
+
const result = await runAgent({
|
|
577
|
+
api,
|
|
578
|
+
agentId,
|
|
579
|
+
sessionId,
|
|
580
|
+
message,
|
|
581
|
+
timeoutMs: 5 * 60_000,
|
|
582
|
+
streaming: {
|
|
583
|
+
linearApi,
|
|
584
|
+
agentSessionId: session.id,
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const responseBody = result.success
|
|
589
|
+
? result.output
|
|
590
|
+
: `I encountered an error processing this request. Please try again.`;
|
|
410
591
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
592
|
+
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
593
|
+
const brandingOpts = avatarUrl
|
|
594
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
595
|
+
: undefined;
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
if (brandingOpts) {
|
|
599
|
+
await linearApi.createComment(issue.id, responseBody, brandingOpts);
|
|
600
|
+
} else {
|
|
601
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
602
|
+
}
|
|
603
|
+
} catch (brandErr) {
|
|
604
|
+
api.logger.warn(`Branded comment failed: ${brandErr}`);
|
|
605
|
+
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const truncated = responseBody.length > 2000
|
|
609
|
+
? responseBody.slice(0, 2000) + "\u2026"
|
|
610
|
+
: responseBody;
|
|
611
|
+
await linearApi.emitActivity(session.id, {
|
|
612
|
+
type: "response",
|
|
613
|
+
body: truncated,
|
|
614
|
+
}).catch(() => {});
|
|
615
|
+
|
|
616
|
+
api.logger.info(`Posted follow-up response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
api.logger.error(`AgentSession prompted handler error: ${err}`);
|
|
619
|
+
await linearApi.emitActivity(session.id, {
|
|
620
|
+
type: "error",
|
|
621
|
+
body: `Failed: ${String(err).slice(0, 500)}`,
|
|
622
|
+
}).catch(() => {});
|
|
623
|
+
} finally {
|
|
624
|
+
clearActiveSession(issue.id);
|
|
625
|
+
activeRuns.delete(issue.id);
|
|
626
|
+
}
|
|
627
|
+
})();
|
|
414
628
|
|
|
415
|
-
void resumePipeline(stored.ctx, stored.plan);
|
|
416
629
|
return true;
|
|
417
630
|
}
|
|
418
631
|
|
|
@@ -526,6 +739,14 @@ export async function handleLinearWebhook(
|
|
|
526
739
|
agentSessionId = sessionResult.sessionId;
|
|
527
740
|
if (agentSessionId) {
|
|
528
741
|
api.logger.info(`Created agent session ${agentSessionId} for @${mentionedAgent}`);
|
|
742
|
+
// Register active session so code_run can resolve it automatically
|
|
743
|
+
setActiveSession({
|
|
744
|
+
agentSessionId,
|
|
745
|
+
issueIdentifier: enrichedIssue.identifier ?? issue.id,
|
|
746
|
+
issueId: issue.id,
|
|
747
|
+
agentId: mentionedAgent,
|
|
748
|
+
startedAt: Date.now(),
|
|
749
|
+
});
|
|
529
750
|
} else {
|
|
530
751
|
api.logger.warn(`Could not create agent session for @${mentionedAgent}: ${sessionResult.error ?? "unknown"} — falling back to flat comment`);
|
|
531
752
|
}
|
|
@@ -534,20 +755,11 @@ export async function handleLinearWebhook(
|
|
|
534
755
|
if (agentSessionId) {
|
|
535
756
|
await linearApi.emitActivity(agentSessionId, {
|
|
536
757
|
type: "thought",
|
|
537
|
-
body: `
|
|
538
|
-
}).catch(() => {});
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// 3. Emit action
|
|
542
|
-
if (agentSessionId) {
|
|
543
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
544
|
-
type: "action",
|
|
545
|
-
action: "Processing mention",
|
|
546
|
-
parameter: `@${alias} by ${commentor}`,
|
|
758
|
+
body: `Reviewing ${enrichedIssue.identifier ?? issue.id}...`,
|
|
547
759
|
}).catch(() => {});
|
|
548
760
|
}
|
|
549
761
|
|
|
550
|
-
//
|
|
762
|
+
// 3. Run agent subprocess with streaming
|
|
551
763
|
const sessionId = `linear-comment-${comment.id ?? Date.now()}`;
|
|
552
764
|
const { runAgent } = await import("./agent.js");
|
|
553
765
|
const result = await runAgent({
|
|
@@ -556,6 +768,7 @@ export async function handleLinearWebhook(
|
|
|
556
768
|
sessionId,
|
|
557
769
|
message: taskMessage,
|
|
558
770
|
timeoutMs: 3 * 60_000,
|
|
771
|
+
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
559
772
|
});
|
|
560
773
|
|
|
561
774
|
const responseBody = result.success
|
|
@@ -592,13 +805,14 @@ export async function handleLinearWebhook(
|
|
|
592
805
|
api.logger.info(`Posted @${mentionedAgent} response to ${issue.identifier ?? issue.id}`);
|
|
593
806
|
} catch (err) {
|
|
594
807
|
api.logger.error(`Comment mention handler error: ${err}`);
|
|
595
|
-
// 7. Emit error activity if session exists
|
|
596
808
|
if (agentSessionId) {
|
|
597
809
|
await linearApi.emitActivity(agentSessionId, {
|
|
598
810
|
type: "error",
|
|
599
811
|
body: `Failed to process mention: ${String(err).slice(0, 500)}`,
|
|
600
812
|
}).catch(() => {});
|
|
601
813
|
}
|
|
814
|
+
} finally {
|
|
815
|
+
clearActiveSession(issue.id);
|
|
602
816
|
}
|
|
603
817
|
})();
|
|
604
818
|
|
|
@@ -646,7 +860,7 @@ export async function handleLinearWebhook(
|
|
|
646
860
|
}
|
|
647
861
|
|
|
648
862
|
const trigger = isDelegatedToUs ? "delegated" : "assigned";
|
|
649
|
-
api.logger.info(`Issue ${trigger} to our app user (${viewerId}),
|
|
863
|
+
api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
|
|
650
864
|
|
|
651
865
|
// Dedup on assignment/delegation
|
|
652
866
|
const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
|
|
@@ -655,191 +869,11 @@ export async function handleLinearWebhook(
|
|
|
655
869
|
return true;
|
|
656
870
|
}
|
|
657
871
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
try {
|
|
664
|
-
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
665
|
-
if (enrichedIssue?.team?.id) {
|
|
666
|
-
teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
|
|
667
|
-
}
|
|
668
|
-
} catch (err) {
|
|
669
|
-
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
673
|
-
const comments = enrichedIssue?.comments?.nodes ?? [];
|
|
674
|
-
const commentSummary = comments
|
|
675
|
-
.slice(-5)
|
|
676
|
-
.map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
|
|
677
|
-
.join("\n");
|
|
678
|
-
|
|
679
|
-
const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
|
|
680
|
-
const currentLabels = enrichedIssue?.labels?.nodes ?? [];
|
|
681
|
-
const currentLabelNames = currentLabels.map((l: any) => l.name).join(", ") || "None";
|
|
682
|
-
const availableLabelList = teamLabels.map((l) => ` - "${l.name}" (id: ${l.id})`).join("\n");
|
|
683
|
-
|
|
684
|
-
const message = [
|
|
685
|
-
`IMPORTANT: You are triaging a delegated Linear issue. You MUST respond with a JSON block containing your triage decisions, followed by your assessment as plain text.`,
|
|
686
|
-
``,
|
|
687
|
-
`## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
688
|
-
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
|
|
689
|
-
``,
|
|
690
|
-
`**Description:**`,
|
|
691
|
-
description,
|
|
692
|
-
commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
|
|
693
|
-
``,
|
|
694
|
-
`## Your Triage Tasks`,
|
|
695
|
-
``,
|
|
696
|
-
`1. **Story Points** — Estimate complexity using ${estimationType} scale (1=trivial, 2=small, 3=medium, 5=large, 8=very large, 13=epic)`,
|
|
697
|
-
`2. **Labels** — Select appropriate labels from the team's available labels`,
|
|
698
|
-
`3. **Assessment** — Brief analysis of what this issue needs`,
|
|
699
|
-
``,
|
|
700
|
-
`## Available Labels`,
|
|
701
|
-
availableLabelList || " (no labels configured)",
|
|
702
|
-
``,
|
|
703
|
-
`## Response Format`,
|
|
704
|
-
``,
|
|
705
|
-
`You MUST start your response with a JSON block, then follow with your assessment:`,
|
|
706
|
-
``,
|
|
707
|
-
'```json',
|
|
708
|
-
`{`,
|
|
709
|
-
` "estimate": <number>,`,
|
|
710
|
-
` "labelIds": ["<id1>", "<id2>"],`,
|
|
711
|
-
` "assessment": "<one-line summary of your sizing rationale>"`,
|
|
712
|
-
`}`,
|
|
713
|
-
'```',
|
|
714
|
-
``,
|
|
715
|
-
`Then write your full assessment as markdown below the JSON block.`,
|
|
716
|
-
].filter(Boolean).join("\n");
|
|
717
|
-
|
|
718
|
-
// Dispatch agent with session lifecycle (non-blocking)
|
|
719
|
-
void (async () => {
|
|
720
|
-
const profiles = loadAgentProfiles();
|
|
721
|
-
const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
722
|
-
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
723
|
-
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
724
|
-
let agentSessionId: string | null = null;
|
|
725
|
-
|
|
726
|
-
try {
|
|
727
|
-
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
728
|
-
agentSessionId = sessionResult.sessionId;
|
|
729
|
-
if (agentSessionId) {
|
|
730
|
-
// Mark session as processed so AgentSessionEvent handler skips it
|
|
731
|
-
wasRecentlyProcessed(`session:${agentSessionId}`);
|
|
732
|
-
api.logger.info(`Created agent session ${agentSessionId} for ${trigger}`);
|
|
733
|
-
} else {
|
|
734
|
-
api.logger.warn(`Could not create agent session for assignment: ${sessionResult.error ?? "unknown"}`);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
if (agentSessionId) {
|
|
738
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
739
|
-
type: "thought",
|
|
740
|
-
body: `Reviewing assigned issue ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
741
|
-
}).catch(() => {});
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
if (agentSessionId) {
|
|
745
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
746
|
-
type: "action",
|
|
747
|
-
action: "Triaging",
|
|
748
|
-
parameter: `${enrichedIssue?.identifier ?? issue.id} — estimating, labeling, sizing`,
|
|
749
|
-
}).catch(() => {});
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
const sessionId = `linear-assign-${issue.id}-${Date.now()}`;
|
|
753
|
-
const { runAgent } = await import("./agent.js");
|
|
754
|
-
const result = await runAgent({
|
|
755
|
-
api,
|
|
756
|
-
agentId,
|
|
757
|
-
sessionId,
|
|
758
|
-
message,
|
|
759
|
-
timeoutMs: 3 * 60_000,
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
const responseBody = result.success
|
|
763
|
-
? result.output
|
|
764
|
-
: `I encountered an error reviewing this assignment. Please try again.`;
|
|
765
|
-
|
|
766
|
-
// Parse triage JSON from agent response and apply to issue
|
|
767
|
-
let commentBody = responseBody;
|
|
768
|
-
if (result.success) {
|
|
769
|
-
const jsonMatch = responseBody.match(/```json\s*\n?([\s\S]*?)\n?```/);
|
|
770
|
-
if (jsonMatch) {
|
|
771
|
-
try {
|
|
772
|
-
const triage = JSON.parse(jsonMatch[1]);
|
|
773
|
-
const updateInput: Record<string, unknown> = {};
|
|
774
|
-
|
|
775
|
-
if (typeof triage.estimate === "number") {
|
|
776
|
-
updateInput.estimate = triage.estimate;
|
|
777
|
-
}
|
|
778
|
-
if (Array.isArray(triage.labelIds) && triage.labelIds.length > 0) {
|
|
779
|
-
// Merge with existing labels
|
|
780
|
-
const existingIds = currentLabels.map((l: any) => l.id);
|
|
781
|
-
const allIds = [...new Set([...existingIds, ...triage.labelIds])];
|
|
782
|
-
updateInput.labelIds = allIds;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
if (Object.keys(updateInput).length > 0) {
|
|
786
|
-
await linearApi.updateIssue(issue.id, updateInput);
|
|
787
|
-
api.logger.info(`Applied triage to ${enrichedIssue?.identifier ?? issue.id}: ${JSON.stringify(updateInput)}`);
|
|
788
|
-
|
|
789
|
-
if (agentSessionId) {
|
|
790
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
791
|
-
type: "action",
|
|
792
|
-
action: "Applied triage",
|
|
793
|
-
result: `estimate=${triage.estimate ?? "unchanged"}, labels=${triage.labelIds?.length ?? 0} added`,
|
|
794
|
-
}).catch(() => {});
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Strip the JSON block from the comment — post only the assessment
|
|
799
|
-
commentBody = responseBody.replace(/```json\s*\n?[\s\S]*?\n?```\s*\n?/, "").trim();
|
|
800
|
-
} catch (parseErr) {
|
|
801
|
-
api.logger.warn(`Could not parse triage JSON: ${parseErr}`);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
// Post comment with assessment
|
|
807
|
-
const brandingOpts = avatarUrl
|
|
808
|
-
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
809
|
-
: undefined;
|
|
810
|
-
|
|
811
|
-
try {
|
|
812
|
-
if (brandingOpts) {
|
|
813
|
-
await linearApi.createComment(issue.id, commentBody, brandingOpts);
|
|
814
|
-
} else {
|
|
815
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
816
|
-
}
|
|
817
|
-
} catch (brandErr) {
|
|
818
|
-
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
819
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
if (agentSessionId) {
|
|
823
|
-
const truncated = commentBody.length > 2000
|
|
824
|
-
? commentBody.slice(0, 2000) + "…"
|
|
825
|
-
: commentBody;
|
|
826
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
827
|
-
type: "response",
|
|
828
|
-
body: truncated,
|
|
829
|
-
}).catch(() => {});
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
api.logger.info(`Posted assignment response to ${enrichedIssue?.identifier ?? issue.id}`);
|
|
833
|
-
} catch (err) {
|
|
834
|
-
api.logger.error(`Issue assignment handler error: ${err}`);
|
|
835
|
-
if (agentSessionId) {
|
|
836
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
837
|
-
type: "error",
|
|
838
|
-
body: `Failed to process assignment: ${String(err).slice(0, 500)}`,
|
|
839
|
-
}).catch(() => {});
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
})();
|
|
872
|
+
// Assignment triggers the full dispatch pipeline:
|
|
873
|
+
// tier assessment → worktree → plan → implement → audit
|
|
874
|
+
void handleDispatch(api, linearApi, issue).catch((err) => {
|
|
875
|
+
api.logger.error(`Dispatch pipeline error for ${issue.identifier ?? issue.id}: ${err}`);
|
|
876
|
+
});
|
|
843
877
|
|
|
844
878
|
return true;
|
|
845
879
|
}
|
|
@@ -904,6 +938,13 @@ export async function handleLinearWebhook(
|
|
|
904
938
|
if (agentSessionId) {
|
|
905
939
|
wasRecentlyProcessed(`session:${agentSessionId}`);
|
|
906
940
|
api.logger.info(`Created agent session ${agentSessionId} for Issue.create triage`);
|
|
941
|
+
setActiveSession({
|
|
942
|
+
agentSessionId,
|
|
943
|
+
issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
|
|
944
|
+
issueId: issue.id,
|
|
945
|
+
agentId,
|
|
946
|
+
startedAt: Date.now(),
|
|
947
|
+
});
|
|
907
948
|
}
|
|
908
949
|
|
|
909
950
|
if (agentSessionId) {
|
|
@@ -964,6 +1005,7 @@ export async function handleLinearWebhook(
|
|
|
964
1005
|
sessionId,
|
|
965
1006
|
message,
|
|
966
1007
|
timeoutMs: 3 * 60_000,
|
|
1008
|
+
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
967
1009
|
});
|
|
968
1010
|
|
|
969
1011
|
const responseBody = result.success
|
|
@@ -1047,6 +1089,8 @@ export async function handleLinearWebhook(
|
|
|
1047
1089
|
body: `Failed to triage: ${String(err).slice(0, 500)}`,
|
|
1048
1090
|
}).catch(() => {});
|
|
1049
1091
|
}
|
|
1092
|
+
} finally {
|
|
1093
|
+
clearActiveSession(issue.id);
|
|
1050
1094
|
}
|
|
1051
1095
|
})();
|
|
1052
1096
|
|
|
@@ -1059,3 +1103,216 @@ export async function handleLinearWebhook(
|
|
|
1059
1103
|
res.end("ok");
|
|
1060
1104
|
return true;
|
|
1061
1105
|
}
|
|
1106
|
+
|
|
1107
|
+
// ── @dispatch handler ─────────────────────────────────────────────
|
|
1108
|
+
//
|
|
1109
|
+
// Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
|
|
1110
|
+
// creates a persistent worktree, registers the dispatch in state, and
|
|
1111
|
+
// launches the pipeline (plan → implement → audit).
|
|
1112
|
+
|
|
1113
|
+
async function handleDispatch(
|
|
1114
|
+
api: OpenClawPluginApi,
|
|
1115
|
+
linearApi: LinearAgentApi,
|
|
1116
|
+
issue: any,
|
|
1117
|
+
): Promise<void> {
|
|
1118
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
1119
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
1120
|
+
const worktreeBaseDir = pluginConfig?.worktreeBaseDir as string | undefined;
|
|
1121
|
+
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
|
|
1122
|
+
const identifier = issue.identifier ?? issue.id;
|
|
1123
|
+
|
|
1124
|
+
api.logger.info(`@dispatch: processing ${identifier}`);
|
|
1125
|
+
|
|
1126
|
+
// 1. Check for existing active dispatch — reclaim if stale
|
|
1127
|
+
const STALE_DISPATCH_MS = 30 * 60_000; // 30 min without a gateway holding it = stale
|
|
1128
|
+
const state = await readDispatchState(statePath);
|
|
1129
|
+
const existing = getActiveDispatch(state, identifier);
|
|
1130
|
+
if (existing) {
|
|
1131
|
+
const ageMs = Date.now() - new Date(existing.dispatchedAt).getTime();
|
|
1132
|
+
const isStale = ageMs > STALE_DISPATCH_MS;
|
|
1133
|
+
const inMemory = activeRuns.has(issue.id);
|
|
1134
|
+
|
|
1135
|
+
if (!isStale && inMemory) {
|
|
1136
|
+
// Truly still running in this gateway process
|
|
1137
|
+
api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
|
|
1138
|
+
await linearApi.createComment(
|
|
1139
|
+
issue.id,
|
|
1140
|
+
`Already running as **${existing.tier}** (status: ${existing.status}, started ${Math.round(ageMs / 60_000)}m ago). Worktree: \`${existing.worktreePath}\``,
|
|
1141
|
+
);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Stale or not in memory (gateway restarted) — reclaim
|
|
1146
|
+
api.logger.info(
|
|
1147
|
+
`dispatch: ${identifier} reclaiming stale dispatch (status: ${existing.status}, ` +
|
|
1148
|
+
`age: ${Math.round(ageMs / 1000)}s, inMemory: ${inMemory}, stale: ${isStale})`,
|
|
1149
|
+
);
|
|
1150
|
+
await removeActiveDispatch(identifier, statePath);
|
|
1151
|
+
activeRuns.delete(issue.id);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// 2. Prevent concurrent runs on same issue
|
|
1155
|
+
if (activeRuns.has(issue.id)) {
|
|
1156
|
+
api.logger.info(`@dispatch: ${identifier} has active agent run — skipping`);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// 3. Fetch full issue details for tier assessment
|
|
1161
|
+
let enrichedIssue: any;
|
|
1162
|
+
try {
|
|
1163
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
api.logger.error(`@dispatch: failed to fetch issue details: ${err}`);
|
|
1166
|
+
enrichedIssue = issue;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
|
|
1170
|
+
const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
|
|
1171
|
+
|
|
1172
|
+
// 4. Assess complexity tier
|
|
1173
|
+
const assessment = await assessTier(api, {
|
|
1174
|
+
identifier,
|
|
1175
|
+
title: enrichedIssue.title ?? "(untitled)",
|
|
1176
|
+
description: enrichedIssue.description,
|
|
1177
|
+
labels,
|
|
1178
|
+
commentCount,
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
|
|
1182
|
+
|
|
1183
|
+
// 5. Create persistent worktree
|
|
1184
|
+
let worktree;
|
|
1185
|
+
try {
|
|
1186
|
+
worktree = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
|
|
1187
|
+
api.logger.info(`@dispatch: worktree ${worktree.resumed ? "resumed" : "created"} at ${worktree.path}`);
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
api.logger.error(`@dispatch: worktree creation failed: ${err}`);
|
|
1190
|
+
await linearApi.createComment(
|
|
1191
|
+
issue.id,
|
|
1192
|
+
`Dispatch failed — could not create worktree: ${String(err).slice(0, 200)}`,
|
|
1193
|
+
);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// 5b. Prepare workspace: pull latest from origin + init submodules
|
|
1198
|
+
const prep = prepareWorkspace(worktree.path, worktree.branch);
|
|
1199
|
+
if (prep.errors.length > 0) {
|
|
1200
|
+
api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
|
|
1201
|
+
} else {
|
|
1202
|
+
api.logger.info(
|
|
1203
|
+
`@dispatch: workspace prepared — pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`,
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// 6. Create agent session on Linear
|
|
1208
|
+
let agentSessionId: string | undefined;
|
|
1209
|
+
try {
|
|
1210
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
1211
|
+
agentSessionId = sessionResult.sessionId ?? undefined;
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
api.logger.warn(`@dispatch: could not create agent session: ${err}`);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// 7. Register dispatch in persistent state
|
|
1217
|
+
const now = new Date().toISOString();
|
|
1218
|
+
await registerDispatch(identifier, {
|
|
1219
|
+
issueId: issue.id,
|
|
1220
|
+
issueIdentifier: identifier,
|
|
1221
|
+
worktreePath: worktree.path,
|
|
1222
|
+
branch: worktree.branch,
|
|
1223
|
+
tier: assessment.tier,
|
|
1224
|
+
model: assessment.model,
|
|
1225
|
+
status: "dispatched",
|
|
1226
|
+
dispatchedAt: now,
|
|
1227
|
+
agentSessionId,
|
|
1228
|
+
}, statePath);
|
|
1229
|
+
|
|
1230
|
+
// 8. Register active session for tool resolution
|
|
1231
|
+
setActiveSession({
|
|
1232
|
+
agentSessionId: agentSessionId ?? "",
|
|
1233
|
+
issueIdentifier: identifier,
|
|
1234
|
+
issueId: issue.id,
|
|
1235
|
+
agentId: resolveAgentId(api),
|
|
1236
|
+
startedAt: Date.now(),
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
// 9. Post dispatch confirmation comment
|
|
1240
|
+
const prepStatus = prep.errors.length > 0
|
|
1241
|
+
? `Workspace prep: partial (${prep.errors.join("; ")})`
|
|
1242
|
+
: `Workspace prep: OK (pulled=${prep.pulled}, submodules=${prep.submodulesInitialized})`;
|
|
1243
|
+
const statusComment = [
|
|
1244
|
+
`**Dispatched** as **${assessment.tier}** (${assessment.model})`,
|
|
1245
|
+
`> ${assessment.reasoning}`,
|
|
1246
|
+
``,
|
|
1247
|
+
`Worktree: \`${worktree.path}\` ${worktree.resumed ? "(resumed)" : "(fresh)"}`,
|
|
1248
|
+
`Branch: \`${worktree.branch}\``,
|
|
1249
|
+
prepStatus,
|
|
1250
|
+
].join("\n");
|
|
1251
|
+
|
|
1252
|
+
await linearApi.createComment(issue.id, statusComment);
|
|
1253
|
+
|
|
1254
|
+
if (agentSessionId) {
|
|
1255
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1256
|
+
type: "thought",
|
|
1257
|
+
body: `Dispatching ${identifier} as ${assessment.tier}...`,
|
|
1258
|
+
}).catch(() => {});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// 10. Apply tier label (best effort)
|
|
1262
|
+
try {
|
|
1263
|
+
if (enrichedIssue.team?.id) {
|
|
1264
|
+
const teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
|
|
1265
|
+
const tierLabel = teamLabels.find((l: any) => l.name === `developer:${assessment.tier}`);
|
|
1266
|
+
if (tierLabel) {
|
|
1267
|
+
const currentLabelIds = enrichedIssue.labels?.nodes?.map((l: any) => l.id) ?? [];
|
|
1268
|
+
await linearApi.updateIssue(issue.id, {
|
|
1269
|
+
labelIds: [...currentLabelIds, tierLabel.id],
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
api.logger.warn(`@dispatch: could not apply tier label: ${err}`);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// 11. Run pipeline (non-blocking)
|
|
1278
|
+
const agentId = resolveAgentId(api);
|
|
1279
|
+
const pipelineCtx: PipelineContext = {
|
|
1280
|
+
api,
|
|
1281
|
+
linearApi,
|
|
1282
|
+
agentSessionId: agentSessionId ?? `dispatch-${identifier}-${Date.now()}`,
|
|
1283
|
+
agentId,
|
|
1284
|
+
issue: {
|
|
1285
|
+
id: issue.id,
|
|
1286
|
+
identifier,
|
|
1287
|
+
title: enrichedIssue.title ?? "(untitled)",
|
|
1288
|
+
description: enrichedIssue.description,
|
|
1289
|
+
},
|
|
1290
|
+
worktreePath: worktree.path,
|
|
1291
|
+
codexBranch: worktree.branch,
|
|
1292
|
+
tier: assessment.tier,
|
|
1293
|
+
model: assessment.model,
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
activeRuns.add(issue.id);
|
|
1297
|
+
|
|
1298
|
+
// Update status to running
|
|
1299
|
+
await updateDispatchStatus(identifier, "running", statePath);
|
|
1300
|
+
|
|
1301
|
+
runFullPipeline(pipelineCtx)
|
|
1302
|
+
.then(async () => {
|
|
1303
|
+
await completeDispatch(identifier, {
|
|
1304
|
+
tier: assessment.tier,
|
|
1305
|
+
status: "done",
|
|
1306
|
+
completedAt: new Date().toISOString(),
|
|
1307
|
+
}, statePath);
|
|
1308
|
+
api.logger.info(`@dispatch: pipeline completed for ${identifier}`);
|
|
1309
|
+
})
|
|
1310
|
+
.catch(async (err) => {
|
|
1311
|
+
api.logger.error(`@dispatch: pipeline failed for ${identifier}: ${err}`);
|
|
1312
|
+
await updateDispatchStatus(identifier, "failed", statePath);
|
|
1313
|
+
})
|
|
1314
|
+
.finally(() => {
|
|
1315
|
+
activeRuns.delete(issue.id);
|
|
1316
|
+
clearActiveSession(issue.id);
|
|
1317
|
+
});
|
|
1318
|
+
}
|