@calltelemetry/openclaw-linear 0.7.1 → 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/README.md +719 -539
- package/index.ts +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -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 +93 -1
- package/src/api/linear-api.ts +37 -1
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/infra/cli.ts +176 -1
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +29 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +26 -15
- package/src/infra/observability.test.ts +85 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/dispatch-state.ts +1 -0
- 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 +47 -18
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +12 -30
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +114 -37
- 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/pipeline/planner.ts
CHANGED
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
* - handlePlannerTurn: processes each user comment during planning
|
|
7
7
|
* - runPlanAudit: validates the plan before finalizing
|
|
8
8
|
*/
|
|
9
|
-
import { readFileSync } from "node:fs";
|
|
10
|
-
import { join, dirname } from "node:path";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { parse as parseYaml } from "yaml";
|
|
13
9
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
10
|
+
import { loadRawPromptYaml } from "./pipeline.js";
|
|
14
11
|
import type { LinearAgentApi } from "../api/linear-api.js";
|
|
15
12
|
import { runAgent } from "../agent/agent.js";
|
|
16
13
|
import {
|
|
@@ -56,30 +53,15 @@ function loadPlannerPrompts(pluginConfig?: Record<string, unknown>): PlannerProm
|
|
|
56
53
|
welcome: "Entering planning mode for **{{projectName}}**. What are the main feature areas?",
|
|
57
54
|
};
|
|
58
55
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} else {
|
|
69
|
-
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
70
|
-
raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const parsed = parseYaml(raw) as any;
|
|
74
|
-
if (parsed?.planner) {
|
|
75
|
-
return {
|
|
76
|
-
system: parsed.planner.system ?? defaults.system,
|
|
77
|
-
interview: parsed.planner.interview ?? defaults.interview,
|
|
78
|
-
audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
|
|
79
|
-
welcome: parsed.planner.welcome ?? defaults.welcome,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
} catch { /* use defaults */ }
|
|
56
|
+
const parsed = loadRawPromptYaml(pluginConfig);
|
|
57
|
+
if (parsed?.planner) {
|
|
58
|
+
return {
|
|
59
|
+
system: parsed.planner.system ?? defaults.system,
|
|
60
|
+
interview: parsed.planner.interview ?? defaults.interview,
|
|
61
|
+
audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
|
|
62
|
+
welcome: parsed.planner.welcome ?? defaults.welcome,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
83
65
|
|
|
84
66
|
return defaults;
|
|
85
67
|
}
|
|
@@ -142,8 +124,8 @@ export async function initiatePlanningSession(
|
|
|
142
124
|
// Interview turn
|
|
143
125
|
// ---------------------------------------------------------------------------
|
|
144
126
|
|
|
145
|
-
const FINALIZE_PATTERN = /\b(finalize\s+plan|
|
|
146
|
-
const ABANDON_PATTERN = /\b(abandon
|
|
127
|
+
const FINALIZE_PATTERN = /\b(finalize\s+(the\s+)?plan\b|done\s+planning\b(?!\s+\w)|approve\s+(the\s+)?plan\b|plan\s+looks\s+good\b|ready\s+to\s+finalize\b|let'?s\s+finalize\b)/i;
|
|
128
|
+
const ABANDON_PATTERN = /\b(abandon\s+plan(ning)?|cancel\s+plan(ning)?|stop\s+planning|exit\s+planning|quit\s+planning)\b/i;
|
|
147
129
|
|
|
148
130
|
export async function handlePlannerTurn(
|
|
149
131
|
ctx: PlannerContext,
|
|
@@ -172,4 +172,93 @@ describe("assessTier", () => {
|
|
|
172
172
|
expect(result.model).toBe(TIER_MODELS.junior);
|
|
173
173
|
expect(result.reasoning).toBe("Config tweak");
|
|
174
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
|
+
});
|
|
175
264
|
});
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -8,8 +8,9 @@ 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";
|
|
15
16
|
import { startProjectDispatch } from "./dag-dispatch.js";
|
|
@@ -262,7 +263,7 @@ export async function handleLinearWebhook(
|
|
|
262
263
|
|
|
263
264
|
const responseBody = result.success
|
|
264
265
|
? result.output
|
|
265
|
-
: `
|
|
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.`;
|
|
266
267
|
|
|
267
268
|
// 5. Post branded comment (fallback to prefix)
|
|
268
269
|
const brandingOpts = avatarUrl
|
|
@@ -438,7 +439,7 @@ export async function handleLinearWebhook(
|
|
|
438
439
|
|
|
439
440
|
const responseBody = result.success
|
|
440
441
|
? result.output
|
|
441
|
-
: `
|
|
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.`;
|
|
442
443
|
|
|
443
444
|
// Post as comment
|
|
444
445
|
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
@@ -616,7 +617,7 @@ export async function handleLinearWebhook(
|
|
|
616
617
|
|
|
617
618
|
const responseBody = result.success
|
|
618
619
|
? result.output
|
|
619
|
-
: `
|
|
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.`;
|
|
620
621
|
|
|
621
622
|
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
622
623
|
const brandingOpts = avatarUrl
|
|
@@ -683,7 +684,7 @@ export async function handleLinearWebhook(
|
|
|
683
684
|
const planState = await readPlanningState(planStatePath);
|
|
684
685
|
|
|
685
686
|
// Check if this is a plan initiation request
|
|
686
|
-
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);
|
|
687
688
|
if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
|
|
688
689
|
api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
|
|
689
690
|
void initiatePlanningSession(
|
|
@@ -697,7 +698,15 @@ export async function handleLinearWebhook(
|
|
|
697
698
|
// Route to planner if project is in planning mode
|
|
698
699
|
if (isInPlanningMode(planState, projectId)) {
|
|
699
700
|
const session = getPlanningSession(planState, projectId);
|
|
700
|
-
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}`)) {
|
|
701
710
|
api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
|
|
702
711
|
void handlePlannerTurn(
|
|
703
712
|
{ api, linearApi: linearApiForPlanning, pluginConfig },
|
|
@@ -705,7 +714,7 @@ export async function handleLinearWebhook(
|
|
|
705
714
|
{ issueId: issue.id, commentBody, commentorName: commentor },
|
|
706
715
|
{
|
|
707
716
|
onApproved: (approvedProjectId) => {
|
|
708
|
-
const notify = createNotifierFromConfig(pluginConfig, api.runtime);
|
|
717
|
+
const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
709
718
|
const hookCtx: HookContext = {
|
|
710
719
|
api,
|
|
711
720
|
linearApi: linearApiForPlanning,
|
|
@@ -875,7 +884,7 @@ export async function handleLinearWebhook(
|
|
|
875
884
|
|
|
876
885
|
const responseBody = result.success
|
|
877
886
|
? result.output
|
|
878
|
-
: `
|
|
887
|
+
: `Something went wrong while processing this. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
|
|
879
888
|
|
|
880
889
|
// 5. Post branded comment (fall back to [Label] prefix if branding fails)
|
|
881
890
|
const brandingOpts = profile?.avatarUrl
|
|
@@ -1029,6 +1038,27 @@ export async function handleLinearWebhook(
|
|
|
1029
1038
|
api.logger.warn(`Could not fetch issue details for triage: ${err}`);
|
|
1030
1039
|
}
|
|
1031
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
|
+
|
|
1032
1062
|
const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
|
|
1033
1063
|
const estimationType = enrichedIssue?.team?.issueEstimationType ?? "fibonacci";
|
|
1034
1064
|
const currentLabels = enrichedIssue?.labels?.nodes ?? [];
|
|
@@ -1113,7 +1143,7 @@ export async function handleLinearWebhook(
|
|
|
1113
1143
|
|
|
1114
1144
|
const responseBody = result.success
|
|
1115
1145
|
? result.output
|
|
1116
|
-
: `
|
|
1146
|
+
: `Something went wrong while triaging this issue. You may need to set the estimate and labels manually.`;
|
|
1117
1147
|
|
|
1118
1148
|
// Parse triage JSON and apply to issue
|
|
1119
1149
|
let commentBody = responseBody;
|
|
@@ -1237,7 +1267,7 @@ async function handleDispatch(
|
|
|
1237
1267
|
api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
|
|
1238
1268
|
await linearApi.createComment(
|
|
1239
1269
|
issue.id,
|
|
1240
|
-
|
|
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.`,
|
|
1241
1271
|
);
|
|
1242
1272
|
return;
|
|
1243
1273
|
}
|
|
@@ -1260,7 +1290,7 @@ async function handleDispatch(
|
|
|
1260
1290
|
api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
|
|
1261
1291
|
await linearApi.createComment(
|
|
1262
1292
|
issue.id,
|
|
1263
|
-
|
|
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"\``,
|
|
1264
1294
|
);
|
|
1265
1295
|
return;
|
|
1266
1296
|
}
|
|
@@ -1292,6 +1322,9 @@ async function handleDispatch(
|
|
|
1292
1322
|
const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name) ?? [];
|
|
1293
1323
|
const commentCount = enrichedIssue.comments?.nodes?.length ?? 0;
|
|
1294
1324
|
|
|
1325
|
+
// Resolve repos for this dispatch (issue body markers, labels, or config default)
|
|
1326
|
+
const repoResolution = resolveRepos(enrichedIssue.description, labels, pluginConfig);
|
|
1327
|
+
|
|
1295
1328
|
// 4. Assess complexity tier
|
|
1296
1329
|
const assessment = await assessTier(api, {
|
|
1297
1330
|
identifier,
|
|
@@ -1304,28 +1337,51 @@ async function handleDispatch(
|
|
|
1304
1337
|
api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
|
|
1305
1338
|
emitDiagnostic(api, { event: "dispatch_started", identifier, tier: assessment.tier, issueId: issue.id });
|
|
1306
1339
|
|
|
1307
|
-
// 5. Create persistent worktree
|
|
1308
|
-
let
|
|
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;
|
|
1345
|
+
|
|
1309
1346
|
try {
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
+
}
|
|
1312
1361
|
} catch (err) {
|
|
1313
1362
|
api.logger.error(`@dispatch: worktree creation failed: ${err}`);
|
|
1314
1363
|
await linearApi.createComment(
|
|
1315
1364
|
issue.id,
|
|
1316
|
-
|
|
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"\``,
|
|
1317
1366
|
);
|
|
1318
1367
|
return;
|
|
1319
1368
|
}
|
|
1320
1369
|
|
|
1321
|
-
// 5b. Prepare workspace
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
+
}
|
|
1325
1378
|
} else {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
+
}
|
|
1329
1385
|
}
|
|
1330
1386
|
|
|
1331
1387
|
// 6. Create agent session on Linear
|
|
@@ -1339,16 +1395,16 @@ async function handleDispatch(
|
|
|
1339
1395
|
|
|
1340
1396
|
// 6b. Initialize .claw/ artifact directory
|
|
1341
1397
|
try {
|
|
1342
|
-
ensureClawDir(
|
|
1343
|
-
writeManifest(
|
|
1398
|
+
ensureClawDir(worktreePath);
|
|
1399
|
+
writeManifest(worktreePath, {
|
|
1344
1400
|
issueIdentifier: identifier,
|
|
1345
1401
|
issueTitle: enrichedIssue.title ?? "(untitled)",
|
|
1346
1402
|
issueId: issue.id,
|
|
1347
1403
|
tier: assessment.tier,
|
|
1348
1404
|
model: assessment.model,
|
|
1349
1405
|
dispatchedAt: new Date().toISOString(),
|
|
1350
|
-
worktreePath
|
|
1351
|
-
branch:
|
|
1406
|
+
worktreePath,
|
|
1407
|
+
branch: worktreeBranch,
|
|
1352
1408
|
attempts: 0,
|
|
1353
1409
|
status: "dispatched",
|
|
1354
1410
|
plugin: "openclaw-linear",
|
|
@@ -1363,8 +1419,8 @@ async function handleDispatch(
|
|
|
1363
1419
|
issueId: issue.id,
|
|
1364
1420
|
issueIdentifier: identifier,
|
|
1365
1421
|
issueTitle: enrichedIssue.title ?? "(untitled)",
|
|
1366
|
-
worktreePath
|
|
1367
|
-
branch:
|
|
1422
|
+
worktreePath,
|
|
1423
|
+
branch: worktreeBranch,
|
|
1368
1424
|
tier: assessment.tier,
|
|
1369
1425
|
model: assessment.model,
|
|
1370
1426
|
status: "dispatched",
|
|
@@ -1372,6 +1428,7 @@ async function handleDispatch(
|
|
|
1372
1428
|
agentSessionId,
|
|
1373
1429
|
attempt: 0,
|
|
1374
1430
|
project: enrichedIssue?.project?.id,
|
|
1431
|
+
worktrees,
|
|
1375
1432
|
}, statePath);
|
|
1376
1433
|
|
|
1377
1434
|
// 8. Register active session for tool resolution
|
|
@@ -1384,16 +1441,24 @@ async function handleDispatch(
|
|
|
1384
1441
|
});
|
|
1385
1442
|
|
|
1386
1443
|
// 9. Post dispatch confirmation comment
|
|
1387
|
-
const
|
|
1388
|
-
?
|
|
1389
|
-
:
|
|
1444
|
+
const worktreeDesc = worktrees
|
|
1445
|
+
? worktrees.map(wt => `\`${wt.repoName}\`: \`${wt.path}\``).join("\n")
|
|
1446
|
+
: `\`${worktreePath}\``;
|
|
1390
1447
|
const statusComment = [
|
|
1391
1448
|
`**Dispatched** as **${assessment.tier}** (${assessment.model})`,
|
|
1392
1449
|
`> ${assessment.reasoning}`,
|
|
1393
1450
|
``,
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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\``,
|
|
1397
1462
|
].join("\n");
|
|
1398
1463
|
|
|
1399
1464
|
await linearApi.createComment(issue.id, statusComment);
|
|
@@ -1425,7 +1490,7 @@ async function handleDispatch(
|
|
|
1425
1490
|
activeRuns.add(issue.id);
|
|
1426
1491
|
|
|
1427
1492
|
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
1428
|
-
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
|
|
1493
|
+
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
1429
1494
|
|
|
1430
1495
|
const hookCtx: HookContext = {
|
|
1431
1496
|
api,
|
|
@@ -1450,6 +1515,18 @@ async function handleDispatch(
|
|
|
1450
1515
|
.catch(async (err) => {
|
|
1451
1516
|
api.logger.error(`@dispatch: pipeline v2 failed for ${identifier}: ${err}`);
|
|
1452
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 */ }
|
|
1453
1530
|
})
|
|
1454
1531
|
.finally(() => {
|
|
1455
1532
|
activeRuns.delete(issue.id);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock the active-session module
|
|
4
|
+
vi.mock("../pipeline/active-session.js", () => ({
|
|
5
|
+
getCurrentSession: vi.fn(() => null),
|
|
6
|
+
getActiveSessionByIdentifier: vi.fn(() => null),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
// Mock the linear-api module — LinearAgentApi must be a class (used with `new`)
|
|
10
|
+
vi.mock("../api/linear-api.js", () => {
|
|
11
|
+
const MockClass = vi.fn(function (this: any) {
|
|
12
|
+
return this;
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
resolveLinearToken: vi.fn(() => ({ accessToken: null, source: "none" })),
|
|
16
|
+
LinearAgentApi: MockClass,
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Mock the watchdog module (re-exported constants)
|
|
21
|
+
vi.mock("../agent/watchdog.js", () => ({
|
|
22
|
+
DEFAULT_INACTIVITY_SEC: 120,
|
|
23
|
+
DEFAULT_MAX_TOTAL_SEC: 7200,
|
|
24
|
+
DEFAULT_TOOL_TIMEOUT_SEC: 600,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { getCurrentSession, getActiveSessionByIdentifier } from "../pipeline/active-session.js";
|
|
28
|
+
import { resolveLinearToken, LinearAgentApi } from "../api/linear-api.js";
|
|
29
|
+
import { extractPrompt, resolveSession, buildLinearApi } from "./cli-shared.js";
|
|
30
|
+
import type { CliToolParams } from "./cli-shared.js";
|
|
31
|
+
|
|
32
|
+
const mockedGetCurrentSession = vi.mocked(getCurrentSession);
|
|
33
|
+
const mockedGetActiveByIdentifier = vi.mocked(getActiveSessionByIdentifier);
|
|
34
|
+
const mockedResolveLinearToken = vi.mocked(resolveLinearToken);
|
|
35
|
+
const MockedLinearAgentApi = vi.mocked(LinearAgentApi);
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// extractPrompt
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
describe("extractPrompt", () => {
|
|
45
|
+
it("returns prompt field when present", () => {
|
|
46
|
+
const params: CliToolParams = { prompt: "do the thing" };
|
|
47
|
+
expect(extractPrompt(params)).toBe("do the thing");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("falls back to text field", () => {
|
|
51
|
+
const params = { text: "text fallback" } as unknown as CliToolParams;
|
|
52
|
+
expect(extractPrompt(params)).toBe("text fallback");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("falls back to message field", () => {
|
|
56
|
+
const params = { message: "message fallback" } as unknown as CliToolParams;
|
|
57
|
+
expect(extractPrompt(params)).toBe("message fallback");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to task field", () => {
|
|
61
|
+
const params = { task: "task fallback" } as unknown as CliToolParams;
|
|
62
|
+
expect(extractPrompt(params)).toBe("task fallback");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns undefined when no fields present", () => {
|
|
66
|
+
const params = {} as unknown as CliToolParams;
|
|
67
|
+
expect(extractPrompt(params)).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// resolveSession
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
describe("resolveSession", () => {
|
|
75
|
+
it("uses explicit session params when provided", () => {
|
|
76
|
+
const params: CliToolParams = {
|
|
77
|
+
prompt: "test",
|
|
78
|
+
agentSessionId: "sess-123",
|
|
79
|
+
issueIdentifier: "API-42",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const result = resolveSession(params);
|
|
83
|
+
expect(result.agentSessionId).toBe("sess-123");
|
|
84
|
+
expect(result.issueIdentifier).toBe("API-42");
|
|
85
|
+
// Should not consult the registry at all
|
|
86
|
+
expect(mockedGetCurrentSession).not.toHaveBeenCalled();
|
|
87
|
+
expect(mockedGetActiveByIdentifier).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("falls back to active session registry", () => {
|
|
91
|
+
mockedGetCurrentSession.mockReturnValue({
|
|
92
|
+
agentSessionId: "active-sess-456",
|
|
93
|
+
issueIdentifier: "API-99",
|
|
94
|
+
issueId: "issue-id-99",
|
|
95
|
+
startedAt: Date.now(),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const params: CliToolParams = { prompt: "test" };
|
|
99
|
+
const result = resolveSession(params);
|
|
100
|
+
|
|
101
|
+
expect(result.agentSessionId).toBe("active-sess-456");
|
|
102
|
+
expect(result.issueIdentifier).toBe("API-99");
|
|
103
|
+
expect(mockedGetCurrentSession).toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// buildLinearApi
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
describe("buildLinearApi", () => {
|
|
111
|
+
it("returns null when no agentSessionId provided", () => {
|
|
112
|
+
const api = { pluginConfig: {} } as any;
|
|
113
|
+
expect(buildLinearApi(api)).toBeNull();
|
|
114
|
+
expect(buildLinearApi(api, undefined)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns null when no token available", () => {
|
|
118
|
+
mockedResolveLinearToken.mockReturnValue({
|
|
119
|
+
accessToken: null,
|
|
120
|
+
source: "none",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const api = { pluginConfig: {} } as any;
|
|
124
|
+
expect(buildLinearApi(api, "sess-123")).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("creates a LinearAgentApi when token is available", () => {
|
|
128
|
+
mockedResolveLinearToken.mockReturnValue({
|
|
129
|
+
accessToken: "lin_tok_abc",
|
|
130
|
+
refreshToken: "refresh_xyz",
|
|
131
|
+
expiresAt: 9999999999,
|
|
132
|
+
source: "profile",
|
|
133
|
+
});
|
|
134
|
+
MockedLinearAgentApi.mockImplementation(function (this: any) {
|
|
135
|
+
this.fake = true;
|
|
136
|
+
return this;
|
|
137
|
+
} as any);
|
|
138
|
+
|
|
139
|
+
const api = {
|
|
140
|
+
pluginConfig: {
|
|
141
|
+
clientId: "cid",
|
|
142
|
+
clientSecret: "csecret",
|
|
143
|
+
},
|
|
144
|
+
} as any;
|
|
145
|
+
|
|
146
|
+
const result = buildLinearApi(api, "sess-123");
|
|
147
|
+
expect(result).not.toBeNull();
|
|
148
|
+
expect(MockedLinearAgentApi).toHaveBeenCalledWith("lin_tok_abc", {
|
|
149
|
+
refreshToken: "refresh_xyz",
|
|
150
|
+
expiresAt: 9999999999,
|
|
151
|
+
clientId: "cid",
|
|
152
|
+
clientSecret: "csecret",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|