@calltelemetry/openclaw-linear 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +719 -539
- package/index.ts +40 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +2 -1
- package/prompts.yaml +19 -5
- package/src/__test__/fixtures/linear-responses.ts +75 -0
- package/src/__test__/fixtures/webhook-payloads.ts +113 -0
- package/src/__test__/helpers.ts +133 -0
- package/src/agent/agent.test.ts +143 -0
- package/src/api/linear-api.test.ts +586 -0
- package/src/api/linear-api.ts +50 -11
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +273 -30
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +114 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +137 -26
- package/src/infra/observability.test.ts +85 -0
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +3 -42
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +455 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +132 -29
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +18 -31
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +264 -0
- package/src/pipeline/webhook.ts +134 -36
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
vi.mock("../pipeline/dispatch-state.js", () => ({
|
|
8
|
+
readDispatchState: vi.fn(),
|
|
9
|
+
getActiveDispatch: vi.fn(),
|
|
10
|
+
listActiveDispatches: vi.fn(),
|
|
11
|
+
removeActiveDispatch: vi.fn(),
|
|
12
|
+
transitionDispatch: vi.fn(),
|
|
13
|
+
TransitionError: class TransitionError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
public dispatchId: string,
|
|
16
|
+
public fromStatus: string,
|
|
17
|
+
public toStatus: string,
|
|
18
|
+
public actualStatus: string,
|
|
19
|
+
) {
|
|
20
|
+
super(
|
|
21
|
+
`CAS transition failed for ${dispatchId}: ` +
|
|
22
|
+
`expected ${fromStatus} → ${toStatus}, but current status is ${actualStatus}`,
|
|
23
|
+
);
|
|
24
|
+
this.name = "TransitionError";
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
registerDispatch: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { registerDispatchCommands } from "./commands.js";
|
|
31
|
+
import {
|
|
32
|
+
readDispatchState,
|
|
33
|
+
getActiveDispatch,
|
|
34
|
+
listActiveDispatches,
|
|
35
|
+
removeActiveDispatch,
|
|
36
|
+
transitionDispatch,
|
|
37
|
+
registerDispatch,
|
|
38
|
+
} from "../pipeline/dispatch-state.js";
|
|
39
|
+
import type { DispatchState, ActiveDispatch } from "../pipeline/dispatch-state.js";
|
|
40
|
+
|
|
41
|
+
const mockReadDispatchState = readDispatchState as ReturnType<typeof vi.fn>;
|
|
42
|
+
const mockGetActiveDispatch = getActiveDispatch as ReturnType<typeof vi.fn>;
|
|
43
|
+
const mockListActiveDispatches = listActiveDispatches as ReturnType<typeof vi.fn>;
|
|
44
|
+
const mockRemoveActiveDispatch = removeActiveDispatch as ReturnType<typeof vi.fn>;
|
|
45
|
+
const mockTransitionDispatch = transitionDispatch as ReturnType<typeof vi.fn>;
|
|
46
|
+
const mockRegisterDispatch = registerDispatch as ReturnType<typeof vi.fn>;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
function emptyState(): DispatchState {
|
|
53
|
+
return {
|
|
54
|
+
dispatches: { active: {}, completed: {} },
|
|
55
|
+
sessionMap: {},
|
|
56
|
+
processedEvents: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeActive(overrides?: Partial<ActiveDispatch>): ActiveDispatch {
|
|
61
|
+
return {
|
|
62
|
+
issueId: "uuid-1",
|
|
63
|
+
issueIdentifier: "CT-100",
|
|
64
|
+
worktreePath: "/tmp/wt/CT-100",
|
|
65
|
+
branch: "codex/CT-100",
|
|
66
|
+
tier: "junior",
|
|
67
|
+
model: "test-model",
|
|
68
|
+
status: "dispatched",
|
|
69
|
+
dispatchedAt: new Date("2026-02-18T10:00:00Z").toISOString(),
|
|
70
|
+
attempt: 0,
|
|
71
|
+
...overrides,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Capture the registered command handler from the mock api */
|
|
76
|
+
function captureHandler() {
|
|
77
|
+
let handler: (ctx: any) => Promise<any>;
|
|
78
|
+
const api = {
|
|
79
|
+
pluginConfig: undefined,
|
|
80
|
+
registerCommand: vi.fn((cmd: any) => {
|
|
81
|
+
handler = cmd.handler;
|
|
82
|
+
}),
|
|
83
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
84
|
+
};
|
|
85
|
+
registerDispatchCommands(api as any);
|
|
86
|
+
return { api, handler: handler! };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Tests
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
describe("registerDispatchCommands", () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
vi.clearAllMocks();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("registers the /dispatch command", () => {
|
|
99
|
+
const { api } = captureHandler();
|
|
100
|
+
expect(api.registerCommand).toHaveBeenCalledOnce();
|
|
101
|
+
const cmd = api.registerCommand.mock.calls[0][0];
|
|
102
|
+
expect(cmd.name).toBe("dispatch");
|
|
103
|
+
expect(cmd.acceptsArgs).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("dispatch list shows active dispatches with age/tier/status", async () => {
|
|
107
|
+
const d = makeActive({
|
|
108
|
+
issueIdentifier: "CT-100",
|
|
109
|
+
tier: "senior",
|
|
110
|
+
status: "working",
|
|
111
|
+
attempt: 1,
|
|
112
|
+
});
|
|
113
|
+
const state = emptyState();
|
|
114
|
+
state.dispatches.active["CT-100"] = d;
|
|
115
|
+
|
|
116
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
117
|
+
mockListActiveDispatches.mockReturnValue([d]);
|
|
118
|
+
|
|
119
|
+
const { handler } = captureHandler();
|
|
120
|
+
const result = await handler({ args: "list" });
|
|
121
|
+
|
|
122
|
+
expect(result.text).toContain("Active Dispatches (1)");
|
|
123
|
+
expect(result.text).toContain("CT-100");
|
|
124
|
+
expect(result.text).toContain("working");
|
|
125
|
+
expect(result.text).toContain("senior");
|
|
126
|
+
expect(result.text).toContain("attempt 1");
|
|
127
|
+
// Should contain age in minutes (a number followed by 'm')
|
|
128
|
+
expect(result.text).toMatch(/\d+m/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("dispatch list with no active dispatches shows 'No active dispatches'", async () => {
|
|
132
|
+
mockReadDispatchState.mockResolvedValue(emptyState());
|
|
133
|
+
mockListActiveDispatches.mockReturnValue([]);
|
|
134
|
+
|
|
135
|
+
const { handler } = captureHandler();
|
|
136
|
+
const result = await handler({ args: "list" });
|
|
137
|
+
|
|
138
|
+
expect(result.text).toBe("No active dispatches.");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("dispatch status shows details for active dispatch", async () => {
|
|
142
|
+
const d = makeActive({
|
|
143
|
+
issueIdentifier: "CT-100",
|
|
144
|
+
issueTitle: "Fix the login bug",
|
|
145
|
+
tier: "medior",
|
|
146
|
+
status: "auditing",
|
|
147
|
+
attempt: 2,
|
|
148
|
+
});
|
|
149
|
+
const state = emptyState();
|
|
150
|
+
state.dispatches.active["CT-100"] = d;
|
|
151
|
+
|
|
152
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
153
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
154
|
+
|
|
155
|
+
const { handler } = captureHandler();
|
|
156
|
+
const result = await handler({ args: "status CT-100" });
|
|
157
|
+
|
|
158
|
+
expect(result.text).toContain("CT-100");
|
|
159
|
+
expect(result.text).toContain("Fix the login bug");
|
|
160
|
+
expect(result.text).toContain("auditing");
|
|
161
|
+
expect(result.text).toContain("medior");
|
|
162
|
+
expect(result.text).toContain("Attempt: 2");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("dispatch status shows 'not found' for missing identifier", async () => {
|
|
166
|
+
const state = emptyState();
|
|
167
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
168
|
+
mockGetActiveDispatch.mockReturnValue(null);
|
|
169
|
+
|
|
170
|
+
const { handler } = captureHandler();
|
|
171
|
+
const result = await handler({ args: "status CT-999" });
|
|
172
|
+
|
|
173
|
+
expect(result.text).toContain("No dispatch found for CT-999");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("dispatch retry on stuck dispatch transitions to dispatched", async () => {
|
|
177
|
+
const d = makeActive({
|
|
178
|
+
issueIdentifier: "CT-100",
|
|
179
|
+
status: "stuck",
|
|
180
|
+
stuckReason: "timed out",
|
|
181
|
+
});
|
|
182
|
+
const state = emptyState();
|
|
183
|
+
state.dispatches.active["CT-100"] = d;
|
|
184
|
+
|
|
185
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
186
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
187
|
+
mockRemoveActiveDispatch.mockResolvedValue(undefined);
|
|
188
|
+
mockRegisterDispatch.mockResolvedValue(undefined);
|
|
189
|
+
|
|
190
|
+
const { handler } = captureHandler();
|
|
191
|
+
const result = await handler({ args: "retry CT-100" });
|
|
192
|
+
|
|
193
|
+
expect(mockRemoveActiveDispatch).toHaveBeenCalledWith("CT-100", undefined);
|
|
194
|
+
expect(mockRegisterDispatch).toHaveBeenCalledWith(
|
|
195
|
+
"CT-100",
|
|
196
|
+
expect.objectContaining({ status: "dispatched", stuckReason: undefined }),
|
|
197
|
+
undefined,
|
|
198
|
+
);
|
|
199
|
+
expect(result.text).toContain("CT-100");
|
|
200
|
+
expect(result.text).toContain("reset to dispatched");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("dispatch retry on working dispatch returns error", async () => {
|
|
204
|
+
const d = makeActive({ issueIdentifier: "CT-100", status: "working" });
|
|
205
|
+
const state = emptyState();
|
|
206
|
+
state.dispatches.active["CT-100"] = d;
|
|
207
|
+
|
|
208
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
209
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
210
|
+
|
|
211
|
+
const { handler } = captureHandler();
|
|
212
|
+
const result = await handler({ args: "retry CT-100" });
|
|
213
|
+
|
|
214
|
+
expect(result.text).toContain("Cannot retry CT-100");
|
|
215
|
+
expect(result.text).toContain("working");
|
|
216
|
+
expect(result.text).toContain("must be stuck");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("dispatch escalate sets dispatch to stuck with reason", async () => {
|
|
220
|
+
const d = makeActive({ issueIdentifier: "CT-100", status: "working" });
|
|
221
|
+
const state = emptyState();
|
|
222
|
+
state.dispatches.active["CT-100"] = d;
|
|
223
|
+
|
|
224
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
225
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
226
|
+
mockTransitionDispatch.mockResolvedValue({ ...d, status: "stuck", stuckReason: "agent looping" });
|
|
227
|
+
|
|
228
|
+
const { handler } = captureHandler();
|
|
229
|
+
const result = await handler({ args: "escalate CT-100 agent looping" });
|
|
230
|
+
|
|
231
|
+
expect(mockTransitionDispatch).toHaveBeenCalledWith(
|
|
232
|
+
"CT-100",
|
|
233
|
+
"working",
|
|
234
|
+
"stuck",
|
|
235
|
+
{ stuckReason: "agent looping" },
|
|
236
|
+
undefined,
|
|
237
|
+
);
|
|
238
|
+
expect(result.text).toContain("CT-100");
|
|
239
|
+
expect(result.text).toContain("escalated to stuck");
|
|
240
|
+
expect(result.text).toContain("agent looping");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("dispatch escalate without reason uses default", async () => {
|
|
244
|
+
const d = makeActive({ issueIdentifier: "CT-100", status: "dispatched" });
|
|
245
|
+
const state = emptyState();
|
|
246
|
+
state.dispatches.active["CT-100"] = d;
|
|
247
|
+
|
|
248
|
+
mockReadDispatchState.mockResolvedValue(state);
|
|
249
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
250
|
+
mockTransitionDispatch.mockResolvedValue({ ...d, status: "stuck", stuckReason: "manual escalation" });
|
|
251
|
+
|
|
252
|
+
const { handler } = captureHandler();
|
|
253
|
+
const result = await handler({ args: "escalate CT-100" });
|
|
254
|
+
|
|
255
|
+
expect(mockTransitionDispatch).toHaveBeenCalledWith(
|
|
256
|
+
"CT-100",
|
|
257
|
+
"dispatched",
|
|
258
|
+
"stuck",
|
|
259
|
+
{ stuckReason: "manual escalation" },
|
|
260
|
+
undefined,
|
|
261
|
+
);
|
|
262
|
+
expect(result.text).toContain("escalated to stuck");
|
|
263
|
+
expect(result.text).toContain("manual escalation");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("help/unknown subcommand shows available commands", async () => {
|
|
267
|
+
const { handler } = captureHandler();
|
|
268
|
+
const result = await handler({ args: "help" });
|
|
269
|
+
|
|
270
|
+
expect(result.text).toContain("Dispatch Commands");
|
|
271
|
+
expect(result.text).toContain("/dispatch list");
|
|
272
|
+
expect(result.text).toContain("/dispatch status");
|
|
273
|
+
expect(result.text).toContain("/dispatch retry");
|
|
274
|
+
expect(result.text).toContain("/dispatch escalate");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands.ts — Zero-LLM slash commands for dispatch operations.
|
|
3
|
+
*
|
|
4
|
+
* Registered via api.registerCommand(). These commands bypass the AI agent
|
|
5
|
+
* entirely — they read/write dispatch state directly and return formatted text.
|
|
6
|
+
*/
|
|
7
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
8
|
+
import {
|
|
9
|
+
readDispatchState,
|
|
10
|
+
getActiveDispatch,
|
|
11
|
+
listActiveDispatches,
|
|
12
|
+
removeActiveDispatch,
|
|
13
|
+
transitionDispatch,
|
|
14
|
+
TransitionError,
|
|
15
|
+
registerDispatch,
|
|
16
|
+
type ActiveDispatch,
|
|
17
|
+
} from "../pipeline/dispatch-state.js";
|
|
18
|
+
|
|
19
|
+
export function registerDispatchCommands(api: OpenClawPluginApi): void {
|
|
20
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
21
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
22
|
+
|
|
23
|
+
api.registerCommand({
|
|
24
|
+
name: "dispatch",
|
|
25
|
+
description: "Manage dispatches: list, status <id>, retry <id>, escalate <id>",
|
|
26
|
+
acceptsArgs: true,
|
|
27
|
+
handler: async (ctx) => {
|
|
28
|
+
const args = (ctx.args ?? "").trim().split(/\s+/);
|
|
29
|
+
const sub = args[0]?.toLowerCase();
|
|
30
|
+
const id = args[1];
|
|
31
|
+
|
|
32
|
+
if (!sub || sub === "list") {
|
|
33
|
+
return await handleList(statePath);
|
|
34
|
+
}
|
|
35
|
+
if (sub === "status" && id) {
|
|
36
|
+
return await handleStatus(id, statePath);
|
|
37
|
+
}
|
|
38
|
+
if (sub === "retry" && id) {
|
|
39
|
+
return await handleRetry(id, statePath, api);
|
|
40
|
+
}
|
|
41
|
+
if (sub === "escalate" && id) {
|
|
42
|
+
const reason = args.slice(2).join(" ") || "manual escalation";
|
|
43
|
+
return await handleEscalate(id, reason, statePath, api);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
text: [
|
|
48
|
+
"**Dispatch Commands:**",
|
|
49
|
+
"`/dispatch list` — show active dispatches",
|
|
50
|
+
"`/dispatch status <id>` — phase/attempt details",
|
|
51
|
+
"`/dispatch retry <id>` — reset stuck → dispatched",
|
|
52
|
+
"`/dispatch escalate <id> [reason]` — force to stuck",
|
|
53
|
+
].join("\n"),
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function handleList(statePath?: string) {
|
|
60
|
+
const state = await readDispatchState(statePath);
|
|
61
|
+
const active = listActiveDispatches(state);
|
|
62
|
+
|
|
63
|
+
if (active.length === 0) {
|
|
64
|
+
return { text: "No active dispatches." };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const lines = active.map((d) => {
|
|
68
|
+
const age = Math.round((Date.now() - new Date(d.dispatchedAt).getTime()) / 60_000);
|
|
69
|
+
return `**${d.issueIdentifier}** — ${d.status} (${d.tier}, attempt ${d.attempt}, ${age}m)`;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return { text: `**Active Dispatches (${active.length})**\n${lines.join("\n")}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function handleStatus(id: string, statePath?: string) {
|
|
76
|
+
const state = await readDispatchState(statePath);
|
|
77
|
+
const d = getActiveDispatch(state, id);
|
|
78
|
+
|
|
79
|
+
if (!d) {
|
|
80
|
+
const completed = state.dispatches.completed[id];
|
|
81
|
+
if (completed) {
|
|
82
|
+
return {
|
|
83
|
+
text: `**${id}** — completed (${completed.status}, ${completed.tier}, ${completed.totalAttempts ?? 0} attempts)`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { text: `No dispatch found for ${id}.` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const age = Math.round((Date.now() - new Date(d.dispatchedAt).getTime()) / 60_000);
|
|
90
|
+
const lines = [
|
|
91
|
+
`**${d.issueIdentifier}** — ${d.issueTitle ?? d.issueIdentifier}`,
|
|
92
|
+
`Status: ${d.status} | Tier: ${d.tier} | Attempt: ${d.attempt}`,
|
|
93
|
+
`Age: ${age}m | Worktree: \`${d.worktreePath}\``,
|
|
94
|
+
d.stuckReason ? `Stuck reason: ${d.stuckReason}` : "",
|
|
95
|
+
d.agentSessionId ? `Session: ${d.agentSessionId}` : "",
|
|
96
|
+
].filter(Boolean);
|
|
97
|
+
|
|
98
|
+
return { text: lines.join("\n") };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function handleRetry(id: string, statePath: string | undefined, api: OpenClawPluginApi) {
|
|
102
|
+
const state = await readDispatchState(statePath);
|
|
103
|
+
const d = getActiveDispatch(state, id);
|
|
104
|
+
|
|
105
|
+
if (!d) {
|
|
106
|
+
return { text: `No active dispatch found for ${id}.` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (d.status !== "stuck") {
|
|
110
|
+
return { text: `Cannot retry ${id} — status is ${d.status} (must be stuck).` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Remove and re-register with reset status
|
|
114
|
+
await removeActiveDispatch(id, statePath);
|
|
115
|
+
const retryDispatch: ActiveDispatch = {
|
|
116
|
+
...d,
|
|
117
|
+
status: "dispatched",
|
|
118
|
+
stuckReason: undefined,
|
|
119
|
+
workerSessionKey: undefined,
|
|
120
|
+
auditSessionKey: undefined,
|
|
121
|
+
dispatchedAt: new Date().toISOString(),
|
|
122
|
+
};
|
|
123
|
+
await registerDispatch(id, retryDispatch, statePath);
|
|
124
|
+
|
|
125
|
+
api.logger.info(`/dispatch retry: ${id} reset from stuck → dispatched`);
|
|
126
|
+
return { text: `**${id}** reset to dispatched. Will be picked up by next dispatch cycle.` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleEscalate(
|
|
130
|
+
id: string,
|
|
131
|
+
reason: string,
|
|
132
|
+
statePath: string | undefined,
|
|
133
|
+
api: OpenClawPluginApi,
|
|
134
|
+
) {
|
|
135
|
+
const state = await readDispatchState(statePath);
|
|
136
|
+
const d = getActiveDispatch(state, id);
|
|
137
|
+
|
|
138
|
+
if (!d) {
|
|
139
|
+
return { text: `No active dispatch found for ${id}.` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (d.status === "stuck" || d.status === "done" || d.status === "failed") {
|
|
143
|
+
return { text: `Cannot escalate ${id} — already in terminal state: ${d.status}.` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await transitionDispatch(id, d.status, "stuck", { stuckReason: reason }, statePath);
|
|
148
|
+
api.logger.info(`/dispatch escalate: ${id} → stuck (${reason})`);
|
|
149
|
+
return { text: `**${id}** escalated to stuck: ${reason}` };
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err instanceof TransitionError) {
|
|
152
|
+
return { text: `CAS conflict: ${err.message}` };
|
|
153
|
+
}
|
|
154
|
+
return { text: `Error: ${String(err)}` };
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/infra/doctor.test.ts
CHANGED
|
@@ -382,6 +382,25 @@ describe("formatReport", () => {
|
|
|
382
382
|
expect(output).toContain("1 passed");
|
|
383
383
|
expect(output).toContain("1 warning");
|
|
384
384
|
});
|
|
385
|
+
|
|
386
|
+
it("shows fix guidance for warnings and errors", () => {
|
|
387
|
+
const report = {
|
|
388
|
+
sections: [
|
|
389
|
+
{
|
|
390
|
+
name: "Auth",
|
|
391
|
+
checks: [
|
|
392
|
+
{ label: "Token expired", severity: "warn" as const, fix: "Run: openclaw openclaw-linear auth" },
|
|
393
|
+
{ label: "All good", severity: "pass" as const, fix: "Should not appear" },
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
summary: { passed: 1, warnings: 1, errors: 0 },
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const output = formatReport(report);
|
|
401
|
+
expect(output).toContain("→ Run: openclaw openclaw-linear auth");
|
|
402
|
+
expect(output).not.toContain("Should not appear");
|
|
403
|
+
});
|
|
385
404
|
});
|
|
386
405
|
|
|
387
406
|
describe("formatReportJson", () => {
|
package/src/infra/doctor.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface CheckResult {
|
|
|
25
25
|
severity: CheckSeverity;
|
|
26
26
|
detail?: string;
|
|
27
27
|
fixable?: boolean;
|
|
28
|
+
/** User-facing guidance on how to resolve the issue */
|
|
29
|
+
fix?: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface CheckSection {
|
|
@@ -66,12 +68,12 @@ function pass(label: string, detail?: string): CheckResult {
|
|
|
66
68
|
return { label, severity: "pass", detail };
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
function warn(label: string, detail?: string, fixable
|
|
70
|
-
return { label, severity: "warn", detail, fixable: fixable || undefined };
|
|
71
|
+
function warn(label: string, detail?: string, opts?: { fixable?: boolean; fix?: string }): CheckResult {
|
|
72
|
+
return { label, severity: "warn", detail, fixable: opts?.fixable || undefined, fix: opts?.fix };
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
function fail(label: string, detail?: string): CheckResult {
|
|
74
|
-
return { label, severity: "fail", detail };
|
|
75
|
+
function fail(label: string, detail?: string, fix?: string): CheckResult {
|
|
76
|
+
return { label, severity: "fail", detail, fix };
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
function resolveDispatchStatePath(pluginConfig?: Record<string, unknown>): string {
|
|
@@ -126,7 +128,7 @@ export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise
|
|
|
126
128
|
if (tokenInfo.accessToken) {
|
|
127
129
|
checks.push(pass(`Access token found (source: ${tokenInfo.source})`));
|
|
128
130
|
} else {
|
|
129
|
-
checks.push(fail("No access token found", "Run: openclaw openclaw-linear auth"));
|
|
131
|
+
checks.push(fail("No access token found", undefined, "Run: openclaw openclaw-linear auth"));
|
|
130
132
|
// Can't check further without token
|
|
131
133
|
return { checks, ctx };
|
|
132
134
|
}
|
|
@@ -135,12 +137,12 @@ export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise
|
|
|
135
137
|
if (tokenInfo.expiresAt) {
|
|
136
138
|
const remaining = tokenInfo.expiresAt - Date.now();
|
|
137
139
|
if (remaining <= 0) {
|
|
138
|
-
checks.push(warn("Token expired",
|
|
140
|
+
checks.push(warn("Token expired", undefined, { fix: "Run: openclaw openclaw-linear auth" }));
|
|
139
141
|
} else {
|
|
140
142
|
const hours = Math.floor(remaining / 3_600_000);
|
|
141
143
|
const mins = Math.floor((remaining % 3_600_000) / 60_000);
|
|
142
144
|
if (remaining < 3_600_000) {
|
|
143
|
-
checks.push(warn(`Token expires soon (${mins}m remaining)
|
|
145
|
+
checks.push(warn(`Token expires soon (${mins}m remaining)`, undefined, { fix: "Restart the gateway to trigger auto-refresh, or run: openclaw openclaw-linear auth" }));
|
|
144
146
|
} else {
|
|
145
147
|
checks.push(pass(`Token not expired (${hours}h ${mins}m remaining)`));
|
|
146
148
|
}
|
|
@@ -190,8 +192,8 @@ export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise
|
|
|
190
192
|
} else {
|
|
191
193
|
checks.push(warn(
|
|
192
194
|
`auth-profiles.json permissions (${mode.toString(8)}, expected 600)`,
|
|
193
|
-
|
|
194
|
-
true,
|
|
195
|
+
undefined,
|
|
196
|
+
{ fixable: true, fix: "Run: chmod 600 ~/.openclaw/auth-profiles.json" },
|
|
195
197
|
));
|
|
196
198
|
}
|
|
197
199
|
} catch {
|
|
@@ -246,7 +248,7 @@ export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckR
|
|
|
246
248
|
|
|
247
249
|
const agentCount = Object.keys(profiles).length;
|
|
248
250
|
if (agentCount === 0) {
|
|
249
|
-
checks.push(fail("agent-profiles.json has no agents"));
|
|
251
|
+
checks.push(fail("agent-profiles.json has no agents", undefined, "Add at least one agent to ~/.openclaw/agent-profiles.json"));
|
|
250
252
|
return checks;
|
|
251
253
|
}
|
|
252
254
|
checks.push(pass(`agent-profiles.json loaded (${agentCount} agent${agentCount > 1 ? "s" : ""})`));
|
|
@@ -256,7 +258,7 @@ export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckR
|
|
|
256
258
|
if (defaultEntry) {
|
|
257
259
|
checks.push(pass(`Default agent: ${defaultEntry[0]}`));
|
|
258
260
|
} else {
|
|
259
|
-
checks.push(warn("No agent has isDefault: true"));
|
|
261
|
+
checks.push(warn("No agent has isDefault: true", undefined, { fix: "Add \"isDefault\": true to one agent in ~/.openclaw/agent-profiles.json" }));
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
// Required fields
|
|
@@ -270,7 +272,7 @@ export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckR
|
|
|
270
272
|
if (missing.length === 0) {
|
|
271
273
|
checks.push(pass("All agents have required fields"));
|
|
272
274
|
} else {
|
|
273
|
-
checks.push(fail(`Agent field issues: ${missing.join("; ")}
|
|
275
|
+
checks.push(fail(`Agent field issues: ${missing.join("; ")}`, undefined, "Each agent needs at least a \"label\" and \"mentionAliases\" array in agent-profiles.json"));
|
|
274
276
|
}
|
|
275
277
|
|
|
276
278
|
// defaultAgentId match
|
|
@@ -321,7 +323,7 @@ export function checkCodingTools(): CheckResult[] {
|
|
|
321
323
|
if (hasConfig) {
|
|
322
324
|
checks.push(pass(`coding-tools.json loaded (default: ${config.codingTool ?? "claude"})`));
|
|
323
325
|
} else {
|
|
324
|
-
checks.push(warn("coding-tools.json not found or empty (using defaults)"));
|
|
326
|
+
checks.push(warn("coding-tools.json not found or empty (using defaults)", undefined, { fix: "Create coding-tools.json in the plugin root — see README for format" }));
|
|
325
327
|
}
|
|
326
328
|
|
|
327
329
|
// Validate default backend
|
|
@@ -355,7 +357,7 @@ export function checkCodingTools(): CheckResult[] {
|
|
|
355
357
|
accessSync(bin, constants.X_OK);
|
|
356
358
|
checks.push(pass(`${name}: installed (version check skipped)`));
|
|
357
359
|
} catch {
|
|
358
|
-
checks.push(warn(`${name}: not found at ${bin}`));
|
|
360
|
+
checks.push(warn(`${name}: not found at ${bin}`, undefined, { fix: `Install ${name} or check that it's in your PATH` }));
|
|
359
361
|
}
|
|
360
362
|
}
|
|
361
363
|
}
|
|
@@ -402,8 +404,8 @@ export async function checkFilesAndDirs(pluginConfig?: Record<string, unknown>,
|
|
|
402
404
|
} else {
|
|
403
405
|
checks.push(warn(
|
|
404
406
|
`Stale lock file (${Math.round(lockAge / 1000)}s old)`,
|
|
405
|
-
|
|
406
|
-
true,
|
|
407
|
+
undefined,
|
|
408
|
+
{ fixable: true, fix: "Run: openclaw openclaw-linear doctor --fix to remove" },
|
|
407
409
|
));
|
|
408
410
|
}
|
|
409
411
|
} else {
|
|
@@ -440,10 +442,10 @@ export async function checkFilesAndDirs(pluginConfig?: Record<string, unknown>,
|
|
|
440
442
|
});
|
|
441
443
|
checks.push(pass("Base repo is valid git repo"));
|
|
442
444
|
} catch {
|
|
443
|
-
checks.push(fail(`Base repo is not a git repo: ${baseRepo}
|
|
445
|
+
checks.push(fail(`Base repo is not a git repo: ${baseRepo}`, undefined, "Run: git init in the base repo directory, or set codexBaseRepo to a different path"));
|
|
444
446
|
}
|
|
445
447
|
} else {
|
|
446
|
-
checks.push(fail(`Base repo does not exist: ${baseRepo}
|
|
448
|
+
checks.push(fail(`Base repo does not exist: ${baseRepo}`, undefined, "Set codexBaseRepo in plugin config to your git repository path"));
|
|
447
449
|
}
|
|
448
450
|
|
|
449
451
|
// Prompts
|
|
@@ -482,7 +484,7 @@ export async function checkFilesAndDirs(pluginConfig?: Record<string, unknown>,
|
|
|
482
484
|
if (errors.length === 0) {
|
|
483
485
|
checks.push(pass(`Prompts valid (${sectionCount}/5 sections, ${varCount}/4 variables)`));
|
|
484
486
|
} else {
|
|
485
|
-
checks.push(fail(`Prompt issues: ${errors.join("; ")}
|
|
487
|
+
checks.push(fail(`Prompt issues: ${errors.join("; ")}`, undefined, "Run: openclaw openclaw-linear prompts validate for details"));
|
|
486
488
|
}
|
|
487
489
|
} catch (err) {
|
|
488
490
|
checks.push(fail(
|
|
@@ -526,7 +528,7 @@ export async function checkConnectivity(pluginConfig?: Record<string, unknown>,
|
|
|
526
528
|
checks.push(fail(`Linear API: unreachable (${err instanceof Error ? err.message : String(err)})`));
|
|
527
529
|
}
|
|
528
530
|
} else {
|
|
529
|
-
checks.push(fail("Linear API: no token available"));
|
|
531
|
+
checks.push(fail("Linear API: no token available", undefined, "Run: openclaw openclaw-linear auth"));
|
|
530
532
|
}
|
|
531
533
|
}
|
|
532
534
|
|
|
@@ -602,7 +604,7 @@ export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>
|
|
|
602
604
|
checks.push(pass("No stale dispatches"));
|
|
603
605
|
} else {
|
|
604
606
|
const ids = stale.map((d) => d.issueIdentifier).join(", ");
|
|
605
|
-
checks.push(warn(`${stale.length} stale dispatch${stale.length > 1 ? "es" : ""}: ${ids}
|
|
607
|
+
checks.push(warn(`${stale.length} stale dispatch${stale.length > 1 ? "es" : ""}: ${ids}`, undefined, { fix: "Re-assign the issue to retry, or run: openclaw openclaw-linear doctor --fix to clean up" }));
|
|
606
608
|
}
|
|
607
609
|
|
|
608
610
|
// Orphaned worktrees
|
|
@@ -639,8 +641,8 @@ export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>
|
|
|
639
641
|
} else {
|
|
640
642
|
checks.push(warn(
|
|
641
643
|
`${old.length} completed dispatch${old.length > 1 ? "es" : ""} older than 7 days`,
|
|
642
|
-
|
|
643
|
-
true,
|
|
644
|
+
undefined,
|
|
645
|
+
{ fixable: true, fix: "Run: openclaw openclaw-linear doctor --fix to clean up old entries" },
|
|
644
646
|
));
|
|
645
647
|
}
|
|
646
648
|
}
|
|
@@ -737,6 +739,9 @@ export function formatReport(report: DoctorReport): string {
|
|
|
737
739
|
lines.push(section.name);
|
|
738
740
|
for (const check of section.checks) {
|
|
739
741
|
lines.push(` ${icon(check.severity)} ${check.label}`);
|
|
742
|
+
if (check.fix && check.severity !== "pass") {
|
|
743
|
+
lines.push(` → ${check.fix}`);
|
|
744
|
+
}
|
|
740
745
|
}
|
|
741
746
|
}
|
|
742
747
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { acquireLock, releaseLock } from "./file-lock.js";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
|
|
7
|
+
const tmpDir = os.tmpdir();
|
|
8
|
+
const testState = path.join(tmpDir, `file-lock-test-${process.pid}.json`);
|
|
9
|
+
const lockFile = testState + ".lock";
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
try { await fs.unlink(lockFile); } catch {}
|
|
13
|
+
try { await fs.unlink(testState); } catch {}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("acquireLock / releaseLock", () => {
|
|
17
|
+
it("creates and removes a lock file", async () => {
|
|
18
|
+
await acquireLock(testState);
|
|
19
|
+
const stat = await fs.stat(lockFile);
|
|
20
|
+
expect(stat.isFile()).toBe(true);
|
|
21
|
+
|
|
22
|
+
await releaseLock(testState);
|
|
23
|
+
await expect(fs.stat(lockFile)).rejects.toThrow();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("blocks concurrent acquires until released", async () => {
|
|
27
|
+
await acquireLock(testState);
|
|
28
|
+
|
|
29
|
+
let secondAcquired = false;
|
|
30
|
+
const secondLock = acquireLock(testState).then(() => {
|
|
31
|
+
secondAcquired = true;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Give the second acquire a moment to spin
|
|
35
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
36
|
+
expect(secondAcquired).toBe(false);
|
|
37
|
+
|
|
38
|
+
await releaseLock(testState);
|
|
39
|
+
await secondLock;
|
|
40
|
+
expect(secondAcquired).toBe(true);
|
|
41
|
+
|
|
42
|
+
await releaseLock(testState);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("releaseLock is safe to call when no lock exists", async () => {
|
|
46
|
+
await expect(releaseLock(testState)).resolves.toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("recovers from stale lock", async () => {
|
|
50
|
+
// Write a lock file with an old timestamp (> 30s ago)
|
|
51
|
+
await fs.writeFile(lockFile, String(Date.now() - 60_000), { flag: "w" });
|
|
52
|
+
|
|
53
|
+
// Should succeed by detecting stale lock
|
|
54
|
+
await acquireLock(testState);
|
|
55
|
+
const content = await fs.readFile(lockFile, "utf-8");
|
|
56
|
+
const lockTime = Number(content);
|
|
57
|
+
expect(Date.now() - lockTime).toBeLessThan(5000);
|
|
58
|
+
|
|
59
|
+
await releaseLock(testState);
|
|
60
|
+
});
|
|
61
|
+
});
|