@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
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E planning pipeline tests.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the real planning lifecycle: initiatePlanningSession → handlePlannerTurn
|
|
5
|
+
* → runPlanAudit → onApproved → DAG dispatch cascade.
|
|
6
|
+
*
|
|
7
|
+
* Mocked: runAgent, LinearAgentApi. Real: planning-state.ts, planner.ts,
|
|
8
|
+
* planner-tools.ts (auditPlan, buildPlanSnapshot), dag-dispatch.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Mocks — external boundaries only
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const { runAgentMock } = vi.hoisted(() => ({
|
|
17
|
+
runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Mock planner response" }),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("../agent/agent.js", () => ({
|
|
21
|
+
runAgent: runAgentMock,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("../api/linear-api.js", () => ({}));
|
|
25
|
+
vi.mock("openclaw/plugin-sdk", () => ({}));
|
|
26
|
+
vi.mock("../infra/observability.js", () => ({
|
|
27
|
+
emitDiagnostic: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Imports (AFTER mocks)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
|
|
35
|
+
import { readPlanningState, type PlanningSession } from "./planning-state.js";
|
|
36
|
+
import { writeProjectDispatch, readProjectDispatch, onProjectIssueCompleted, onProjectIssueStuck, type ProjectDispatchState } from "./dag-dispatch.js";
|
|
37
|
+
import { createMockLinearApi, tmpStatePath } from "../__test__/helpers.js";
|
|
38
|
+
import { makeProjectIssue } from "../__test__/fixtures/linear-responses.js";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function createCtx(configPath: string, overrides?: Record<string, unknown>) {
|
|
45
|
+
const linearApi = createMockLinearApi();
|
|
46
|
+
return {
|
|
47
|
+
ctx: {
|
|
48
|
+
api: {
|
|
49
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
50
|
+
pluginConfig: { planningStatePath: configPath, ...overrides },
|
|
51
|
+
} as any,
|
|
52
|
+
linearApi: linearApi as any,
|
|
53
|
+
pluginConfig: { planningStatePath: configPath, ...overrides },
|
|
54
|
+
},
|
|
55
|
+
linearApi,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createSession(configPath: string, overrides?: Partial<PlanningSession>): PlanningSession {
|
|
60
|
+
return {
|
|
61
|
+
projectId: "proj-1",
|
|
62
|
+
projectName: "Test Project",
|
|
63
|
+
rootIssueId: "issue-1",
|
|
64
|
+
rootIdentifier: "PROJ-1",
|
|
65
|
+
teamId: "team-1",
|
|
66
|
+
status: "interviewing",
|
|
67
|
+
startedAt: new Date().toISOString(),
|
|
68
|
+
turnCount: 0,
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Build a set of project issues that will pass auditPlan (description ≥ 50 chars, estimate, priority). */
|
|
74
|
+
function makePassingIssues() {
|
|
75
|
+
return [
|
|
76
|
+
makeProjectIssue("PROJ-2", {
|
|
77
|
+
title: "Implement search API",
|
|
78
|
+
description: "Build the search API endpoint with filtering and pagination support for the frontend.",
|
|
79
|
+
estimate: 3,
|
|
80
|
+
priority: 2,
|
|
81
|
+
labels: ["Epic"],
|
|
82
|
+
}),
|
|
83
|
+
makeProjectIssue("PROJ-3", {
|
|
84
|
+
title: "Build search results page",
|
|
85
|
+
description: "Create a search results page component that displays results from the search API endpoint.",
|
|
86
|
+
estimate: 2,
|
|
87
|
+
priority: 2,
|
|
88
|
+
parentIdentifier: "PROJ-2",
|
|
89
|
+
}),
|
|
90
|
+
makeProjectIssue("PROJ-4", {
|
|
91
|
+
title: "Add search autocomplete",
|
|
92
|
+
description: "Implement autocomplete suggestions in the search input using the search API typeahead endpoint.",
|
|
93
|
+
estimate: 1,
|
|
94
|
+
priority: 3,
|
|
95
|
+
parentIdentifier: "PROJ-2",
|
|
96
|
+
}),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Tests
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
describe("E2E planning pipeline", () => {
|
|
105
|
+
let configPath: string;
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
vi.clearAllMocks();
|
|
109
|
+
configPath = tmpStatePath("claw-e2e-plan-");
|
|
110
|
+
runAgentMock.mockResolvedValue({ success: true, output: "Mock planner response" });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// =========================================================================
|
|
114
|
+
// Test 1: Full lifecycle — initiate → interview → approve
|
|
115
|
+
// =========================================================================
|
|
116
|
+
it("full lifecycle: initiate → interview turns → finalize → approved", async () => {
|
|
117
|
+
const { ctx, linearApi } = createCtx(configPath);
|
|
118
|
+
|
|
119
|
+
const rootIssue = { id: "issue-1", identifier: "PROJ-1", title: "Root Issue", team: { id: "team-1" } };
|
|
120
|
+
|
|
121
|
+
// Mock getProject and getTeamStates for initiation
|
|
122
|
+
linearApi.getProject.mockResolvedValue({
|
|
123
|
+
id: "proj-1",
|
|
124
|
+
name: "Test Project",
|
|
125
|
+
teams: { nodes: [{ id: "team-1", name: "Team" }] },
|
|
126
|
+
});
|
|
127
|
+
linearApi.getTeamStates.mockResolvedValue([
|
|
128
|
+
{ id: "st-1", name: "Backlog", type: "backlog" },
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
// Step 1: Initiate planning session
|
|
132
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
133
|
+
|
|
134
|
+
// Verify welcome comment posted
|
|
135
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
136
|
+
"issue-1",
|
|
137
|
+
expect.stringContaining("planning mode"),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Verify session registered
|
|
141
|
+
let state = await readPlanningState(configPath);
|
|
142
|
+
expect(state.sessions["proj-1"]).toBeDefined();
|
|
143
|
+
expect(state.sessions["proj-1"].status).toBe("interviewing");
|
|
144
|
+
expect(state.sessions["proj-1"].turnCount).toBe(0);
|
|
145
|
+
|
|
146
|
+
// Step 2: Interview turn 1
|
|
147
|
+
const session = createSession(configPath);
|
|
148
|
+
|
|
149
|
+
// Mock getProjectIssues and getIssueDetails for interview
|
|
150
|
+
linearApi.getProjectIssues.mockResolvedValue([]);
|
|
151
|
+
linearApi.getIssueDetails.mockResolvedValue({
|
|
152
|
+
id: "issue-1",
|
|
153
|
+
identifier: "PROJ-1",
|
|
154
|
+
title: "Root Issue",
|
|
155
|
+
comments: { nodes: [] },
|
|
156
|
+
project: { id: "proj-1" },
|
|
157
|
+
team: { id: "team-1" },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await handlePlannerTurn(ctx, session, {
|
|
161
|
+
issueId: "issue-1",
|
|
162
|
+
commentBody: "Build a search API and results page",
|
|
163
|
+
commentorName: "User",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Verify runAgent called for interview
|
|
167
|
+
expect(runAgentMock).toHaveBeenCalledTimes(1);
|
|
168
|
+
|
|
169
|
+
// Verify agent response posted as comment
|
|
170
|
+
expect(linearApi.createComment).toHaveBeenCalledWith("issue-1", "Mock planner response");
|
|
171
|
+
|
|
172
|
+
// Verify turnCount incremented
|
|
173
|
+
state = await readPlanningState(configPath);
|
|
174
|
+
expect(state.sessions["proj-1"].turnCount).toBe(1);
|
|
175
|
+
|
|
176
|
+
// Step 3: Interview turn 2
|
|
177
|
+
vi.clearAllMocks();
|
|
178
|
+
runAgentMock.mockResolvedValue({ success: true, output: "Great, plan updated." });
|
|
179
|
+
|
|
180
|
+
const session2 = { ...session, turnCount: 1 };
|
|
181
|
+
await handlePlannerTurn(ctx, session2, {
|
|
182
|
+
issueId: "issue-1",
|
|
183
|
+
commentBody: "Add autocomplete too",
|
|
184
|
+
commentorName: "User",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(runAgentMock).toHaveBeenCalledTimes(1);
|
|
188
|
+
state = await readPlanningState(configPath);
|
|
189
|
+
expect(state.sessions["proj-1"].turnCount).toBe(2);
|
|
190
|
+
|
|
191
|
+
// Step 4: Finalize — with passing issues in the project
|
|
192
|
+
vi.clearAllMocks();
|
|
193
|
+
linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
|
|
194
|
+
|
|
195
|
+
const session3 = { ...session, turnCount: 2 };
|
|
196
|
+
const onApproved = vi.fn();
|
|
197
|
+
|
|
198
|
+
await handlePlannerTurn(ctx, session3, {
|
|
199
|
+
issueId: "issue-1",
|
|
200
|
+
commentBody: "finalize plan",
|
|
201
|
+
commentorName: "User",
|
|
202
|
+
}, { onApproved });
|
|
203
|
+
|
|
204
|
+
// Verify "Plan Approved" comment
|
|
205
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
206
|
+
"issue-1",
|
|
207
|
+
expect.stringContaining("Plan Approved"),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Verify session ended as approved
|
|
211
|
+
state = await readPlanningState(configPath);
|
|
212
|
+
expect(state.sessions["proj-1"].status).toBe("approved");
|
|
213
|
+
|
|
214
|
+
// Verify onApproved callback fired
|
|
215
|
+
expect(onApproved).toHaveBeenCalledWith("proj-1");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// =========================================================================
|
|
219
|
+
// Test 2: Audit fail → re-plan → pass
|
|
220
|
+
// =========================================================================
|
|
221
|
+
it("audit fail → fix issues → re-finalize → approved", async () => {
|
|
222
|
+
const { ctx, linearApi } = createCtx(configPath);
|
|
223
|
+
const session = createSession(configPath);
|
|
224
|
+
|
|
225
|
+
// Register session so updatePlanningSession/endPlanningSession can find it
|
|
226
|
+
const { registerPlanningSession } = await import("./planning-state.js");
|
|
227
|
+
await registerPlanningSession("proj-1", session, configPath);
|
|
228
|
+
|
|
229
|
+
linearApi.getIssueDetails.mockResolvedValue({
|
|
230
|
+
id: "issue-1",
|
|
231
|
+
identifier: "PROJ-1",
|
|
232
|
+
title: "Root Issue",
|
|
233
|
+
comments: { nodes: [] },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// First finalize — with issues that fail audit (missing descriptions/estimates)
|
|
237
|
+
linearApi.getProjectIssues.mockResolvedValue([
|
|
238
|
+
makeProjectIssue("PROJ-2", {
|
|
239
|
+
title: "Bad issue",
|
|
240
|
+
description: "short", // <50 chars → audit fails
|
|
241
|
+
}),
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
await runPlanAudit(ctx, session);
|
|
245
|
+
|
|
246
|
+
// Verify "Plan Audit Failed" comment
|
|
247
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
248
|
+
"issue-1",
|
|
249
|
+
expect.stringContaining("Plan Audit Failed"),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Session should still be interviewing (NOT approved)
|
|
253
|
+
let state = await readPlanningState(configPath);
|
|
254
|
+
expect(state.sessions["proj-1"].status).toBe("interviewing");
|
|
255
|
+
|
|
256
|
+
// Second finalize — with proper issues
|
|
257
|
+
vi.clearAllMocks();
|
|
258
|
+
linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
|
|
259
|
+
|
|
260
|
+
const onApproved = vi.fn();
|
|
261
|
+
await runPlanAudit(ctx, session, { onApproved });
|
|
262
|
+
|
|
263
|
+
// Now should be approved
|
|
264
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
265
|
+
"issue-1",
|
|
266
|
+
expect.stringContaining("Plan Approved"),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
state = await readPlanningState(configPath);
|
|
270
|
+
expect(state.sessions["proj-1"].status).toBe("approved");
|
|
271
|
+
expect(onApproved).toHaveBeenCalledWith("proj-1");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// =========================================================================
|
|
275
|
+
// Test 3: Abandon
|
|
276
|
+
// =========================================================================
|
|
277
|
+
it("abandon: cancel planning ends session", async () => {
|
|
278
|
+
const { ctx, linearApi } = createCtx(configPath);
|
|
279
|
+
const session = createSession(configPath);
|
|
280
|
+
|
|
281
|
+
// Register session so endPlanningSession can find it
|
|
282
|
+
const { registerPlanningSession } = await import("./planning-state.js");
|
|
283
|
+
await registerPlanningSession("proj-1", session, configPath);
|
|
284
|
+
|
|
285
|
+
await handlePlannerTurn(ctx, session, {
|
|
286
|
+
issueId: "issue-1",
|
|
287
|
+
commentBody: "cancel planning",
|
|
288
|
+
commentorName: "User",
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Verify abandonment comment
|
|
292
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
293
|
+
"issue-1",
|
|
294
|
+
expect.stringContaining("Planning mode ended"),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Session ended as abandoned
|
|
298
|
+
const state = await readPlanningState(configPath);
|
|
299
|
+
expect(state.sessions["proj-1"].status).toBe("abandoned");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// =========================================================================
|
|
303
|
+
// Test 4: onApproved fires
|
|
304
|
+
// =========================================================================
|
|
305
|
+
it("onApproved callback fires with projectId on approval", async () => {
|
|
306
|
+
const { ctx, linearApi } = createCtx(configPath);
|
|
307
|
+
const session = createSession(configPath);
|
|
308
|
+
|
|
309
|
+
// Register session so endPlanningSession can update it
|
|
310
|
+
const { registerPlanningSession } = await import("./planning-state.js");
|
|
311
|
+
await registerPlanningSession("proj-1", session, configPath);
|
|
312
|
+
|
|
313
|
+
linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
|
|
314
|
+
|
|
315
|
+
const onApproved = vi.fn();
|
|
316
|
+
await runPlanAudit(ctx, session, { onApproved });
|
|
317
|
+
|
|
318
|
+
expect(onApproved).toHaveBeenCalledTimes(1);
|
|
319
|
+
expect(onApproved).toHaveBeenCalledWith("proj-1");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// =========================================================================
|
|
323
|
+
// Test 5: DAG cascade — full chain
|
|
324
|
+
// =========================================================================
|
|
325
|
+
it("DAG cascade: complete issues in sequence, project completes", async () => {
|
|
326
|
+
const dagConfigPath = tmpStatePath("claw-e2e-dag-");
|
|
327
|
+
const notifyCalls: Array<[string, unknown]> = [];
|
|
328
|
+
const hookCtx = {
|
|
329
|
+
api: {
|
|
330
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
331
|
+
} as any,
|
|
332
|
+
linearApi: {} as any,
|
|
333
|
+
notify: vi.fn(async (kind: string, payload: unknown) => {
|
|
334
|
+
notifyCalls.push([kind, payload]);
|
|
335
|
+
}),
|
|
336
|
+
pluginConfig: {},
|
|
337
|
+
configPath: dagConfigPath,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Set up 3-issue project: A → B → C
|
|
341
|
+
const projectDispatch: ProjectDispatchState = {
|
|
342
|
+
projectId: "proj-1",
|
|
343
|
+
projectName: "Test Project",
|
|
344
|
+
rootIdentifier: "PROJ-1",
|
|
345
|
+
status: "dispatching",
|
|
346
|
+
startedAt: new Date().toISOString(),
|
|
347
|
+
maxConcurrent: 3,
|
|
348
|
+
issues: {
|
|
349
|
+
"PROJ-A": {
|
|
350
|
+
identifier: "PROJ-A",
|
|
351
|
+
issueId: "id-a",
|
|
352
|
+
dependsOn: [],
|
|
353
|
+
unblocks: ["PROJ-B"],
|
|
354
|
+
dispatchStatus: "dispatched",
|
|
355
|
+
},
|
|
356
|
+
"PROJ-B": {
|
|
357
|
+
identifier: "PROJ-B",
|
|
358
|
+
issueId: "id-b",
|
|
359
|
+
dependsOn: ["PROJ-A"],
|
|
360
|
+
unblocks: ["PROJ-C"],
|
|
361
|
+
dispatchStatus: "pending",
|
|
362
|
+
},
|
|
363
|
+
"PROJ-C": {
|
|
364
|
+
identifier: "PROJ-C",
|
|
365
|
+
issueId: "id-c",
|
|
366
|
+
dependsOn: ["PROJ-B"],
|
|
367
|
+
unblocks: [],
|
|
368
|
+
dispatchStatus: "pending",
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
await writeProjectDispatch(projectDispatch, dagConfigPath);
|
|
373
|
+
|
|
374
|
+
// Complete A → B becomes ready
|
|
375
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-A");
|
|
376
|
+
|
|
377
|
+
let state = await readProjectDispatch("proj-1", dagConfigPath);
|
|
378
|
+
expect(state!.issues["PROJ-A"].dispatchStatus).toBe("done");
|
|
379
|
+
expect(state!.issues["PROJ-B"].dispatchStatus).toBe("dispatched");
|
|
380
|
+
expect(state!.issues["PROJ-C"].dispatchStatus).toBe("pending");
|
|
381
|
+
expect(state!.status).toBe("dispatching");
|
|
382
|
+
|
|
383
|
+
// Complete B → C becomes ready
|
|
384
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-B");
|
|
385
|
+
|
|
386
|
+
state = await readProjectDispatch("proj-1", dagConfigPath);
|
|
387
|
+
expect(state!.issues["PROJ-B"].dispatchStatus).toBe("done");
|
|
388
|
+
expect(state!.issues["PROJ-C"].dispatchStatus).toBe("dispatched");
|
|
389
|
+
expect(state!.status).toBe("dispatching");
|
|
390
|
+
|
|
391
|
+
// Complete C → project complete
|
|
392
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-C");
|
|
393
|
+
|
|
394
|
+
state = await readProjectDispatch("proj-1", dagConfigPath);
|
|
395
|
+
expect(state!.issues["PROJ-C"].dispatchStatus).toBe("done");
|
|
396
|
+
expect(state!.status).toBe("completed");
|
|
397
|
+
|
|
398
|
+
// Verify notifications
|
|
399
|
+
const progressNotifications = notifyCalls.filter(([k]) => k === "project_progress");
|
|
400
|
+
const completeNotifications = notifyCalls.filter(([k]) => k === "project_complete");
|
|
401
|
+
expect(progressNotifications.length).toBe(2); // after A, after B
|
|
402
|
+
expect(completeNotifications.length).toBe(1); // after C
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// =========================================================================
|
|
406
|
+
// Test 6: DAG stuck
|
|
407
|
+
// =========================================================================
|
|
408
|
+
it("DAG stuck: stuck issue blocks dependent, project stuck", async () => {
|
|
409
|
+
const dagConfigPath = tmpStatePath("claw-e2e-dag-stuck-");
|
|
410
|
+
const hookCtx = {
|
|
411
|
+
api: {
|
|
412
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
413
|
+
} as any,
|
|
414
|
+
linearApi: {} as any,
|
|
415
|
+
notify: vi.fn().mockResolvedValue(undefined),
|
|
416
|
+
pluginConfig: {},
|
|
417
|
+
configPath: dagConfigPath,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// 2-issue project: A → B
|
|
421
|
+
const projectDispatch: ProjectDispatchState = {
|
|
422
|
+
projectId: "proj-1",
|
|
423
|
+
projectName: "Test Project",
|
|
424
|
+
rootIdentifier: "PROJ-1",
|
|
425
|
+
status: "dispatching",
|
|
426
|
+
startedAt: new Date().toISOString(),
|
|
427
|
+
maxConcurrent: 3,
|
|
428
|
+
issues: {
|
|
429
|
+
"PROJ-A": {
|
|
430
|
+
identifier: "PROJ-A",
|
|
431
|
+
issueId: "id-a",
|
|
432
|
+
dependsOn: [],
|
|
433
|
+
unblocks: ["PROJ-B"],
|
|
434
|
+
dispatchStatus: "dispatched",
|
|
435
|
+
},
|
|
436
|
+
"PROJ-B": {
|
|
437
|
+
identifier: "PROJ-B",
|
|
438
|
+
issueId: "id-b",
|
|
439
|
+
dependsOn: ["PROJ-A"],
|
|
440
|
+
unblocks: [],
|
|
441
|
+
dispatchStatus: "pending",
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
await writeProjectDispatch(projectDispatch, dagConfigPath);
|
|
446
|
+
|
|
447
|
+
// A gets stuck
|
|
448
|
+
await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-A");
|
|
449
|
+
|
|
450
|
+
const state = await readProjectDispatch("proj-1", dagConfigPath);
|
|
451
|
+
expect(state!.issues["PROJ-A"].dispatchStatus).toBe("stuck");
|
|
452
|
+
expect(state!.issues["PROJ-B"].dispatchStatus).toBe("pending"); // still blocked
|
|
453
|
+
expect(state!.status).toBe("stuck"); // project stuck — no progress possible
|
|
454
|
+
});
|
|
455
|
+
});
|
|
@@ -223,4 +223,73 @@ describe("loadPrompts", () => {
|
|
|
223
223
|
expect(first).not.toBe(second);
|
|
224
224
|
expect(first).toEqual(second);
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
it("merges global overlay with defaults (section-level shallow merge)", () => {
|
|
228
|
+
// Use promptsPath in pluginConfig to load custom YAML from a temp file
|
|
229
|
+
const { writeFileSync, mkdtempSync } = require("node:fs");
|
|
230
|
+
const { join } = require("node:path");
|
|
231
|
+
const { tmpdir } = require("node:os");
|
|
232
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-prompt-"));
|
|
233
|
+
const yamlPath = join(dir, "prompts.yaml");
|
|
234
|
+
writeFileSync(yamlPath, "worker:\n system: custom worker system\n");
|
|
235
|
+
|
|
236
|
+
clearPromptCache();
|
|
237
|
+
const prompts = loadPrompts({ promptsPath: yamlPath });
|
|
238
|
+
// Worker system should be overridden
|
|
239
|
+
expect(prompts.worker.system).toBe("custom worker system");
|
|
240
|
+
// Worker task should still be default
|
|
241
|
+
expect(prompts.worker.task).toContain("{{identifier}}");
|
|
242
|
+
// Audit should be completely default
|
|
243
|
+
expect(prompts.audit.system).toContain("auditor");
|
|
244
|
+
clearPromptCache();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("merges per-project overlay on top of global (three layers)", () => {
|
|
248
|
+
const { writeFileSync, mkdtempSync, mkdirSync } = require("node:fs");
|
|
249
|
+
const { join } = require("node:path");
|
|
250
|
+
const { tmpdir } = require("node:os");
|
|
251
|
+
|
|
252
|
+
// Layer 2: global override via promptsPath
|
|
253
|
+
const globalDir = mkdtempSync(join(tmpdir(), "claw-prompt-global-"));
|
|
254
|
+
const globalYaml = join(globalDir, "prompts.yaml");
|
|
255
|
+
writeFileSync(globalYaml, "worker:\n system: global system\n");
|
|
256
|
+
|
|
257
|
+
// Layer 3: per-project override in worktree/.claw/prompts.yaml
|
|
258
|
+
const worktreeDir = mkdtempSync(join(tmpdir(), "claw-prompt-wt-"));
|
|
259
|
+
mkdirSync(join(worktreeDir, ".claw"), { recursive: true });
|
|
260
|
+
writeFileSync(join(worktreeDir, ".claw", "prompts.yaml"), "audit:\n system: project auditor\n");
|
|
261
|
+
|
|
262
|
+
clearPromptCache();
|
|
263
|
+
const prompts = loadPrompts({ promptsPath: globalYaml }, worktreeDir);
|
|
264
|
+
// Layer 2: global override
|
|
265
|
+
expect(prompts.worker.system).toBe("global system");
|
|
266
|
+
// Layer 3: per-project override
|
|
267
|
+
expect(prompts.audit.system).toBe("project auditor");
|
|
268
|
+
// Layer 1: defaults retained where not overridden
|
|
269
|
+
expect(prompts.rework.addendum).toContain("PREVIOUS AUDIT FAILED");
|
|
270
|
+
clearPromptCache();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("clearPromptCache clears both global and project caches", () => {
|
|
274
|
+
const { writeFileSync, mkdtempSync, mkdirSync } = require("node:fs");
|
|
275
|
+
const { join } = require("node:path");
|
|
276
|
+
const { tmpdir } = require("node:os");
|
|
277
|
+
|
|
278
|
+
// Per-project YAML only (no global — uses defaults for global)
|
|
279
|
+
const worktreeDir = mkdtempSync(join(tmpdir(), "claw-prompt-cache-"));
|
|
280
|
+
mkdirSync(join(worktreeDir, ".claw"), { recursive: true });
|
|
281
|
+
writeFileSync(join(worktreeDir, ".claw", "prompts.yaml"), "worker:\n system: cached project\n");
|
|
282
|
+
|
|
283
|
+
clearPromptCache();
|
|
284
|
+
const first = loadPrompts(undefined, worktreeDir);
|
|
285
|
+
expect(first.worker.system).toBe("cached project");
|
|
286
|
+
// Same ref from cache
|
|
287
|
+
expect(loadPrompts(undefined, worktreeDir)).toBe(first);
|
|
288
|
+
// Clear both caches
|
|
289
|
+
clearPromptCache();
|
|
290
|
+
const second = loadPrompts(undefined, worktreeDir);
|
|
291
|
+
expect(second).not.toBe(first);
|
|
292
|
+
expect(second.worker.system).toBe("cached project");
|
|
293
|
+
clearPromptCache();
|
|
294
|
+
});
|
|
226
295
|
});
|
package/src/pipeline/pipeline.ts
CHANGED
|
@@ -60,15 +60,15 @@ interface PromptTemplates {
|
|
|
60
60
|
|
|
61
61
|
const DEFAULT_PROMPTS: PromptTemplates = {
|
|
62
62
|
worker: {
|
|
63
|
-
system: "You are implementing a Linear issue.
|
|
64
|
-
task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}",
|
|
63
|
+
system: "You are a coding worker implementing a Linear issue. Your ONLY job is to write code and return a text summary. Do NOT attempt to update, close, comment on, or modify the Linear issue. Do NOT mark the issue as Done.",
|
|
64
|
+
task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nImplement the solution, run tests, commit your work, and return a text summary.",
|
|
65
65
|
},
|
|
66
66
|
audit: {
|
|
67
67
|
system: "You are an independent auditor. The Linear issue body is the SOURCE OF TRUTH. Worker comments are secondary evidence.",
|
|
68
68
|
task: 'Audit issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nReturn JSON verdict: {"pass": true/false, "criteria": [...], "gaps": [...], "testResults": "..."}',
|
|
69
69
|
},
|
|
70
70
|
rework: {
|
|
71
|
-
addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues.",
|
|
71
|
+
addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues. Preserve correct code from prior attempts.",
|
|
72
72
|
},
|
|
73
73
|
};
|
|
74
74
|
|
|
@@ -88,12 +88,11 @@ function mergePromptLayers(base: PromptTemplates, overlay: Partial<PromptTemplat
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* Load
|
|
92
|
-
*
|
|
91
|
+
* Load and parse the raw prompts YAML file (global promptsPath or sidecar).
|
|
92
|
+
* Returns the parsed object, or null if no file found.
|
|
93
|
+
* Shared by both pipeline and planner prompt loaders.
|
|
93
94
|
*/
|
|
94
|
-
function
|
|
95
|
-
if (_cachedGlobalPrompts) return _cachedGlobalPrompts;
|
|
96
|
-
|
|
95
|
+
export function loadRawPromptYaml(pluginConfig?: Record<string, unknown>): Record<string, any> | null {
|
|
97
96
|
try {
|
|
98
97
|
const customPath = pluginConfig?.promptsPath as string | undefined;
|
|
99
98
|
let raw: string;
|
|
@@ -108,9 +107,23 @@ function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTempla
|
|
|
108
107
|
raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
|
|
109
108
|
}
|
|
110
109
|
|
|
111
|
-
|
|
112
|
-
_cachedGlobalPrompts = mergePromptLayers(DEFAULT_PROMPTS, parsed);
|
|
110
|
+
return parseYaml(raw) as Record<string, any>;
|
|
113
111
|
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load global prompts (layers 1+2: hardcoded defaults + global promptsPath override).
|
|
118
|
+
* Cached after first load.
|
|
119
|
+
*/
|
|
120
|
+
function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
|
|
121
|
+
if (_cachedGlobalPrompts) return _cachedGlobalPrompts;
|
|
122
|
+
|
|
123
|
+
const parsed = loadRawPromptYaml(pluginConfig);
|
|
124
|
+
if (parsed) {
|
|
125
|
+
_cachedGlobalPrompts = mergePromptLayers(DEFAULT_PROMPTS, parsed as Partial<PromptTemplates>);
|
|
126
|
+
} else {
|
|
114
127
|
_cachedGlobalPrompts = DEFAULT_PROMPTS;
|
|
115
128
|
}
|
|
116
129
|
|
|
@@ -188,7 +201,7 @@ export function buildWorkerTask(
|
|
|
188
201
|
worktreePath,
|
|
189
202
|
tier: "",
|
|
190
203
|
attempt: String(opts?.attempt ?? 0),
|
|
191
|
-
gaps: opts?.gaps?.join("\n- ")
|
|
204
|
+
gaps: opts?.gaps?.length ? "- " + opts.gaps.join("\n- ") : "",
|
|
192
205
|
};
|
|
193
206
|
|
|
194
207
|
let task = renderTemplate(prompts.worker.task, vars);
|
|
@@ -332,7 +345,12 @@ export async function triggerAudit(
|
|
|
332
345
|
};
|
|
333
346
|
|
|
334
347
|
// Build audit prompt from YAML templates
|
|
335
|
-
|
|
348
|
+
// For multi-repo dispatches, render worktreePath as a list of repo→path mappings
|
|
349
|
+
const effectiveAuditPath = dispatch.worktrees
|
|
350
|
+
? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
|
|
351
|
+
: dispatch.worktreePath;
|
|
352
|
+
|
|
353
|
+
const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig);
|
|
336
354
|
|
|
337
355
|
// Set Linear label
|
|
338
356
|
await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
|
|
@@ -444,6 +462,11 @@ export async function processVerdict(
|
|
|
444
462
|
const verdict = parseVerdict(auditOutput);
|
|
445
463
|
if (!verdict) {
|
|
446
464
|
api.logger.warn(`${TAG} could not parse audit verdict from output (${auditOutput.length} chars)`);
|
|
465
|
+
// Post comment so user knows what happened
|
|
466
|
+
await linearApi.createComment(
|
|
467
|
+
dispatch.issueId,
|
|
468
|
+
`## Audit Inconclusive\n\nThe auditor's response couldn't be parsed as a verdict. **Retrying automatically** — this usually resolves itself.\n\n**If it keeps happening:** \`openclaw openclaw-linear prompts validate\`\n\n**Status:** Retrying audit now. No action needed.`,
|
|
469
|
+
).catch((err) => api.logger.error(`${TAG} failed to post inconclusive comment: ${err}`));
|
|
447
470
|
// Treat unparseable verdict as failure
|
|
448
471
|
await handleAuditFail(hookCtx, dispatch, {
|
|
449
472
|
pass: false,
|
|
@@ -527,7 +550,7 @@ async function handleAuditPass(
|
|
|
527
550
|
const summaryExcerpt = summary ? `\n\n**Summary:**\n${summary.slice(0, 2000)}` : "";
|
|
528
551
|
await linearApi.createComment(
|
|
529
552
|
dispatch.issueId,
|
|
530
|
-
`##
|
|
553
|
+
`## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw/\`\n- Create a PR from the worktree branch if one wasn't opened automatically`,
|
|
531
554
|
).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
|
|
532
555
|
|
|
533
556
|
api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
|
|
@@ -603,7 +626,7 @@ async function handleAuditFail(
|
|
|
603
626
|
const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
|
|
604
627
|
await linearApi.createComment(
|
|
605
628
|
dispatch.issueId,
|
|
606
|
-
`##
|
|
629
|
+
`## Needs Your Help\n\nAll ${nextAttempt} attempts failed. The agent couldn't resolve these issues on its own.\n\n**What went wrong:**\n${gapsList}\n\n**Test results:** ${verdict.testResults || "N/A"}\n\n---\n\n**What you can do:**\n1. **Clarify requirements** — update the issue body with more detail, then re-assign to try again\n2. **Fix it manually** — the agent's work is in the worktree: \`cd ${dispatch.worktreePath}\`\n3. **Force retry** — \`/dispatch retry ${dispatch.issueIdentifier}\`\n4. **View logs** — worker output: \`.claw/worker-*.md\`, audit verdicts: \`.claw/audit-*.json\``,
|
|
607
630
|
).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
|
|
608
631
|
|
|
609
632
|
api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
|
|
@@ -647,7 +670,7 @@ async function handleAuditFail(
|
|
|
647
670
|
const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
|
|
648
671
|
await linearApi.createComment(
|
|
649
672
|
dispatch.issueId,
|
|
650
|
-
`##
|
|
673
|
+
`## Needs More Work\n\nThe audit found gaps. **Retrying now** — the worker gets the feedback below as context.\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}** — ${maxAttempts + 1 - nextAttempt > 0 ? `${maxAttempts + 1 - nextAttempt} more ${maxAttempts + 1 - nextAttempt === 1 ? "retry" : "retries"} if this fails too` : "this is the last attempt"}.\n\n**What needs fixing:**\n${gapsList}\n\n**Test results:** ${verdict.testResults || "N/A"}\n\n**Status:** Worker is restarting with the gaps above as context. No action needed unless all retries fail.`,
|
|
651
674
|
).catch((err) => api.logger.error(`${TAG} failed to post rework comment: ${err}`));
|
|
652
675
|
|
|
653
676
|
api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
|
|
@@ -716,7 +739,12 @@ export async function spawnWorker(
|
|
|
716
739
|
};
|
|
717
740
|
|
|
718
741
|
// Build worker prompt from YAML templates
|
|
719
|
-
|
|
742
|
+
// For multi-repo dispatches, render worktreePath as a list of repo→path mappings
|
|
743
|
+
const effectiveWorkerPath = dispatch.worktrees
|
|
744
|
+
? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
|
|
745
|
+
: dispatch.worktreePath;
|
|
746
|
+
|
|
747
|
+
const workerPrompt = buildWorkerTask(issue, effectiveWorkerPath, {
|
|
720
748
|
attempt: dispatch.attempt,
|
|
721
749
|
gaps: opts?.gaps,
|
|
722
750
|
pluginConfig,
|
|
@@ -798,8 +826,9 @@ export async function spawnWorker(
|
|
|
798
826
|
|
|
799
827
|
await linearApi.createComment(
|
|
800
828
|
dispatch.issueId,
|
|
801
|
-
`##
|
|
802
|
-
|
|
829
|
+
`## Agent Timed Out\n\nThe agent stopped responding for over ${thresholdSec}s. It was automatically restarted, but the retry also timed out.\n\n` +
|
|
830
|
+
`**What you can do:**\n1. **Try again** — re-assign this issue or \`/dispatch retry ${dispatch.issueIdentifier}\`\n2. **Break it down** — if it keeps timing out, split into smaller issues\n3. **Increase timeout** — set \`inactivitySec\` higher in your agent profile\n\n` +
|
|
831
|
+
`**Logs:** \`${dispatch.worktreePath}/.claw/log.jsonl\` (look for \`"phase": "watchdog"\`)\n\n**Current status:** Stuck — waiting for you.`,
|
|
803
832
|
).catch(() => {});
|
|
804
833
|
|
|
805
834
|
await hookCtx.notify("watchdog_kill", {
|