@calltelemetry/openclaw-linear 0.7.0 → 0.8.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/LICENSE +21 -0
- package/README.md +719 -539
- package/index.ts +40 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +2 -1
- package/prompts.yaml +19 -5
- package/src/__test__/fixtures/linear-responses.ts +75 -0
- package/src/__test__/fixtures/webhook-payloads.ts +113 -0
- package/src/__test__/helpers.ts +133 -0
- package/src/agent/agent.test.ts +143 -0
- package/src/api/linear-api.test.ts +586 -0
- package/src/api/linear-api.ts +50 -11
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +273 -30
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +114 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +137 -26
- package/src/infra/observability.test.ts +85 -0
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +3 -42
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +455 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +132 -29
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +18 -31
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +264 -0
- package/src/pipeline/webhook.ts +134 -36
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { TIER_MODELS, assessTier, type IssueContext } from "./tier-assess.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mock runAgent
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const mockRunAgent = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("../agent/agent.js", () => ({
|
|
11
|
+
runAgent: (...args: unknown[]) => mockRunAgent(...args),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
function makeApi(overrides?: { defaultAgentId?: string }) {
|
|
19
|
+
return {
|
|
20
|
+
logger: {
|
|
21
|
+
info: vi.fn(),
|
|
22
|
+
warn: vi.fn(),
|
|
23
|
+
error: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
pluginConfig: {
|
|
26
|
+
defaultAgentId: overrides?.defaultAgentId,
|
|
27
|
+
},
|
|
28
|
+
} as any;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeIssue(overrides?: Partial<IssueContext>): IssueContext {
|
|
32
|
+
return {
|
|
33
|
+
identifier: "CT-123",
|
|
34
|
+
title: "Fix login bug",
|
|
35
|
+
description: "Users cannot log in when using SSO",
|
|
36
|
+
labels: ["bug"],
|
|
37
|
+
commentCount: 2,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Tests
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe("TIER_MODELS", () => {
|
|
47
|
+
it("maps junior to haiku, medior to sonnet, senior to opus", () => {
|
|
48
|
+
expect(TIER_MODELS.junior).toBe("anthropic/claude-haiku-4-5");
|
|
49
|
+
expect(TIER_MODELS.medior).toBe("anthropic/claude-sonnet-4-6");
|
|
50
|
+
expect(TIER_MODELS.senior).toBe("anthropic/claude-opus-4-6");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("assessTier", () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
mockRunAgent.mockReset();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns parsed tier from agent response", async () => {
|
|
60
|
+
mockRunAgent.mockResolvedValue({
|
|
61
|
+
success: true,
|
|
62
|
+
output: '{"tier":"senior","reasoning":"Multi-service architecture change"}',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
66
|
+
const result = await assessTier(api, makeIssue());
|
|
67
|
+
|
|
68
|
+
expect(result.tier).toBe("senior");
|
|
69
|
+
expect(result.model).toBe(TIER_MODELS.senior);
|
|
70
|
+
expect(result.reasoning).toBe("Multi-service architecture change");
|
|
71
|
+
expect(api.logger.info).toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("falls back to medior when agent fails (success: false) with no parseable JSON", async () => {
|
|
75
|
+
mockRunAgent.mockResolvedValue({
|
|
76
|
+
success: false,
|
|
77
|
+
output: "Agent process exited with code 1",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
81
|
+
const result = await assessTier(api, makeIssue());
|
|
82
|
+
|
|
83
|
+
expect(result.tier).toBe("medior");
|
|
84
|
+
expect(result.model).toBe(TIER_MODELS.medior);
|
|
85
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
|
|
86
|
+
expect(api.logger.warn).toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back to medior when output has no JSON", async () => {
|
|
90
|
+
mockRunAgent.mockResolvedValue({
|
|
91
|
+
success: true,
|
|
92
|
+
output: "I think this is a medium complexity issue because it involves multiple files.",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
96
|
+
const result = await assessTier(api, makeIssue());
|
|
97
|
+
|
|
98
|
+
expect(result.tier).toBe("medior");
|
|
99
|
+
expect(result.model).toBe(TIER_MODELS.medior);
|
|
100
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("falls back to medior when JSON has invalid tier", async () => {
|
|
104
|
+
mockRunAgent.mockResolvedValue({
|
|
105
|
+
success: true,
|
|
106
|
+
output: '{"tier":"expert","reasoning":"Very hard problem"}',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
110
|
+
const result = await assessTier(api, makeIssue());
|
|
111
|
+
|
|
112
|
+
expect(result.tier).toBe("medior");
|
|
113
|
+
expect(result.model).toBe(TIER_MODELS.medior);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("handles agent throwing an error", async () => {
|
|
117
|
+
mockRunAgent.mockRejectedValue(new Error("Connection refused"));
|
|
118
|
+
|
|
119
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
120
|
+
const result = await assessTier(api, makeIssue());
|
|
121
|
+
|
|
122
|
+
expect(result.tier).toBe("medior");
|
|
123
|
+
expect(result.model).toBe(TIER_MODELS.medior);
|
|
124
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
|
|
125
|
+
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
126
|
+
expect.stringContaining("Tier assessment error for CT-123"),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("truncates long descriptions to 1500 chars", async () => {
|
|
131
|
+
const longDescription = "A".repeat(3000);
|
|
132
|
+
mockRunAgent.mockResolvedValue({
|
|
133
|
+
success: true,
|
|
134
|
+
output: '{"tier":"junior","reasoning":"Simple copy change"}',
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
138
|
+
await assessTier(api, makeIssue({ description: longDescription }));
|
|
139
|
+
|
|
140
|
+
// Verify the message sent to runAgent has the description truncated
|
|
141
|
+
const callArgs = mockRunAgent.mock.calls[0][0];
|
|
142
|
+
const message: string = callArgs.message;
|
|
143
|
+
// The description in the prompt should be at most 1500 chars of the original
|
|
144
|
+
// "Description: " prefix + 1500 chars = the truncated form
|
|
145
|
+
expect(message).toContain("Description: " + "A".repeat(1500));
|
|
146
|
+
expect(message).not.toContain("A".repeat(1501));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("uses configured agentId when provided", async () => {
|
|
150
|
+
mockRunAgent.mockResolvedValue({
|
|
151
|
+
success: true,
|
|
152
|
+
output: '{"tier":"junior","reasoning":"Typo fix"}',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
156
|
+
await assessTier(api, makeIssue(), "kaylee");
|
|
157
|
+
|
|
158
|
+
const callArgs = mockRunAgent.mock.calls[0][0];
|
|
159
|
+
expect(callArgs.agentId).toBe("kaylee");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("parses JSON even when wrapped in markdown fences", async () => {
|
|
163
|
+
mockRunAgent.mockResolvedValue({
|
|
164
|
+
success: true,
|
|
165
|
+
output: '```json\n{"tier":"junior","reasoning":"Config tweak"}\n```',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
169
|
+
const result = await assessTier(api, makeIssue());
|
|
170
|
+
|
|
171
|
+
expect(result.tier).toBe("junior");
|
|
172
|
+
expect(result.model).toBe(TIER_MODELS.junior);
|
|
173
|
+
expect(result.reasoning).toBe("Config tweak");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("handles null description gracefully", async () => {
|
|
177
|
+
mockRunAgent.mockResolvedValue({
|
|
178
|
+
success: true,
|
|
179
|
+
output: '{"tier":"junior","reasoning":"Trivial"}',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
183
|
+
const result = await assessTier(api, makeIssue({ description: null }));
|
|
184
|
+
|
|
185
|
+
expect(result.tier).toBe("junior");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("handles empty labels and no comments", async () => {
|
|
189
|
+
mockRunAgent.mockResolvedValue({
|
|
190
|
+
success: true,
|
|
191
|
+
output: '{"tier":"medior","reasoning":"Standard feature"}',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
195
|
+
const result = await assessTier(api, makeIssue({ labels: [], commentCount: undefined }));
|
|
196
|
+
|
|
197
|
+
expect(result.tier).toBe("medior");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("falls back to medior on malformed JSON (half JSON)", async () => {
|
|
201
|
+
mockRunAgent.mockResolvedValue({
|
|
202
|
+
success: true,
|
|
203
|
+
output: '{"tier":"seni',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
207
|
+
const result = await assessTier(api, makeIssue());
|
|
208
|
+
|
|
209
|
+
expect(result.tier).toBe("medior");
|
|
210
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("provides default reasoning when missing from response", async () => {
|
|
214
|
+
mockRunAgent.mockResolvedValue({
|
|
215
|
+
success: true,
|
|
216
|
+
output: '{"tier":"senior"}',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
220
|
+
const result = await assessTier(api, makeIssue());
|
|
221
|
+
|
|
222
|
+
expect(result.tier).toBe("senior");
|
|
223
|
+
expect(result.reasoning).toBe("no reasoning provided");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("extracts JSON from output with success=false but valid JSON", async () => {
|
|
227
|
+
mockRunAgent.mockResolvedValue({
|
|
228
|
+
success: false,
|
|
229
|
+
output: 'Agent exited early but: {"tier":"junior","reasoning":"Simple fix"}',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
233
|
+
const result = await assessTier(api, makeIssue());
|
|
234
|
+
|
|
235
|
+
expect(result.tier).toBe("junior");
|
|
236
|
+
expect(result.reasoning).toBe("Simple fix");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("defaults agentId from pluginConfig when not passed", async () => {
|
|
240
|
+
mockRunAgent.mockResolvedValue({
|
|
241
|
+
success: true,
|
|
242
|
+
output: '{"tier":"medior","reasoning":"Normal"}',
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const api = makeApi({ defaultAgentId: "zoe" });
|
|
246
|
+
await assessTier(api, makeIssue());
|
|
247
|
+
|
|
248
|
+
const callArgs = mockRunAgent.mock.calls[0][0];
|
|
249
|
+
expect(callArgs.agentId).toBe("zoe");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("uses 30s timeout for assessment", async () => {
|
|
253
|
+
mockRunAgent.mockResolvedValue({
|
|
254
|
+
success: true,
|
|
255
|
+
output: '{"tier":"medior","reasoning":"Normal"}',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const api = makeApi({ defaultAgentId: "mal" });
|
|
259
|
+
await assessTier(api, makeIssue());
|
|
260
|
+
|
|
261
|
+
const callArgs = mockRunAgent.mock.calls[0][0];
|
|
262
|
+
expect(callArgs.timeoutMs).toBe(30_000);
|
|
263
|
+
});
|
|
264
|
+
});
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -8,10 +8,13 @@ import { setActiveSession, clearActiveSession } from "./active-session.js";
|
|
|
8
8
|
import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
|
|
9
9
|
import { createNotifierFromConfig, type NotifyFn } from "../infra/notify.js";
|
|
10
10
|
import { assessTier } from "./tier-assess.js";
|
|
11
|
-
import { createWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
|
|
12
|
-
import {
|
|
11
|
+
import { createWorktree, createMultiWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
|
|
12
|
+
import { resolveRepos, isMultiRepo } from "../infra/multi-repo.js";
|
|
13
|
+
import { ensureClawDir, writeManifest, writeDispatchMemory, resolveOrchestratorWorkspace } from "./artifacts.js";
|
|
13
14
|
import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
|
|
14
15
|
import { initiatePlanningSession, handlePlannerTurn } from "./planner.js";
|
|
16
|
+
import { startProjectDispatch } from "./dag-dispatch.js";
|
|
17
|
+
import { emitDiagnostic } from "../infra/observability.js";
|
|
15
18
|
|
|
16
19
|
// ── Agent profiles (loaded from config, no hardcoded names) ───────
|
|
17
20
|
interface AgentProfile {
|
|
@@ -148,6 +151,7 @@ export async function handleLinearWebhook(
|
|
|
148
151
|
// Debug: log full payload structure for diagnosing webhook types
|
|
149
152
|
const payloadKeys = Object.keys(payload).join(", ");
|
|
150
153
|
api.logger.info(`Linear webhook received: type=${payload.type} action=${payload.action} keys=[${payloadKeys}]`);
|
|
154
|
+
emitDiagnostic(api, { event: "webhook_received", webhookType: payload.type, webhookAction: payload.action });
|
|
151
155
|
|
|
152
156
|
|
|
153
157
|
// ── AppUserNotification — OAuth app webhook for agent mentions/assignments
|
|
@@ -259,7 +263,7 @@ export async function handleLinearWebhook(
|
|
|
259
263
|
|
|
260
264
|
const responseBody = result.success
|
|
261
265
|
? result.output
|
|
262
|
-
: `
|
|
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.`;
|
|
263
267
|
|
|
264
268
|
// 5. Post branded comment (fallback to prefix)
|
|
265
269
|
const brandingOpts = avatarUrl
|
|
@@ -435,7 +439,7 @@ export async function handleLinearWebhook(
|
|
|
435
439
|
|
|
436
440
|
const responseBody = result.success
|
|
437
441
|
? result.output
|
|
438
|
-
: `
|
|
442
|
+
: `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.`;
|
|
439
443
|
|
|
440
444
|
// Post as comment
|
|
441
445
|
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
@@ -613,7 +617,7 @@ export async function handleLinearWebhook(
|
|
|
613
617
|
|
|
614
618
|
const responseBody = result.success
|
|
615
619
|
? result.output
|
|
616
|
-
: `
|
|
620
|
+
: `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.`;
|
|
617
621
|
|
|
618
622
|
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
619
623
|
const brandingOpts = avatarUrl
|
|
@@ -666,6 +670,8 @@ export async function handleLinearWebhook(
|
|
|
666
670
|
const issue = comment?.issue ?? payload.issue;
|
|
667
671
|
|
|
668
672
|
// ── Planning mode intercept ──────────────────────────────────
|
|
673
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
674
|
+
|
|
669
675
|
if (issue?.id) {
|
|
670
676
|
const linearApiForPlanning = createLinearApi(api);
|
|
671
677
|
if (linearApiForPlanning) {
|
|
@@ -678,7 +684,7 @@ export async function handleLinearWebhook(
|
|
|
678
684
|
const planState = await readPlanningState(planStatePath);
|
|
679
685
|
|
|
680
686
|
// Check if this is a plan initiation request
|
|
681
|
-
const isPlanRequest = /\b(plan|planning)\s+(this\s+)
|
|
687
|
+
const isPlanRequest = /\b(plan|planning)\s+(this\s+)(project|out)\b/i.test(commentBody) || /\bplan\s+this\s+out\b/i.test(commentBody);
|
|
682
688
|
if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
|
|
683
689
|
api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
|
|
684
690
|
void initiatePlanningSession(
|
|
@@ -692,12 +698,34 @@ export async function handleLinearWebhook(
|
|
|
692
698
|
// Route to planner if project is in planning mode
|
|
693
699
|
if (isInPlanningMode(planState, projectId)) {
|
|
694
700
|
const session = getPlanningSession(planState, projectId);
|
|
695
|
-
if (session
|
|
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}`)) {
|
|
696
710
|
api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
|
|
697
711
|
void handlePlannerTurn(
|
|
698
712
|
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
699
713
|
session,
|
|
700
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
|
+
},
|
|
701
729
|
).catch((err) => api.logger.error(`Planner turn error: ${err}`));
|
|
702
730
|
}
|
|
703
731
|
return true;
|
|
@@ -856,7 +884,7 @@ export async function handleLinearWebhook(
|
|
|
856
884
|
|
|
857
885
|
const responseBody = result.success
|
|
858
886
|
? result.output
|
|
859
|
-
: `
|
|
887
|
+
: `Something went wrong while processing this. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
|
|
860
888
|
|
|
861
889
|
// 5. Post branded comment (fall back to [Label] prefix if branding fails)
|
|
862
890
|
const brandingOpts = profile?.avatarUrl
|
|
@@ -1010,6 +1038,27 @@ export async function handleLinearWebhook(
|
|
|
1010
1038
|
api.logger.warn(`Could not fetch issue details for triage: ${err}`);
|
|
1011
1039
|
}
|
|
1012
1040
|
|
|
1041
|
+
// Skip triage for issues in projects that are actively being planned —
|
|
1042
|
+
// the planner creates issues and triage would overwrite its estimates/labels.
|
|
1043
|
+
const triageProjectId = enrichedIssue?.project?.id;
|
|
1044
|
+
if (triageProjectId) {
|
|
1045
|
+
const planStatePath = pluginConfig?.planningStatePath as string | undefined;
|
|
1046
|
+
try {
|
|
1047
|
+
const planState = await readPlanningState(planStatePath);
|
|
1048
|
+
if (isInPlanningMode(planState, triageProjectId)) {
|
|
1049
|
+
api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} belongs to project in planning mode — skipping triage`);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
} catch { /* proceed with triage if planning state check fails */ }
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Skip triage for issues created by our own bot user
|
|
1056
|
+
const viewerId = await linearApi.getViewerId();
|
|
1057
|
+
if (viewerId && issue.creatorId === viewerId) {
|
|
1058
|
+
api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} created by our bot — skipping triage`);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1013
1062
|
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
1014
1063
|
const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
|
|
1015
1064
|
const currentLabels = enrichedIssue?.labels?.nodes ?? [];
|
|
@@ -1094,7 +1143,7 @@ export async function handleLinearWebhook(
|
|
|
1094
1143
|
|
|
1095
1144
|
const responseBody = result.success
|
|
1096
1145
|
? result.output
|
|
1097
|
-
: `
|
|
1146
|
+
: `Something went wrong while triaging this issue. You may need to set the estimate and labels manually.`;
|
|
1098
1147
|
|
|
1099
1148
|
// Parse triage JSON and apply to issue
|
|
1100
1149
|
let commentBody = responseBody;
|
|
@@ -1218,7 +1267,7 @@ async function handleDispatch(
|
|
|
1218
1267
|
api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
|
|
1219
1268
|
await linearApi.createComment(
|
|
1220
1269
|
issue.id,
|
|
1221
|
-
|
|
1270
|
+
`**Can't dispatch yet** — this project is in planning mode.\n\n**To continue:** Comment on the planning issue with your requirements, then say **"finalize plan"** when ready.\n\n**To cancel planning:** Comment **"abandon"** on the planning issue.`,
|
|
1222
1271
|
);
|
|
1223
1272
|
return;
|
|
1224
1273
|
}
|
|
@@ -1241,7 +1290,7 @@ async function handleDispatch(
|
|
|
1241
1290
|
api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
|
|
1242
1291
|
await linearApi.createComment(
|
|
1243
1292
|
issue.id,
|
|
1244
|
-
|
|
1293
|
+
`**Already running** as **${existing.tier}** — status: **${existing.status}**, started ${Math.round(ageMs / 60_000)}m ago.\n\nWorktree: \`${existing.worktreePath}\`\n\n**Options:**\n- Check progress: \`/dispatch status ${identifier}\`\n- Force restart: \`/dispatch retry ${identifier}\` (only works when stuck)\n- Escalate: \`/dispatch escalate ${identifier} "reason"\``,
|
|
1245
1294
|
);
|
|
1246
1295
|
return;
|
|
1247
1296
|
}
|
|
@@ -1273,6 +1322,9 @@ async function handleDispatch(
|
|
|
1273
1322
|
const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
|
|
1274
1323
|
const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
|
|
1275
1324
|
|
|
1325
|
+
// Resolve repos for this dispatch (issue body markers, labels, or config default)
|
|
1326
|
+
const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig);
|
|
1327
|
+
|
|
1276
1328
|
// 4. Assess complexity tier
|
|
1277
1329
|
const assessment = await assessTier(api, {
|
|
1278
1330
|
identifier,
|
|
@@ -1283,29 +1335,53 @@ async function handleDispatch(
|
|
|
1283
1335
|
});
|
|
1284
1336
|
|
|
1285
1337
|
api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
|
|
1338
|
+
emitDiagnostic(api, { event: "dispatch_started", identifier, tier: assessment.tier, issueId: issue.id });
|
|
1339
|
+
|
|
1340
|
+
// 5. Create persistent worktree(s)
|
|
1341
|
+
let worktreePath: string;
|
|
1342
|
+
let worktreeBranch: string;
|
|
1343
|
+
let worktreeResumed: boolean;
|
|
1344
|
+
let worktrees: Array<{ repoName: string; path: string; branch: string }> | undefined;
|
|
1286
1345
|
|
|
1287
|
-
// 5. Create persistent worktree
|
|
1288
|
-
let worktree;
|
|
1289
1346
|
try {
|
|
1290
|
-
|
|
1291
|
-
|
|
1347
|
+
if (isMultiRepo(repoResolution)) {
|
|
1348
|
+
const multi = createMultiWorktree(identifier, repoResolution.repos, { baseDir: worktreeBaseDir });
|
|
1349
|
+
worktreePath = multi.parentPath;
|
|
1350
|
+
worktreeBranch = `codex/${identifier}`;
|
|
1351
|
+
worktreeResumed = multi.worktrees.some(w => w.resumed);
|
|
1352
|
+
worktrees = multi.worktrees.map(w => ({ repoName: w.repoName, path: w.path, branch: w.branch }));
|
|
1353
|
+
api.logger.info(`@dispatch: multi-repo worktrees ${worktreeResumed ? "resumed" : "created"} at ${worktreePath} (${repoResolution.repos.map(r => r.name).join(", ")})`);
|
|
1354
|
+
} else {
|
|
1355
|
+
const single = createWorktree(identifier, { baseRepo, baseDir: worktreeBaseDir });
|
|
1356
|
+
worktreePath = single.path;
|
|
1357
|
+
worktreeBranch = single.branch;
|
|
1358
|
+
worktreeResumed = single.resumed;
|
|
1359
|
+
api.logger.info(`@dispatch: worktree ${worktreeResumed ? "resumed" : "created"} at ${worktreePath}`);
|
|
1360
|
+
}
|
|
1292
1361
|
} catch (err) {
|
|
1293
1362
|
api.logger.error(`@dispatch: worktree creation failed: ${err}`);
|
|
1294
1363
|
await linearApi.createComment(
|
|
1295
1364
|
issue.id,
|
|
1296
|
-
|
|
1365
|
+
`**Dispatch failed** — couldn't create the worktree.\n\n> ${String(err).slice(0, 200)}\n\n**What to try:**\n- Check that the base repo exists\n- Re-assign this issue to try again\n- Check logs: \`journalctl --user -u openclaw-gateway --since "5 min ago"\``,
|
|
1297
1366
|
);
|
|
1298
1367
|
return;
|
|
1299
1368
|
}
|
|
1300
1369
|
|
|
1301
|
-
// 5b. Prepare workspace
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1370
|
+
// 5b. Prepare workspace(s)
|
|
1371
|
+
if (worktrees) {
|
|
1372
|
+
for (const wt of worktrees) {
|
|
1373
|
+
const prep = prepareWorkspace(wt.path, wt.branch);
|
|
1374
|
+
if (prep.errors.length > 0) {
|
|
1375
|
+
api.logger.warn(`@dispatch: workspace prep for ${wt.repoName} had errors: ${prep.errors.join("; ")}`);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1305
1378
|
} else {
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1379
|
+
const prep = prepareWorkspace(worktreePath, worktreeBranch);
|
|
1380
|
+
if (prep.errors.length > 0) {
|
|
1381
|
+
api.logger.warn(`@dispatch: workspace prep had errors: ${prep.errors.join("; ")}`);
|
|
1382
|
+
} else {
|
|
1383
|
+
api.logger.info(`@dispatch: workspace prepared — pulled=${prep.pulled}, submodules=${prep.submodulesInitialized}`);
|
|
1384
|
+
}
|
|
1309
1385
|
}
|
|
1310
1386
|
|
|
1311
1387
|
// 6. Create agent session on Linear
|
|
@@ -1319,16 +1395,16 @@ async function handleDispatch(
|
|
|
1319
1395
|
|
|
1320
1396
|
// 6b. Initialize .claw/ artifact directory
|
|
1321
1397
|
try {
|
|
1322
|
-
ensureClawDir(
|
|
1323
|
-
writeManifest(
|
|
1398
|
+
ensureClawDir(worktreePath);
|
|
1399
|
+
writeManifest(worktreePath, {
|
|
1324
1400
|
issueIdentifier: identifier,
|
|
1325
1401
|
issueTitle: enrichedIssue.title ?? "(untitled)",
|
|
1326
1402
|
issueId: issue.id,
|
|
1327
1403
|
tier: assessment.tier,
|
|
1328
1404
|
model: assessment.model,
|
|
1329
1405
|
dispatchedAt: new Date().toISOString(),
|
|
1330
|
-
worktreePath
|
|
1331
|
-
branch:
|
|
1406
|
+
worktreePath,
|
|
1407
|
+
branch: worktreeBranch,
|
|
1332
1408
|
attempts: 0,
|
|
1333
1409
|
status: "dispatched",
|
|
1334
1410
|
plugin: "openclaw-linear",
|
|
@@ -1343,14 +1419,16 @@ async function handleDispatch(
|
|
|
1343
1419
|
issueId: issue.id,
|
|
1344
1420
|
issueIdentifier: identifier,
|
|
1345
1421
|
issueTitle: enrichedIssue.title ?? "(untitled)",
|
|
1346
|
-
worktreePath
|
|
1347
|
-
branch:
|
|
1422
|
+
worktreePath,
|
|
1423
|
+
branch: worktreeBranch,
|
|
1348
1424
|
tier: assessment.tier,
|
|
1349
1425
|
model: assessment.model,
|
|
1350
1426
|
status: "dispatched",
|
|
1351
1427
|
dispatchedAt: now,
|
|
1352
1428
|
agentSessionId,
|
|
1353
1429
|
attempt: 0,
|
|
1430
|
+
project: enrichedIssue?.project?.id,
|
|
1431
|
+
worktrees,
|
|
1354
1432
|
}, statePath);
|
|
1355
1433
|
|
|
1356
1434
|
// 8. Register active session for tool resolution
|
|
@@ -1363,16 +1441,24 @@ async function handleDispatch(
|
|
|
1363
1441
|
});
|
|
1364
1442
|
|
|
1365
1443
|
// 9. Post dispatch confirmation comment
|
|
1366
|
-
const
|
|
1367
|
-
?
|
|
1368
|
-
:
|
|
1444
|
+
const worktreeDesc = worktrees
|
|
1445
|
+
? worktrees.map(wt => `\`${wt.repoName}\`: \`${wt.path}\``).join("\n")
|
|
1446
|
+
: `\`${worktreePath}\``;
|
|
1369
1447
|
const statusComment = [
|
|
1370
1448
|
`**Dispatched** as **${assessment.tier}** (${assessment.model})`,
|
|
1371
1449
|
`> ${assessment.reasoning}`,
|
|
1372
1450
|
``,
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1451
|
+
worktrees
|
|
1452
|
+
? `Worktrees ${worktreeResumed ? "(resumed)" : "(fresh)"}:\n${worktreeDesc}`
|
|
1453
|
+
: `Worktree: ${worktreeDesc} ${worktreeResumed ? "(resumed)" : "(fresh)"}`,
|
|
1454
|
+
`Branch: \`${worktreeBranch}\``,
|
|
1455
|
+
``,
|
|
1456
|
+
`**Status:** Worker is starting now. An independent audit runs automatically after implementation.`,
|
|
1457
|
+
``,
|
|
1458
|
+
`**While you wait:**`,
|
|
1459
|
+
`- Check progress: \`/dispatch status ${identifier}\``,
|
|
1460
|
+
`- Cancel: \`/dispatch escalate ${identifier} "reason"\``,
|
|
1461
|
+
`- All dispatches: \`/dispatch list\``,
|
|
1376
1462
|
].join("\n");
|
|
1377
1463
|
|
|
1378
1464
|
await linearApi.createComment(issue.id, statusComment);
|
|
@@ -1404,7 +1490,7 @@ async function handleDispatch(
|
|
|
1404
1490
|
activeRuns.add(issue.id);
|
|
1405
1491
|
|
|
1406
1492
|
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
1407
|
-
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
|
|
1493
|
+
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
1408
1494
|
|
|
1409
1495
|
const hookCtx: HookContext = {
|
|
1410
1496
|
api,
|
|
@@ -1429,6 +1515,18 @@ async function handleDispatch(
|
|
|
1429
1515
|
.catch(async (err) => {
|
|
1430
1516
|
api.logger.error(`@dispatch: pipeline v2 failed for ${identifier}: ${err}`);
|
|
1431
1517
|
await updateDispatchStatus(identifier, "failed", statePath);
|
|
1518
|
+
// Write memory for failed dispatches so they're searchable in dispatch history
|
|
1519
|
+
try {
|
|
1520
|
+
const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
|
|
1521
|
+
writeDispatchMemory(identifier, `Pipeline failed: ${String(err).slice(0, 500)}`, wsDir, {
|
|
1522
|
+
title: enrichedIssue.title ?? identifier,
|
|
1523
|
+
tier: assessment.tier,
|
|
1524
|
+
status: "failed",
|
|
1525
|
+
project: enrichedIssue?.project?.id,
|
|
1526
|
+
attempts: 1,
|
|
1527
|
+
model: assessment.model,
|
|
1528
|
+
});
|
|
1529
|
+
} catch { /* best effort */ }
|
|
1432
1530
|
})
|
|
1433
1531
|
.finally(() => {
|
|
1434
1532
|
activeRuns.delete(issue.id);
|