@calltelemetry/openclaw-linear 0.4.0 → 0.5.0
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 +263 -249
- package/index.ts +108 -1
- package/openclaw.plugin.json +6 -1
- package/package.json +5 -1
- package/prompts.yaml +61 -0
- package/src/active-session.ts +40 -0
- package/src/cli.ts +103 -0
- package/src/code-tool.ts +2 -2
- package/src/codex-worktree.ts +162 -36
- package/src/dispatch-service.ts +161 -0
- package/src/dispatch-state.ts +497 -0
- package/src/notify.ts +91 -0
- package/src/pipeline.ts +582 -198
- package/src/tier-assess.ts +157 -0
- package/src/webhook.ts +232 -197
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tier-assess.ts — LLM-based complexity assessment for Linear issues.
|
|
3
|
+
*
|
|
4
|
+
* Uses runAgent() with the agent's configured model (e.g. kimi-k2.5)
|
|
5
|
+
* to assess issue complexity. The agent model handles orchestration —
|
|
6
|
+
* it never calls coding CLIs directly.
|
|
7
|
+
*
|
|
8
|
+
* Cost: one short agent turn (~500 tokens). Latency: ~2-5s.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
13
|
+
import type { Tier } from "./dispatch-state.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Tier → Model mapping
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const TIER_MODELS: Record<Tier, string> = {
|
|
20
|
+
junior: "anthropic/claude-haiku-4-5",
|
|
21
|
+
medior: "anthropic/claude-sonnet-4-6",
|
|
22
|
+
senior: "anthropic/claude-opus-4-6",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface TierAssessment {
|
|
26
|
+
tier: Tier;
|
|
27
|
+
model: string;
|
|
28
|
+
reasoning: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IssueContext {
|
|
32
|
+
identifier: string;
|
|
33
|
+
title: string;
|
|
34
|
+
description?: string | null;
|
|
35
|
+
labels?: string[];
|
|
36
|
+
commentCount?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Assessment
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const ASSESS_PROMPT = `You are a complexity assessor. Assess this issue and respond ONLY with JSON.
|
|
44
|
+
|
|
45
|
+
Tiers:
|
|
46
|
+
- junior: typos, copy changes, config tweaks, simple CSS, env var additions
|
|
47
|
+
- medior: features, bugfixes, moderate refactoring, adding tests, API changes
|
|
48
|
+
- senior: architecture changes, database migrations, security fixes, multi-service coordination
|
|
49
|
+
|
|
50
|
+
Consider:
|
|
51
|
+
1. How many files/services are likely affected?
|
|
52
|
+
2. Does it touch auth, data, or external APIs? (higher risk → higher tier)
|
|
53
|
+
3. Is the description clear and actionable?
|
|
54
|
+
4. Are there dependencies or unknowns?
|
|
55
|
+
|
|
56
|
+
Respond ONLY with: {"tier":"junior|medior|senior","reasoning":"one sentence"}`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Assess issue complexity using the agent's configured model.
|
|
60
|
+
*
|
|
61
|
+
* Falls back to "medior" if the agent call fails or returns invalid JSON.
|
|
62
|
+
*/
|
|
63
|
+
export async function assessTier(
|
|
64
|
+
api: OpenClawPluginApi,
|
|
65
|
+
issue: IssueContext,
|
|
66
|
+
agentId?: string,
|
|
67
|
+
): Promise<TierAssessment> {
|
|
68
|
+
const issueText = [
|
|
69
|
+
`Issue: ${issue.identifier} — ${issue.title}`,
|
|
70
|
+
issue.description ? `Description: ${issue.description.slice(0, 1500)}` : "",
|
|
71
|
+
issue.labels?.length ? `Labels: ${issue.labels.join(", ")}` : "",
|
|
72
|
+
issue.commentCount != null ? `Comments: ${issue.commentCount}` : "",
|
|
73
|
+
].filter(Boolean).join("\n");
|
|
74
|
+
|
|
75
|
+
const message = `${ASSESS_PROMPT}\n\n${issueText}`;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const { runAgent } = await import("./agent.js");
|
|
79
|
+
const result = await runAgent({
|
|
80
|
+
api,
|
|
81
|
+
agentId: agentId ?? resolveDefaultAgent(api),
|
|
82
|
+
sessionId: `tier-assess-${issue.identifier}-${Date.now()}`,
|
|
83
|
+
message,
|
|
84
|
+
timeoutMs: 30_000, // 30s — this should be fast
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Try to parse assessment from output regardless of success flag.
|
|
88
|
+
// runAgent may report success:false (non-zero exit code) even when
|
|
89
|
+
// the agent produced valid JSON output — e.g. agent exited with
|
|
90
|
+
// signal but wrote the response before terminating.
|
|
91
|
+
if (result.output) {
|
|
92
|
+
const parsed = parseAssessment(result.output);
|
|
93
|
+
if (parsed) {
|
|
94
|
+
api.logger.info(`Tier assessment for ${issue.identifier}: ${parsed.tier} — ${parsed.reasoning} (agent success=${result.success})`);
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!result.success) {
|
|
100
|
+
api.logger.warn(`Tier assessment agent failed for ${issue.identifier}: ${result.output.slice(0, 200)}`);
|
|
101
|
+
} else {
|
|
102
|
+
api.logger.warn(`Tier assessment for ${issue.identifier}: could not parse response: ${result.output.slice(0, 200)}`);
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
api.logger.warn(`Tier assessment error for ${issue.identifier}: ${err}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fallback: medior is the safest default
|
|
109
|
+
const fallback: TierAssessment = {
|
|
110
|
+
tier: "medior",
|
|
111
|
+
model: TIER_MODELS.medior,
|
|
112
|
+
reasoning: "Assessment failed — defaulting to medior",
|
|
113
|
+
};
|
|
114
|
+
api.logger.info(`Tier assessment fallback for ${issue.identifier}: medior`);
|
|
115
|
+
return fallback;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Helpers
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
function resolveDefaultAgent(api: OpenClawPluginApi): string {
|
|
123
|
+
// Use the plugin's configured default agent (same one that runs the pipeline)
|
|
124
|
+
const fromConfig = (api as any).pluginConfig?.defaultAgentId;
|
|
125
|
+
if (typeof fromConfig === "string" && fromConfig) return fromConfig;
|
|
126
|
+
|
|
127
|
+
// Fall back to isDefault in agent profiles
|
|
128
|
+
try {
|
|
129
|
+
const profilesPath = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
|
|
130
|
+
const raw = readFileSync(profilesPath, "utf8");
|
|
131
|
+
const profiles = JSON.parse(raw).agents ?? {};
|
|
132
|
+
const defaultAgent = Object.entries(profiles).find(([, p]: [string, any]) => p.isDefault);
|
|
133
|
+
if (defaultAgent) return defaultAgent[0];
|
|
134
|
+
} catch { /* fall through */ }
|
|
135
|
+
|
|
136
|
+
return "default";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseAssessment(raw: string): TierAssessment | null {
|
|
140
|
+
// Extract JSON from the response (may have markdown wrapping)
|
|
141
|
+
const jsonMatch = raw.match(/\{[^}]+\}/);
|
|
142
|
+
if (!jsonMatch) return null;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
146
|
+
const tier = parsed.tier as string;
|
|
147
|
+
if (tier !== "junior" && tier !== "medior" && tier !== "senior") return null;
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
tier: tier as Tier,
|
|
151
|
+
model: TIER_MODELS[tier as Tier],
|
|
152
|
+
reasoning: parsed.reasoning ?? "no reasoning provided",
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/webhook.ts
CHANGED
|
@@ -3,9 +3,12 @@ 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
|
-
|
|
7
|
-
// import { runFullPipeline, resumePipeline, type PipelineContext } from "./pipeline.js";
|
|
6
|
+
import { spawnWorker, type HookContext } from "./pipeline.js";
|
|
8
7
|
import { setActiveSession, clearActiveSession } from "./active-session.js";
|
|
8
|
+
import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
|
|
9
|
+
import { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "./notify.js";
|
|
10
|
+
import { assessTier } from "./tier-assess.js";
|
|
11
|
+
import { createWorktree, prepareWorkspace } from "./codex-worktree.js";
|
|
9
12
|
|
|
10
13
|
// ── Agent profiles (loaded from config, no hardcoded names) ───────
|
|
11
14
|
interface AgentProfile {
|
|
@@ -858,7 +861,7 @@ export async function handleLinearWebhook(
|
|
|
858
861
|
}
|
|
859
862
|
|
|
860
863
|
const trigger = isDelegatedToUs ? "delegated" : "assigned";
|
|
861
|
-
api.logger.info(`Issue ${trigger} to our app user (${viewerId}),
|
|
864
|
+
api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
|
|
862
865
|
|
|
863
866
|
// Dedup on assignment/delegation
|
|
864
867
|
const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
|
|
@@ -867,200 +870,11 @@ export async function handleLinearWebhook(
|
|
|
867
870
|
return true;
|
|
868
871
|
}
|
|
869
872
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
try {
|
|
876
|
-
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
877
|
-
if (enrichedIssue?.team?.id) {
|
|
878
|
-
teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
|
|
879
|
-
}
|
|
880
|
-
} catch (err) {
|
|
881
|
-
api.logger.warn(`Could not fetch issue details: ${err}`);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
885
|
-
const comments = enrichedIssue?.comments?.nodes ?? [];
|
|
886
|
-
const commentSummary = comments
|
|
887
|
-
.slice(-5)
|
|
888
|
-
.map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
|
|
889
|
-
.join("\n");
|
|
890
|
-
|
|
891
|
-
const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
|
|
892
|
-
const currentLabels = enrichedIssue?.labels?.nodes ?? [];
|
|
893
|
-
const currentLabelNames = currentLabels.map((l: any) => l.name).join(", ") || "None";
|
|
894
|
-
const availableLabelList = teamLabels.map((l) => ` - "${l.name}" (id: ${l.id})`).join("\n");
|
|
895
|
-
|
|
896
|
-
const message = [
|
|
897
|
-
`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.`,
|
|
898
|
-
``,
|
|
899
|
-
`## Issue: ${enrichedIssue?.identifier ?? issue.identifier ?? issue.id} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
|
|
900
|
-
`**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Current Estimate:** ${enrichedIssue?.estimate ?? "None"} | **Current Labels:** ${currentLabelNames}`,
|
|
901
|
-
``,
|
|
902
|
-
`**Description:**`,
|
|
903
|
-
description,
|
|
904
|
-
commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
|
|
905
|
-
``,
|
|
906
|
-
`## Your Triage Tasks`,
|
|
907
|
-
``,
|
|
908
|
-
`1. **Story Points** — Estimate complexity using ${estimationType} scale (1=trivial, 2=small, 3=medium, 5=large, 8=very large, 13=epic)`,
|
|
909
|
-
`2. **Labels** — Select appropriate labels from the team's available labels`,
|
|
910
|
-
`3. **Assessment** — Brief analysis of what this issue needs`,
|
|
911
|
-
``,
|
|
912
|
-
`## Available Labels`,
|
|
913
|
-
availableLabelList || " (no labels configured)",
|
|
914
|
-
``,
|
|
915
|
-
`## Response Format`,
|
|
916
|
-
``,
|
|
917
|
-
`You MUST start your response with a JSON block, then follow with your assessment:`,
|
|
918
|
-
``,
|
|
919
|
-
'```json',
|
|
920
|
-
`{`,
|
|
921
|
-
` "estimate": <number>,`,
|
|
922
|
-
` "labelIds": ["<id1>", "<id2>"],`,
|
|
923
|
-
` "assessment": "<one-line summary of your sizing rationale>"`,
|
|
924
|
-
`}`,
|
|
925
|
-
'```',
|
|
926
|
-
``,
|
|
927
|
-
`Then write your full assessment as markdown below the JSON block.`,
|
|
928
|
-
].filter(Boolean).join("\n");
|
|
929
|
-
|
|
930
|
-
// Dispatch agent with session lifecycle (non-blocking)
|
|
931
|
-
void (async () => {
|
|
932
|
-
const profiles = loadAgentProfiles();
|
|
933
|
-
const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
934
|
-
const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
|
|
935
|
-
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
936
|
-
let agentSessionId: string | null = null;
|
|
937
|
-
|
|
938
|
-
try {
|
|
939
|
-
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
940
|
-
agentSessionId = sessionResult.sessionId;
|
|
941
|
-
if (agentSessionId) {
|
|
942
|
-
wasRecentlyProcessed(`session:${agentSessionId}`);
|
|
943
|
-
api.logger.info(`Created agent session ${agentSessionId} for ${trigger}`);
|
|
944
|
-
setActiveSession({
|
|
945
|
-
agentSessionId,
|
|
946
|
-
issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
|
|
947
|
-
issueId: issue.id,
|
|
948
|
-
agentId,
|
|
949
|
-
startedAt: Date.now(),
|
|
950
|
-
});
|
|
951
|
-
} else {
|
|
952
|
-
api.logger.warn(`Could not create agent session for assignment: ${sessionResult.error ?? "unknown"}`);
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
if (agentSessionId) {
|
|
956
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
957
|
-
type: "thought",
|
|
958
|
-
body: `Reviewing assigned issue ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
959
|
-
}).catch(() => {});
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
if (agentSessionId) {
|
|
963
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
964
|
-
type: "action",
|
|
965
|
-
action: "Triaging",
|
|
966
|
-
parameter: `${enrichedIssue?.identifier ?? issue.id} — estimating, labeling, sizing`,
|
|
967
|
-
}).catch(() => {});
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
const sessionId = `linear-assign-${issue.id}-${Date.now()}`;
|
|
971
|
-
const { runAgent } = await import("./agent.js");
|
|
972
|
-
const result = await runAgent({
|
|
973
|
-
api,
|
|
974
|
-
agentId,
|
|
975
|
-
sessionId,
|
|
976
|
-
message,
|
|
977
|
-
timeoutMs: 3 * 60_000,
|
|
978
|
-
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
const responseBody = result.success
|
|
982
|
-
? result.output
|
|
983
|
-
: `I encountered an error reviewing this assignment. Please try again.`;
|
|
984
|
-
|
|
985
|
-
// Parse triage JSON from agent response and apply to issue
|
|
986
|
-
let commentBody = responseBody;
|
|
987
|
-
if (result.success) {
|
|
988
|
-
const jsonMatch = responseBody.match(/```json\s*\n?([\s\S]*?)\n?```/);
|
|
989
|
-
if (jsonMatch) {
|
|
990
|
-
try {
|
|
991
|
-
const triage = JSON.parse(jsonMatch[1]);
|
|
992
|
-
const updateInput: Record<string, unknown> = {};
|
|
993
|
-
|
|
994
|
-
if (typeof triage.estimate === "number") {
|
|
995
|
-
updateInput.estimate = triage.estimate;
|
|
996
|
-
}
|
|
997
|
-
if (Array.isArray(triage.labelIds) && triage.labelIds.length > 0) {
|
|
998
|
-
// Merge with existing labels
|
|
999
|
-
const existingIds = currentLabels.map((l: any) => l.id);
|
|
1000
|
-
const allIds = [...new Set([...existingIds, ...triage.labelIds])];
|
|
1001
|
-
updateInput.labelIds = allIds;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
if (Object.keys(updateInput).length > 0) {
|
|
1005
|
-
await linearApi.updateIssue(issue.id, updateInput);
|
|
1006
|
-
api.logger.info(`Applied triage to ${enrichedIssue?.identifier ?? issue.id}: ${JSON.stringify(updateInput)}`);
|
|
1007
|
-
|
|
1008
|
-
if (agentSessionId) {
|
|
1009
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
1010
|
-
type: "action",
|
|
1011
|
-
action: "Applied triage",
|
|
1012
|
-
result: `estimate=${triage.estimate ?? "unchanged"}, labels=${triage.labelIds?.length ?? 0} added`,
|
|
1013
|
-
}).catch(() => {});
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
// Strip the JSON block from the comment — post only the assessment
|
|
1018
|
-
commentBody = responseBody.replace(/```json\s*\n?[\s\S]*?\n?```\s*\n?/, "").trim();
|
|
1019
|
-
} catch (parseErr) {
|
|
1020
|
-
api.logger.warn(`Could not parse triage JSON: ${parseErr}`);
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// Post comment with assessment
|
|
1026
|
-
const brandingOpts = avatarUrl
|
|
1027
|
-
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1028
|
-
: undefined;
|
|
1029
|
-
|
|
1030
|
-
try {
|
|
1031
|
-
if (brandingOpts) {
|
|
1032
|
-
await linearApi.createComment(issue.id, commentBody, brandingOpts);
|
|
1033
|
-
} else {
|
|
1034
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
1035
|
-
}
|
|
1036
|
-
} catch (brandErr) {
|
|
1037
|
-
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
1038
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
if (agentSessionId) {
|
|
1042
|
-
const truncated = commentBody.length > 2000
|
|
1043
|
-
? commentBody.slice(0, 2000) + "…"
|
|
1044
|
-
: commentBody;
|
|
1045
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
1046
|
-
type: "response",
|
|
1047
|
-
body: truncated,
|
|
1048
|
-
}).catch(() => {});
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
api.logger.info(`Posted assignment response to ${enrichedIssue?.identifier ?? issue.id}`);
|
|
1052
|
-
} catch (err) {
|
|
1053
|
-
api.logger.error(`Issue assignment handler error: ${err}`);
|
|
1054
|
-
if (agentSessionId) {
|
|
1055
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
1056
|
-
type: "error",
|
|
1057
|
-
body: `Failed to process assignment: ${String(err).slice(0, 500)}`,
|
|
1058
|
-
}).catch(() => {});
|
|
1059
|
-
}
|
|
1060
|
-
} finally {
|
|
1061
|
-
clearActiveSession(issue.id);
|
|
1062
|
-
}
|
|
1063
|
-
})();
|
|
873
|
+
// Assignment triggers the full dispatch pipeline:
|
|
874
|
+
// tier assessment → worktree → plan → implement → audit
|
|
875
|
+
void handleDispatch(api, linearApi, issue).catch((err) => {
|
|
876
|
+
api.logger.error(`Dispatch pipeline error for ${issue.identifier ?? issue.id}: ${err}`);
|
|
877
|
+
});
|
|
1064
878
|
|
|
1065
879
|
return true;
|
|
1066
880
|
}
|
|
@@ -1290,3 +1104,224 @@ export async function handleLinearWebhook(
|
|
|
1290
1104
|
res.end("ok");
|
|
1291
1105
|
return true;
|
|
1292
1106
|
}
|
|
1107
|
+
|
|
1108
|
+
// ── @dispatch handler ─────────────────────────────────────────────
|
|
1109
|
+
//
|
|
1110
|
+
// Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
|
|
1111
|
+
// creates a persistent worktree, registers the dispatch in state, and
|
|
1112
|
+
// launches the pipeline (plan → implement → audit).
|
|
1113
|
+
|
|
1114
|
+
async function handleDispatch(
|
|
1115
|
+
api: OpenClawPluginApi,
|
|
1116
|
+
linearApi: LinearAgentApi,
|
|
1117
|
+
issue: any,
|
|
1118
|
+
): Promise<void> {
|
|
1119
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
1120
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
1121
|
+
const worktreeBaseDir = pluginConfig?.worktreeBaseDir as string | undefined;
|
|
1122
|
+
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
|
|
1123
|
+
const identifier = issue.identifier ?? issue.id;
|
|
1124
|
+
|
|
1125
|
+
api.logger.info(`@dispatch: processing ${identifier}`);
|
|
1126
|
+
|
|
1127
|
+
// 1. Check for existing active dispatch — reclaim if stale
|
|
1128
|
+
const STALE_DISPATCH_MS = 30 * 60_000; // 30 min without a gateway holding it = stale
|
|
1129
|
+
const state = await readDispatchState(statePath);
|
|
1130
|
+
const existing = getActiveDispatch(state, identifier);
|
|
1131
|
+
if (existing) {
|
|
1132
|
+
const ageMs = Date.now() - new Date(existing.dispatchedAt).getTime();
|
|
1133
|
+
const isStale = ageMs > STALE_DISPATCH_MS;
|
|
1134
|
+
const inMemory = activeRuns.has(issue.id);
|
|
1135
|
+
|
|
1136
|
+
if (!isStale && inMemory) {
|
|
1137
|
+
// Truly still running in this gateway process
|
|
1138
|
+
api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
|
|
1139
|
+
await linearApi.createComment(
|
|
1140
|
+
issue.id,
|
|
1141
|
+
`Already running as **${existing.tier}** (status: ${existing.status}, started ${Math.round(ageMs / 60_000)}m ago). Worktree: \`${existing.worktreePath}\``,
|
|
1142
|
+
);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Stale or not in memory (gateway restarted) — reclaim
|
|
1147
|
+
api.logger.info(
|
|
1148
|
+
`dispatch: ${identifier} reclaiming stale dispatch (status: ${existing.status}, ` +
|
|
1149
|
+
`age: ${Math.round(ageMs / 1000)}s, inMemory: ${inMemory}, stale: ${isStale})`,
|
|
1150
|
+
);
|
|
1151
|
+
await removeActiveDispatch(identifier, statePath);
|
|
1152
|
+
activeRuns.delete(issue.id);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// 2. Prevent concurrent runs on same issue
|
|
1156
|
+
if (activeRuns.has(issue.id)) {
|
|
1157
|
+
api.logger.info(`@dispatch: ${identifier} has active agent run — skipping`);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// 3. Fetch full issue details for tier assessment
|
|
1162
|
+
let enrichedIssue: any;
|
|
1163
|
+
try {
|
|
1164
|
+
enrichedIssue = await linearApi.getIssueDetails(issue.id);
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
api.logger.error(`@dispatch: failed to fetch issue details: ${err}`);
|
|
1167
|
+
enrichedIssue = issue;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
|
|
1171
|
+
const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
|
|
1172
|
+
|
|
1173
|
+
// 4. Assess complexity tier
|
|
1174
|
+
const assessment = await assessTier(api, {
|
|
1175
|
+
identifier,
|
|
1176
|
+
title: enrichedIssue.title ?? "(untitled)",
|
|
1177
|
+
description: enrichedIssue.description,
|
|
1178
|
+
labels,
|
|
1179
|
+
commentCount,
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
|
|
1183
|
+
|
|
1184
|
+
// 5. Create persistent worktree
|
|
1185
|
+
let worktree;
|
|
1186
|
+
try {
|
|
1187
|
+
worktree = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
|
|
1188
|
+
api.logger.info(`@dispatch: worktree ${worktree.resumed ? "resumed" : "created"} at ${worktree.path}`);
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
api.logger.error(`@dispatch: worktree creation failed: ${err}`);
|
|
1191
|
+
await linearApi.createComment(
|
|
1192
|
+
issue.id,
|
|
1193
|
+
`Dispatch failed — could not create worktree: ${String(err).slice(0, 200)}`,
|
|
1194
|
+
);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// 5b. Prepare workspace: pull latest from origin + init submodules
|
|
1199
|
+
const prep = prepareWorkspace(worktree.path, worktree.branch);
|
|
1200
|
+
if (prep.errors.length > 0) {
|
|
1201
|
+
api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
|
|
1202
|
+
} else {
|
|
1203
|
+
api.logger.info(
|
|
1204
|
+
`@dispatch: workspace prepared — pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`,
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// 6. Create agent session on Linear
|
|
1209
|
+
let agentSessionId: string | undefined;
|
|
1210
|
+
try {
|
|
1211
|
+
const sessionResult = await linearApi.createSessionOnIssue(issue.id);
|
|
1212
|
+
agentSessionId = sessionResult.sessionId ?? undefined;
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
api.logger.warn(`@dispatch: could not create agent session: ${err}`);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// 7. Register dispatch in persistent state
|
|
1218
|
+
const now = new Date().toISOString();
|
|
1219
|
+
await registerDispatch(identifier, {
|
|
1220
|
+
issueId: issue.id,
|
|
1221
|
+
issueIdentifier: identifier,
|
|
1222
|
+
worktreePath: worktree.path,
|
|
1223
|
+
branch: worktree.branch,
|
|
1224
|
+
tier: assessment.tier,
|
|
1225
|
+
model: assessment.model,
|
|
1226
|
+
status: "dispatched",
|
|
1227
|
+
dispatchedAt: now,
|
|
1228
|
+
agentSessionId,
|
|
1229
|
+
attempt: 0,
|
|
1230
|
+
}, statePath);
|
|
1231
|
+
|
|
1232
|
+
// 8. Register active session for tool resolution
|
|
1233
|
+
setActiveSession({
|
|
1234
|
+
agentSessionId: agentSessionId ?? "",
|
|
1235
|
+
issueIdentifier: identifier,
|
|
1236
|
+
issueId: issue.id,
|
|
1237
|
+
agentId: resolveAgentId(api),
|
|
1238
|
+
startedAt: Date.now(),
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
// 9. Post dispatch confirmation comment
|
|
1242
|
+
const prepStatus = prep.errors.length > 0
|
|
1243
|
+
? `Workspace prep: partial (${prep.errors.join("; ")})`
|
|
1244
|
+
: `Workspace prep: OK (pulled=${prep.pulled}, submodules=${prep.submodulesInitialized})`;
|
|
1245
|
+
const statusComment = [
|
|
1246
|
+
`**Dispatched** as **${assessment.tier}** (${assessment.model})`,
|
|
1247
|
+
`> ${assessment.reasoning}`,
|
|
1248
|
+
``,
|
|
1249
|
+
`Worktree: \`${worktree.path}\` ${worktree.resumed ? "(resumed)" : "(fresh)"}`,
|
|
1250
|
+
`Branch: \`${worktree.branch}\``,
|
|
1251
|
+
prepStatus,
|
|
1252
|
+
].join("\n");
|
|
1253
|
+
|
|
1254
|
+
await linearApi.createComment(issue.id, statusComment);
|
|
1255
|
+
|
|
1256
|
+
if (agentSessionId) {
|
|
1257
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
1258
|
+
type: "thought",
|
|
1259
|
+
body: `Dispatching ${identifier} as ${assessment.tier}...`,
|
|
1260
|
+
}).catch(() => {});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// 10. Apply tier label (best effort)
|
|
1264
|
+
try {
|
|
1265
|
+
if (enrichedIssue.team?.id) {
|
|
1266
|
+
const teamLabels = await linearApi.getTeamLabels(enrichedIssue.team.id);
|
|
1267
|
+
const tierLabel = teamLabels.find((l: any) => l.name === `developer:${assessment.tier}`);
|
|
1268
|
+
if (tierLabel) {
|
|
1269
|
+
const currentLabelIds = enrichedIssue.labels?.nodes?.map((l: any) => l.id) ?? [];
|
|
1270
|
+
await linearApi.updateIssue(issue.id, {
|
|
1271
|
+
labelIds: [...currentLabelIds, tierLabel.id],
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
api.logger.warn(`@dispatch: could not apply tier label: ${err}`);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// 11. Run v2 pipeline: worker → audit → verdict (non-blocking)
|
|
1280
|
+
activeRuns.add(issue.id);
|
|
1281
|
+
|
|
1282
|
+
// Instantiate notifier
|
|
1283
|
+
const discordBotToken = (() => {
|
|
1284
|
+
try {
|
|
1285
|
+
const config = JSON.parse(
|
|
1286
|
+
require("node:fs").readFileSync(
|
|
1287
|
+
require("node:path").join(process.env.HOME ?? "/home/claw", ".openclaw", "openclaw.json"),
|
|
1288
|
+
"utf8",
|
|
1289
|
+
),
|
|
1290
|
+
);
|
|
1291
|
+
return config?.channels?.discord?.token as string | undefined;
|
|
1292
|
+
} catch { return undefined; }
|
|
1293
|
+
})();
|
|
1294
|
+
const flowDiscordChannel = pluginConfig?.flowDiscordChannel as string | undefined;
|
|
1295
|
+
const notify: NotifyFn = (discordBotToken && flowDiscordChannel)
|
|
1296
|
+
? createDiscordNotifier(discordBotToken, flowDiscordChannel)
|
|
1297
|
+
: createNoopNotifier();
|
|
1298
|
+
|
|
1299
|
+
const hookCtx: HookContext = {
|
|
1300
|
+
api,
|
|
1301
|
+
linearApi,
|
|
1302
|
+
notify,
|
|
1303
|
+
pluginConfig,
|
|
1304
|
+
configPath: statePath,
|
|
1305
|
+
};
|
|
1306
|
+
|
|
1307
|
+
// Re-read dispatch to get fresh state after registration
|
|
1308
|
+
const freshState = await readDispatchState(statePath);
|
|
1309
|
+
const dispatch = getActiveDispatch(freshState, identifier)!;
|
|
1310
|
+
|
|
1311
|
+
await notify("dispatch", {
|
|
1312
|
+
identifier,
|
|
1313
|
+
title: enrichedIssue.title ?? "(untitled)",
|
|
1314
|
+
status: "dispatched",
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
// spawnWorker handles: dispatched→working→auditing→done/rework/stuck
|
|
1318
|
+
spawnWorker(hookCtx, dispatch)
|
|
1319
|
+
.catch(async (err) => {
|
|
1320
|
+
api.logger.error(`@dispatch: pipeline v2 failed for ${identifier}: ${err}`);
|
|
1321
|
+
await updateDispatchStatus(identifier, "failed", statePath);
|
|
1322
|
+
})
|
|
1323
|
+
.finally(() => {
|
|
1324
|
+
activeRuns.delete(issue.id);
|
|
1325
|
+
clearActiveSession(issue.id);
|
|
1326
|
+
});
|
|
1327
|
+
}
|