@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,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E dispatch pipeline tests.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the real pipeline chain: spawnWorker → triggerAudit → processVerdict
|
|
5
|
+
* → handleAuditPass/handleAuditFail, with file-backed dispatch-state, real
|
|
6
|
+
* artifact writes, real notification formatting, and DAG cascade.
|
|
7
|
+
*
|
|
8
|
+
* Only external boundaries are mocked: runAgent, LinearAgentApi, codex-worktree.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, vi, type Mock } from "vitest";
|
|
11
|
+
import { mkdtempSync, readFileSync, existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Mocks — external boundaries only (vi.hoisted + vi.mock)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const { runAgentMock } = vi.hoisted(() => ({
|
|
20
|
+
runAgentMock: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("../agent/agent.js", () => ({
|
|
24
|
+
runAgent: runAgentMock,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("../agent/watchdog.js", () => ({
|
|
28
|
+
resolveWatchdogConfig: vi.fn(() => ({
|
|
29
|
+
inactivityMs: 120_000,
|
|
30
|
+
maxTotalMs: 7_200_000,
|
|
31
|
+
toolTimeoutMs: 600_000,
|
|
32
|
+
})),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("../infra/codex-worktree.js", () => ({
|
|
36
|
+
createWorktree: vi.fn(),
|
|
37
|
+
prepareWorkspace: vi.fn(),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock("../api/linear-api.js", () => ({
|
|
41
|
+
LinearAgentApi: class {},
|
|
42
|
+
resolveLinearToken: vi.fn().mockReturnValue({ accessToken: "test-token", source: "env" }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("../infra/observability.js", () => ({
|
|
46
|
+
emitDiagnostic: vi.fn(),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
vi.mock("openclaw/plugin-sdk", () => ({}));
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Imports (AFTER mocks)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
import { spawnWorker, clearPromptCache, type HookContext } from "./pipeline.js";
|
|
56
|
+
import { registerDispatch, readDispatchState, type ActiveDispatch } from "./dispatch-state.js";
|
|
57
|
+
import { writeProjectDispatch, readProjectDispatch, type ProjectDispatchState, type ProjectIssueStatus } from "./dag-dispatch.js";
|
|
58
|
+
import { createMockLinearApi, type MockLinearApi } from "../__test__/helpers.js";
|
|
59
|
+
import { makeIssueDetails } from "../__test__/fixtures/linear-responses.js";
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function tmpDir(): string {
|
|
66
|
+
return mkdtempSync(join(tmpdir(), "claw-e2e-"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeHookCtx(opts?: {
|
|
70
|
+
configPath?: string;
|
|
71
|
+
linearApi?: MockLinearApi;
|
|
72
|
+
pluginConfig?: Record<string, unknown>;
|
|
73
|
+
notifyFn?: Mock;
|
|
74
|
+
}): HookContext & { mockLinearApi: MockLinearApi; notifyCalls: Array<[string, unknown]> } {
|
|
75
|
+
const configPath = opts?.configPath ?? join(tmpDir(), "state.json");
|
|
76
|
+
const mockLinearApi = opts?.linearApi ?? createMockLinearApi();
|
|
77
|
+
const notifyCalls: Array<[string, unknown]> = [];
|
|
78
|
+
const notifyFn = opts?.notifyFn ?? vi.fn(async (kind: string, payload: unknown) => {
|
|
79
|
+
notifyCalls.push([kind, payload]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
api: {
|
|
84
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
85
|
+
pluginConfig: opts?.pluginConfig ?? {},
|
|
86
|
+
runtime: {},
|
|
87
|
+
} as any,
|
|
88
|
+
linearApi: mockLinearApi as any,
|
|
89
|
+
notify: notifyFn,
|
|
90
|
+
pluginConfig: opts?.pluginConfig ?? {},
|
|
91
|
+
configPath,
|
|
92
|
+
mockLinearApi,
|
|
93
|
+
notifyCalls,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function makeDispatch(worktreePath: string, overrides?: Partial<ActiveDispatch>): ActiveDispatch {
|
|
98
|
+
return {
|
|
99
|
+
issueId: "issue-1",
|
|
100
|
+
issueIdentifier: "ENG-100",
|
|
101
|
+
worktreePath,
|
|
102
|
+
branch: "codex/ENG-100",
|
|
103
|
+
tier: "junior" as const,
|
|
104
|
+
model: "test-model",
|
|
105
|
+
status: "dispatched",
|
|
106
|
+
dispatchedAt: new Date().toISOString(),
|
|
107
|
+
attempt: 0,
|
|
108
|
+
...overrides,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function passVerdict(criteria: string[] = ["tests pass"]) {
|
|
113
|
+
return JSON.stringify({ pass: true, criteria, gaps: [], testResults: "ok" });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function failVerdict(gaps: string[] = ["missing tests"], criteria: string[] = []) {
|
|
117
|
+
return JSON.stringify({ pass: false, criteria, gaps, testResults: "failed" });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Tests
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe("E2E dispatch pipeline", () => {
|
|
125
|
+
let worktree: string;
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
vi.clearAllMocks();
|
|
129
|
+
clearPromptCache();
|
|
130
|
+
worktree = tmpDir();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// =========================================================================
|
|
134
|
+
// Test 1: Happy path — dispatch → working → auditing → done
|
|
135
|
+
// =========================================================================
|
|
136
|
+
it("happy path: dispatch → audit pass → done", async () => {
|
|
137
|
+
const hookCtx = makeHookCtx();
|
|
138
|
+
const dispatch = makeDispatch(worktree);
|
|
139
|
+
|
|
140
|
+
// Register the dispatch in state
|
|
141
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
|
|
142
|
+
|
|
143
|
+
// Mock linearApi.getIssueDetails for pipeline
|
|
144
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
145
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// runAgent: worker returns text, then audit returns pass verdict
|
|
149
|
+
let callCount = 0;
|
|
150
|
+
runAgentMock.mockImplementation(async () => {
|
|
151
|
+
callCount++;
|
|
152
|
+
if (callCount === 1) {
|
|
153
|
+
// Worker
|
|
154
|
+
return { success: true, output: "Implemented the fix and added tests.", watchdogKilled: false };
|
|
155
|
+
}
|
|
156
|
+
// Audit
|
|
157
|
+
return { success: true, output: passVerdict(), watchdogKilled: false };
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await spawnWorker(hookCtx, dispatch);
|
|
161
|
+
|
|
162
|
+
// Verify state transitions: dispatched → working → auditing → done → completed
|
|
163
|
+
const state = await readDispatchState(hookCtx.configPath);
|
|
164
|
+
expect(state.dispatches.active["ENG-100"]).toBeUndefined(); // moved to completed
|
|
165
|
+
expect(state.dispatches.completed["ENG-100"]).toBeDefined();
|
|
166
|
+
expect(state.dispatches.completed["ENG-100"].status).toBe("done");
|
|
167
|
+
|
|
168
|
+
// Verify notify events
|
|
169
|
+
const notifyKinds = hookCtx.notifyCalls.map(([kind]) => kind);
|
|
170
|
+
expect(notifyKinds).toContain("working");
|
|
171
|
+
expect(notifyKinds).toContain("auditing");
|
|
172
|
+
expect(notifyKinds).toContain("audit_pass");
|
|
173
|
+
|
|
174
|
+
// Verify Linear comment was posted for audit pass
|
|
175
|
+
expect(hookCtx.mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
176
|
+
"issue-1",
|
|
177
|
+
expect.stringContaining("Done"),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Verify artifacts exist
|
|
181
|
+
const clawDir = join(worktree, ".claw");
|
|
182
|
+
expect(existsSync(join(clawDir, "worker-0.md"))).toBe(true);
|
|
183
|
+
expect(existsSync(join(clawDir, "audit-0.json"))).toBe(true);
|
|
184
|
+
expect(existsSync(join(clawDir, "log.jsonl"))).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// =========================================================================
|
|
188
|
+
// Test 2: Rework — audit fail → retry → pass
|
|
189
|
+
// =========================================================================
|
|
190
|
+
it("rework: audit fail → retry → pass", async () => {
|
|
191
|
+
const hookCtx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 2 } });
|
|
192
|
+
const dispatch = makeDispatch(worktree);
|
|
193
|
+
|
|
194
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
|
|
195
|
+
|
|
196
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
197
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Call sequence: worker1, audit1(fail), then pipeline transitions to "working"
|
|
201
|
+
// but does NOT re-invoke spawnWorker for rework — it just sets state.
|
|
202
|
+
// So we test the first spawnWorker (fail), then manually call spawnWorker again
|
|
203
|
+
// for the rework flow.
|
|
204
|
+
let callCount = 0;
|
|
205
|
+
runAgentMock.mockImplementation(async () => {
|
|
206
|
+
callCount++;
|
|
207
|
+
if (callCount === 1) return { success: true, output: "First attempt done.", watchdogKilled: false };
|
|
208
|
+
if (callCount === 2) return { success: true, output: failVerdict(["missing tests"]), watchdogKilled: false };
|
|
209
|
+
if (callCount === 3) return { success: true, output: "Reworked: added tests.", watchdogKilled: false };
|
|
210
|
+
return { success: true, output: passVerdict(), watchdogKilled: false };
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// First run — should fail audit
|
|
214
|
+
await spawnWorker(hookCtx, dispatch);
|
|
215
|
+
|
|
216
|
+
// After fail, state should be "working" with attempt=1
|
|
217
|
+
let state = await readDispatchState(hookCtx.configPath);
|
|
218
|
+
const reworkDispatch = state.dispatches.active["ENG-100"];
|
|
219
|
+
expect(reworkDispatch).toBeDefined();
|
|
220
|
+
expect(reworkDispatch.status).toBe("working");
|
|
221
|
+
expect(reworkDispatch.attempt).toBe(1);
|
|
222
|
+
|
|
223
|
+
// Verify audit_fail notification was sent
|
|
224
|
+
const failNotify = hookCtx.notifyCalls.find(([k]) => k === "audit_fail");
|
|
225
|
+
expect(failNotify).toBeDefined();
|
|
226
|
+
|
|
227
|
+
// Rework comment posted
|
|
228
|
+
expect(hookCtx.mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
229
|
+
"issue-1",
|
|
230
|
+
expect.stringContaining("Needs More Work"),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Second run (rework) — dispatch is already in "working" state
|
|
234
|
+
await spawnWorker(hookCtx, reworkDispatch, { gaps: ["missing tests"] });
|
|
235
|
+
|
|
236
|
+
// Should now be completed
|
|
237
|
+
state = await readDispatchState(hookCtx.configPath);
|
|
238
|
+
expect(state.dispatches.active["ENG-100"]).toBeUndefined();
|
|
239
|
+
expect(state.dispatches.completed["ENG-100"]).toBeDefined();
|
|
240
|
+
expect(state.dispatches.completed["ENG-100"].status).toBe("done");
|
|
241
|
+
|
|
242
|
+
const passNotify = hookCtx.notifyCalls.find(([k]) => k === "audit_pass");
|
|
243
|
+
expect(passNotify).toBeDefined();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// =========================================================================
|
|
247
|
+
// Test 3: Stuck — max rework exceeded
|
|
248
|
+
// =========================================================================
|
|
249
|
+
it("stuck: max rework exceeded → escalation", async () => {
|
|
250
|
+
const hookCtx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 0 } });
|
|
251
|
+
const dispatch = makeDispatch(worktree);
|
|
252
|
+
|
|
253
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
|
|
254
|
+
|
|
255
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
256
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// Worker succeeds, audit always fails
|
|
260
|
+
let callCount = 0;
|
|
261
|
+
runAgentMock.mockImplementation(async () => {
|
|
262
|
+
callCount++;
|
|
263
|
+
if (callCount % 2 === 1) return { success: true, output: "Attempted fix.", watchdogKilled: false };
|
|
264
|
+
return { success: true, output: failVerdict(["still broken"]), watchdogKilled: false };
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await spawnWorker(hookCtx, dispatch);
|
|
268
|
+
|
|
269
|
+
// maxReworkAttempts=0, so first failure should escalate (attempt 0 fails → nextAttempt=1 > max=0)
|
|
270
|
+
const state = await readDispatchState(hookCtx.configPath);
|
|
271
|
+
const stuckDispatch = state.dispatches.active["ENG-100"];
|
|
272
|
+
expect(stuckDispatch).toBeDefined();
|
|
273
|
+
expect(stuckDispatch.status).toBe("stuck");
|
|
274
|
+
expect(stuckDispatch.stuckReason).toContain("audit_failed");
|
|
275
|
+
|
|
276
|
+
// Escalation notification
|
|
277
|
+
const escalation = hookCtx.notifyCalls.find(([k]) => k === "escalation");
|
|
278
|
+
expect(escalation).toBeDefined();
|
|
279
|
+
|
|
280
|
+
// Escalation comment
|
|
281
|
+
expect(hookCtx.mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
282
|
+
"issue-1",
|
|
283
|
+
expect.stringContaining("Needs Your Help"),
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// =========================================================================
|
|
288
|
+
// Test 4: Watchdog kill
|
|
289
|
+
// =========================================================================
|
|
290
|
+
it("watchdog kill → stuck", async () => {
|
|
291
|
+
const hookCtx = makeHookCtx();
|
|
292
|
+
const dispatch = makeDispatch(worktree);
|
|
293
|
+
|
|
294
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
|
|
295
|
+
|
|
296
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
297
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// runAgent returns watchdog killed
|
|
301
|
+
runAgentMock.mockResolvedValue({
|
|
302
|
+
success: false,
|
|
303
|
+
output: "",
|
|
304
|
+
watchdogKilled: true,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await spawnWorker(hookCtx, dispatch);
|
|
308
|
+
|
|
309
|
+
// State should be stuck with watchdog reason
|
|
310
|
+
const state = await readDispatchState(hookCtx.configPath);
|
|
311
|
+
const stuckDispatch = state.dispatches.active["ENG-100"];
|
|
312
|
+
expect(stuckDispatch).toBeDefined();
|
|
313
|
+
expect(stuckDispatch.status).toBe("stuck");
|
|
314
|
+
expect(stuckDispatch.stuckReason).toBe("watchdog_kill_2x");
|
|
315
|
+
|
|
316
|
+
// Watchdog kill notification
|
|
317
|
+
const wdNotify = hookCtx.notifyCalls.find(([k]) => k === "watchdog_kill");
|
|
318
|
+
expect(wdNotify).toBeDefined();
|
|
319
|
+
|
|
320
|
+
// Watchdog comment
|
|
321
|
+
expect(hookCtx.mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
322
|
+
"issue-1",
|
|
323
|
+
expect.stringContaining("Agent Timed Out"),
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// =========================================================================
|
|
328
|
+
// Test 5: DAG cascade — pass triggers next issue
|
|
329
|
+
// =========================================================================
|
|
330
|
+
it("DAG cascade: audit pass triggers next issue dispatch", async () => {
|
|
331
|
+
const configDir = tmpDir();
|
|
332
|
+
const configPath = join(configDir, "state.json");
|
|
333
|
+
const hookCtx = makeHookCtx({ configPath });
|
|
334
|
+
const dispatch = makeDispatch(worktree, { project: "proj-1" });
|
|
335
|
+
|
|
336
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, configPath);
|
|
337
|
+
|
|
338
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
339
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// Set up project dispatch state (ENG-100 → ENG-101)
|
|
343
|
+
const projectDispatch: ProjectDispatchState = {
|
|
344
|
+
projectId: "proj-1",
|
|
345
|
+
projectName: "Test Project",
|
|
346
|
+
rootIdentifier: "PROJ-1",
|
|
347
|
+
status: "dispatching",
|
|
348
|
+
startedAt: new Date().toISOString(),
|
|
349
|
+
maxConcurrent: 3,
|
|
350
|
+
issues: {
|
|
351
|
+
"ENG-100": {
|
|
352
|
+
identifier: "ENG-100",
|
|
353
|
+
issueId: "issue-1",
|
|
354
|
+
dependsOn: [],
|
|
355
|
+
unblocks: ["ENG-101"],
|
|
356
|
+
dispatchStatus: "dispatched",
|
|
357
|
+
},
|
|
358
|
+
"ENG-101": {
|
|
359
|
+
identifier: "ENG-101",
|
|
360
|
+
issueId: "issue-2",
|
|
361
|
+
dependsOn: ["ENG-100"],
|
|
362
|
+
unblocks: [],
|
|
363
|
+
dispatchStatus: "pending",
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
await writeProjectDispatch(projectDispatch, configPath);
|
|
368
|
+
|
|
369
|
+
// Worker + audit pass
|
|
370
|
+
let callCount = 0;
|
|
371
|
+
runAgentMock.mockImplementation(async () => {
|
|
372
|
+
callCount++;
|
|
373
|
+
if (callCount === 1) return { success: true, output: "Done.", watchdogKilled: false };
|
|
374
|
+
return { success: true, output: passVerdict(), watchdogKilled: false };
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
await spawnWorker(hookCtx, dispatch);
|
|
378
|
+
|
|
379
|
+
// Wait a tick for the async DAG cascade (void fire-and-forget)
|
|
380
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
381
|
+
|
|
382
|
+
// Read project dispatch state — ENG-100 should be done, ENG-101 should be dispatched
|
|
383
|
+
const updatedProject = await readProjectDispatch("proj-1", configPath);
|
|
384
|
+
expect(updatedProject).not.toBeNull();
|
|
385
|
+
expect(updatedProject!.issues["ENG-100"].dispatchStatus).toBe("done");
|
|
386
|
+
expect(updatedProject!.issues["ENG-101"].dispatchStatus).toBe("dispatched");
|
|
387
|
+
|
|
388
|
+
// Verify project_progress notification
|
|
389
|
+
const progressNotify = hookCtx.notifyCalls.find(([k]) => k === "project_progress");
|
|
390
|
+
expect(progressNotify).toBeDefined();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// =========================================================================
|
|
394
|
+
// Test 6: DAG cascade — stuck propagates
|
|
395
|
+
// =========================================================================
|
|
396
|
+
it("DAG cascade: stuck propagates to project", async () => {
|
|
397
|
+
const configDir = tmpDir();
|
|
398
|
+
const configPath = join(configDir, "state.json");
|
|
399
|
+
const hookCtx = makeHookCtx({ configPath, pluginConfig: { maxReworkAttempts: 0 } });
|
|
400
|
+
const dispatch = makeDispatch(worktree, { project: "proj-1" });
|
|
401
|
+
|
|
402
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, configPath);
|
|
403
|
+
|
|
404
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
405
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Set up project dispatch state (ENG-100 → ENG-101, only 2 issues)
|
|
409
|
+
const projectDispatch: ProjectDispatchState = {
|
|
410
|
+
projectId: "proj-1",
|
|
411
|
+
projectName: "Test Project",
|
|
412
|
+
rootIdentifier: "PROJ-1",
|
|
413
|
+
status: "dispatching",
|
|
414
|
+
startedAt: new Date().toISOString(),
|
|
415
|
+
maxConcurrent: 3,
|
|
416
|
+
issues: {
|
|
417
|
+
"ENG-100": {
|
|
418
|
+
identifier: "ENG-100",
|
|
419
|
+
issueId: "issue-1",
|
|
420
|
+
dependsOn: [],
|
|
421
|
+
unblocks: ["ENG-101"],
|
|
422
|
+
dispatchStatus: "dispatched",
|
|
423
|
+
},
|
|
424
|
+
"ENG-101": {
|
|
425
|
+
identifier: "ENG-101",
|
|
426
|
+
issueId: "issue-2",
|
|
427
|
+
dependsOn: ["ENG-100"],
|
|
428
|
+
unblocks: [],
|
|
429
|
+
dispatchStatus: "pending",
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
await writeProjectDispatch(projectDispatch, configPath);
|
|
434
|
+
|
|
435
|
+
// Worker succeeds, audit fails
|
|
436
|
+
let callCount = 0;
|
|
437
|
+
runAgentMock.mockImplementation(async () => {
|
|
438
|
+
callCount++;
|
|
439
|
+
if (callCount === 1) return { success: true, output: "Attempted.", watchdogKilled: false };
|
|
440
|
+
return { success: true, output: failVerdict(["still broken"]), watchdogKilled: false };
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await spawnWorker(hookCtx, dispatch);
|
|
444
|
+
|
|
445
|
+
// Wait for async DAG cascade
|
|
446
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
447
|
+
|
|
448
|
+
// Project should be stuck since ENG-100 is stuck and ENG-101 depends on it
|
|
449
|
+
const updatedProject = await readProjectDispatch("proj-1", configPath);
|
|
450
|
+
expect(updatedProject).not.toBeNull();
|
|
451
|
+
expect(updatedProject!.issues["ENG-100"].dispatchStatus).toBe("stuck");
|
|
452
|
+
expect(updatedProject!.issues["ENG-101"].dispatchStatus).toBe("pending");
|
|
453
|
+
expect(updatedProject!.status).toBe("stuck");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// =========================================================================
|
|
457
|
+
// Test 7: Artifact integrity
|
|
458
|
+
// =========================================================================
|
|
459
|
+
it("artifact integrity: manifest, worker output, audit verdict, dispatch log", async () => {
|
|
460
|
+
const hookCtx = makeHookCtx();
|
|
461
|
+
const dispatch = makeDispatch(worktree);
|
|
462
|
+
|
|
463
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
|
|
464
|
+
|
|
465
|
+
// Pre-create manifest (as webhook.ts handleDispatch would)
|
|
466
|
+
const { ensureClawDir, writeManifest } = await import("./artifacts.js");
|
|
467
|
+
ensureClawDir(worktree);
|
|
468
|
+
writeManifest(worktree, {
|
|
469
|
+
issueIdentifier: "ENG-100",
|
|
470
|
+
issueId: "issue-1",
|
|
471
|
+
tier: "junior",
|
|
472
|
+
status: "dispatched",
|
|
473
|
+
attempts: 0,
|
|
474
|
+
dispatchedAt: new Date().toISOString(),
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
478
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
let callCount = 0;
|
|
482
|
+
runAgentMock.mockImplementation(async () => {
|
|
483
|
+
callCount++;
|
|
484
|
+
if (callCount === 1) return { success: true, output: "Worker output here.", watchdogKilled: false };
|
|
485
|
+
return { success: true, output: passVerdict(["unit tests", "lint"]), watchdogKilled: false };
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await spawnWorker(hookCtx, dispatch);
|
|
489
|
+
|
|
490
|
+
const clawDir = join(worktree, ".claw");
|
|
491
|
+
|
|
492
|
+
// Manifest (updated by pipeline: status→done, attempts→1)
|
|
493
|
+
const manifest = JSON.parse(readFileSync(join(clawDir, "manifest.json"), "utf8"));
|
|
494
|
+
expect(manifest.status).toBe("done");
|
|
495
|
+
expect(manifest.attempts).toBe(1);
|
|
496
|
+
|
|
497
|
+
// Worker output
|
|
498
|
+
const workerOutput = readFileSync(join(clawDir, "worker-0.md"), "utf8");
|
|
499
|
+
expect(workerOutput).toBe("Worker output here.");
|
|
500
|
+
|
|
501
|
+
// Audit verdict
|
|
502
|
+
const verdict = JSON.parse(readFileSync(join(clawDir, "audit-0.json"), "utf8"));
|
|
503
|
+
expect(verdict.pass).toBe(true);
|
|
504
|
+
expect(verdict.criteria).toContain("unit tests");
|
|
505
|
+
|
|
506
|
+
// Dispatch log (JSONL format)
|
|
507
|
+
const logContent = readFileSync(join(clawDir, "log.jsonl"), "utf8");
|
|
508
|
+
const logLines = logContent.trim().split("\n").map((l) => JSON.parse(l));
|
|
509
|
+
expect(logLines.some((e) => e.phase === "worker")).toBe(true);
|
|
510
|
+
expect(logLines.some((e) => e.phase === "audit")).toBe(true);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// =========================================================================
|
|
514
|
+
// Test 8: Multi-target notify with rich format
|
|
515
|
+
// =========================================================================
|
|
516
|
+
it("multi-target notify: discord + telegram called for lifecycle events", async () => {
|
|
517
|
+
// For this test, we use real createNotifierFromConfig + mock runtime channels
|
|
518
|
+
const { createNotifierFromConfig } = await import("../infra/notify.js");
|
|
519
|
+
|
|
520
|
+
const mockRuntime = {
|
|
521
|
+
channel: {
|
|
522
|
+
discord: { sendMessageDiscord: vi.fn().mockResolvedValue(undefined) },
|
|
523
|
+
telegram: { sendMessageTelegram: vi.fn().mockResolvedValue(undefined) },
|
|
524
|
+
slack: { sendMessageSlack: vi.fn().mockResolvedValue(undefined) },
|
|
525
|
+
signal: { sendMessageSignal: vi.fn().mockResolvedValue(undefined) },
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const pluginConfig = {
|
|
530
|
+
notifications: {
|
|
531
|
+
targets: [
|
|
532
|
+
{ channel: "discord", target: "discord-channel-1" },
|
|
533
|
+
{ channel: "telegram", target: "telegram-chat-1" },
|
|
534
|
+
],
|
|
535
|
+
richFormat: true,
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const notify = createNotifierFromConfig(pluginConfig, mockRuntime as any);
|
|
540
|
+
|
|
541
|
+
const configDir = tmpDir();
|
|
542
|
+
const configPath = join(configDir, "state.json");
|
|
543
|
+
const hookCtx = makeHookCtx({
|
|
544
|
+
configPath,
|
|
545
|
+
pluginConfig,
|
|
546
|
+
});
|
|
547
|
+
// Override notify with the real notifier
|
|
548
|
+
(hookCtx as any).notify = notify;
|
|
549
|
+
|
|
550
|
+
const dispatch = makeDispatch(worktree);
|
|
551
|
+
await registerDispatch(dispatch.issueIdentifier, dispatch, configPath);
|
|
552
|
+
|
|
553
|
+
hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
|
|
554
|
+
makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
let callCount = 0;
|
|
558
|
+
runAgentMock.mockImplementation(async () => {
|
|
559
|
+
callCount++;
|
|
560
|
+
if (callCount === 1) return { success: true, output: "Done.", watchdogKilled: false };
|
|
561
|
+
return { success: true, output: passVerdict(), watchdogKilled: false };
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
await spawnWorker(hookCtx, dispatch);
|
|
565
|
+
|
|
566
|
+
// Both channels should have been called for "working", "auditing", "audit_pass"
|
|
567
|
+
const discordCalls = mockRuntime.channel.discord.sendMessageDiscord.mock.calls;
|
|
568
|
+
const telegramCalls = mockRuntime.channel.telegram.sendMessageTelegram.mock.calls;
|
|
569
|
+
|
|
570
|
+
// At least 3 events (working, auditing, audit_pass) × both channels
|
|
571
|
+
expect(discordCalls.length).toBeGreaterThanOrEqual(3);
|
|
572
|
+
expect(telegramCalls.length).toBeGreaterThanOrEqual(3);
|
|
573
|
+
|
|
574
|
+
// Verify Discord got the right target
|
|
575
|
+
for (const call of discordCalls) {
|
|
576
|
+
expect(call[0]).toBe("discord-channel-1");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Verify Telegram got the right target with HTML (rich format)
|
|
580
|
+
for (const call of telegramCalls) {
|
|
581
|
+
expect(call[0]).toBe("telegram-chat-1");
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
});
|