@calltelemetry/openclaw-linear 0.7.0 → 0.7.1
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/index.ts +39 -0
- package/openclaw.plugin.json +3 -3
- package/package.json +2 -1
- package/src/api/linear-api.test.ts +494 -0
- package/src/api/linear-api.ts +14 -11
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +97 -29
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.ts +85 -0
- package/src/infra/notify.ts +115 -15
- 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.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 +2 -42
- package/src/pipeline/pipeline.ts +91 -17
- package/src/pipeline/planner.ts +6 -1
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +175 -0
- package/src/pipeline/webhook.ts +21 -0
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch-methods.ts — Gateway RPC methods for dispatch operations.
|
|
3
|
+
*
|
|
4
|
+
* Registers methods on the OpenClaw gateway that allow clients (UI, CLI, other
|
|
5
|
+
* plugins) to inspect and manage the dispatch pipeline via the standard
|
|
6
|
+
* gateway request/respond protocol.
|
|
7
|
+
*
|
|
8
|
+
* Methods:
|
|
9
|
+
* dispatch.list — List active + completed dispatches (filterable)
|
|
10
|
+
* dispatch.get — Full details for a single dispatch
|
|
11
|
+
* dispatch.retry — Re-dispatch a stuck issue
|
|
12
|
+
* dispatch.escalate — Force a working/auditing dispatch into stuck
|
|
13
|
+
* dispatch.cancel — Remove an active dispatch entirely
|
|
14
|
+
* dispatch.stats — Aggregate counts by status and tier
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
18
|
+
import {
|
|
19
|
+
readDispatchState,
|
|
20
|
+
getActiveDispatch,
|
|
21
|
+
listActiveDispatches,
|
|
22
|
+
transitionDispatch,
|
|
23
|
+
removeActiveDispatch,
|
|
24
|
+
registerDispatch,
|
|
25
|
+
TransitionError,
|
|
26
|
+
type ActiveDispatch,
|
|
27
|
+
type DispatchState,
|
|
28
|
+
type DispatchStatus,
|
|
29
|
+
type CompletedDispatch,
|
|
30
|
+
} from "../pipeline/dispatch-state.js";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function ok(data: Record<string, unknown> = {}): Record<string, unknown> {
|
|
37
|
+
return { ok: true, ...data };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fail(error: string): Record<string, unknown> {
|
|
41
|
+
return { ok: false, error };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Registration
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export function registerDispatchMethods(api: OpenClawPluginApi): void {
|
|
49
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
50
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
51
|
+
|
|
52
|
+
// ---- dispatch.list -------------------------------------------------------
|
|
53
|
+
api.registerGatewayMethod("dispatch.list", async ({ params, respond }) => {
|
|
54
|
+
try {
|
|
55
|
+
const statusFilter = params.status as DispatchStatus | undefined;
|
|
56
|
+
const tierFilter = params.tier as string | undefined;
|
|
57
|
+
|
|
58
|
+
const state = await readDispatchState(statePath);
|
|
59
|
+
let active = listActiveDispatches(state);
|
|
60
|
+
|
|
61
|
+
if (statusFilter) {
|
|
62
|
+
active = active.filter((d) => d.status === statusFilter);
|
|
63
|
+
}
|
|
64
|
+
if (tierFilter) {
|
|
65
|
+
active = active.filter((d) => d.tier === tierFilter);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const completed = Object.values(state.dispatches.completed);
|
|
69
|
+
|
|
70
|
+
respond(true, ok({ active, completed }));
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
respond(true, fail(err.message ?? String(err)));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---- dispatch.get --------------------------------------------------------
|
|
77
|
+
api.registerGatewayMethod("dispatch.get", async ({ params, respond }) => {
|
|
78
|
+
try {
|
|
79
|
+
const identifier = params.identifier as string | undefined;
|
|
80
|
+
if (!identifier) {
|
|
81
|
+
respond(true, fail("Missing required param: identifier"));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const state = await readDispatchState(statePath);
|
|
86
|
+
const active = getActiveDispatch(state, identifier);
|
|
87
|
+
if (active) {
|
|
88
|
+
respond(true, ok({ dispatch: active, source: "active" }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const completed = state.dispatches.completed[identifier];
|
|
93
|
+
if (completed) {
|
|
94
|
+
respond(true, ok({ dispatch: completed, source: "completed" }));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
respond(true, fail(`No dispatch found for identifier: ${identifier}`));
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
respond(true, fail(err.message ?? String(err)));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---- dispatch.retry ------------------------------------------------------
|
|
105
|
+
// Stuck dispatches are terminal in VALID_TRANSITIONS, so we cannot use
|
|
106
|
+
// transitionDispatch. Instead, remove the active dispatch and re-register
|
|
107
|
+
// it with status reset to "dispatched" and an incremented attempt counter.
|
|
108
|
+
api.registerGatewayMethod("dispatch.retry", async ({ params, respond }) => {
|
|
109
|
+
try {
|
|
110
|
+
const identifier = params.identifier as string | undefined;
|
|
111
|
+
if (!identifier) {
|
|
112
|
+
respond(true, fail("Missing required param: identifier"));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const state = await readDispatchState(statePath);
|
|
117
|
+
const dispatch = getActiveDispatch(state, identifier);
|
|
118
|
+
if (!dispatch) {
|
|
119
|
+
respond(true, fail(`No active dispatch for identifier: ${identifier}`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (dispatch.status !== "stuck") {
|
|
124
|
+
respond(true, fail(`Cannot retry dispatch in status "${dispatch.status}" — only "stuck" dispatches can be retried`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Capture current state, remove, then re-register with reset status
|
|
129
|
+
const retryDispatch: ActiveDispatch = {
|
|
130
|
+
...dispatch,
|
|
131
|
+
status: "dispatched",
|
|
132
|
+
attempt: dispatch.attempt + 1,
|
|
133
|
+
stuckReason: undefined,
|
|
134
|
+
workerSessionKey: undefined,
|
|
135
|
+
auditSessionKey: undefined,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
await removeActiveDispatch(identifier, statePath);
|
|
139
|
+
await registerDispatch(identifier, retryDispatch, statePath);
|
|
140
|
+
|
|
141
|
+
api.logger.info(`dispatch.retry: ${identifier} re-dispatched (attempt ${retryDispatch.attempt})`);
|
|
142
|
+
respond(true, ok({ dispatch: retryDispatch }));
|
|
143
|
+
} catch (err: any) {
|
|
144
|
+
respond(true, fail(err.message ?? String(err)));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- dispatch.escalate ---------------------------------------------------
|
|
149
|
+
api.registerGatewayMethod("dispatch.escalate", async ({ params, respond }) => {
|
|
150
|
+
try {
|
|
151
|
+
const identifier = params.identifier as string | undefined;
|
|
152
|
+
if (!identifier) {
|
|
153
|
+
respond(true, fail("Missing required param: identifier"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const reason = (params.reason as string) || "Manually escalated via gateway";
|
|
158
|
+
|
|
159
|
+
const state = await readDispatchState(statePath);
|
|
160
|
+
const dispatch = getActiveDispatch(state, identifier);
|
|
161
|
+
if (!dispatch) {
|
|
162
|
+
respond(true, fail(`No active dispatch for identifier: ${identifier}`));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (dispatch.status !== "working" && dispatch.status !== "auditing") {
|
|
167
|
+
respond(true, fail(`Cannot escalate dispatch in status "${dispatch.status}" — only "working" or "auditing" dispatches can be escalated`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const updated = await transitionDispatch(
|
|
172
|
+
identifier,
|
|
173
|
+
dispatch.status,
|
|
174
|
+
"stuck",
|
|
175
|
+
{ stuckReason: reason },
|
|
176
|
+
statePath,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
api.logger.info(`dispatch.escalate: ${identifier} escalated to stuck (was ${dispatch.status}, reason: ${reason})`);
|
|
180
|
+
respond(true, ok({ dispatch: updated }));
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
if (err instanceof TransitionError) {
|
|
183
|
+
respond(true, fail(`Transition conflict: ${err.message}`));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
respond(true, fail(err.message ?? String(err)));
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---- dispatch.cancel -----------------------------------------------------
|
|
191
|
+
api.registerGatewayMethod("dispatch.cancel", async ({ params, respond }) => {
|
|
192
|
+
try {
|
|
193
|
+
const identifier = params.identifier as string | undefined;
|
|
194
|
+
if (!identifier) {
|
|
195
|
+
respond(true, fail("Missing required param: identifier"));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const state = await readDispatchState(statePath);
|
|
200
|
+
const dispatch = getActiveDispatch(state, identifier);
|
|
201
|
+
if (!dispatch) {
|
|
202
|
+
respond(true, fail(`No active dispatch for identifier: ${identifier}`));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await removeActiveDispatch(identifier, statePath);
|
|
207
|
+
|
|
208
|
+
api.logger.info(`dispatch.cancel: ${identifier} removed (was ${dispatch.status})`);
|
|
209
|
+
respond(true, ok({ cancelled: identifier, previousStatus: dispatch.status }));
|
|
210
|
+
} catch (err: any) {
|
|
211
|
+
respond(true, fail(err.message ?? String(err)));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---- dispatch.stats ------------------------------------------------------
|
|
216
|
+
api.registerGatewayMethod("dispatch.stats", async ({ params, respond }) => {
|
|
217
|
+
try {
|
|
218
|
+
const state = await readDispatchState(statePath);
|
|
219
|
+
const active = listActiveDispatches(state);
|
|
220
|
+
|
|
221
|
+
const byStatus: Record<string, number> = {};
|
|
222
|
+
const byTier: Record<string, number> = {};
|
|
223
|
+
|
|
224
|
+
for (const d of active) {
|
|
225
|
+
byStatus[d.status] = (byStatus[d.status] ?? 0) + 1;
|
|
226
|
+
byTier[d.tier] = (byTier[d.tier] ?? 0) + 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const completedCount = Object.keys(state.dispatches.completed).length;
|
|
230
|
+
|
|
231
|
+
respond(true, ok({
|
|
232
|
+
activeCount: active.length,
|
|
233
|
+
completedCount,
|
|
234
|
+
byStatus,
|
|
235
|
+
byTier,
|
|
236
|
+
}));
|
|
237
|
+
} catch (err: any) {
|
|
238
|
+
respond(true, fail(err.message ?? String(err)));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
api.logger.info("Dispatch gateway methods registered (dispatch.list, dispatch.get, dispatch.retry, dispatch.escalate, dispatch.cancel, dispatch.stats)");
|
|
243
|
+
}
|
package/src/infra/cli.ts
CHANGED
|
@@ -5,8 +5,7 @@ import type { Command } from "commander";
|
|
|
5
5
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
7
|
import { exec } from "node:child_process";
|
|
8
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { readFileSync as readFileSyncFs, existsSync } from "node:fs";
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
10
9
|
import { join, dirname } from "node:path";
|
|
11
10
|
import { fileURLToPath } from "node:url";
|
|
12
11
|
import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
|
|
@@ -258,33 +257,21 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
258
257
|
|
|
259
258
|
prompts
|
|
260
259
|
.command("show")
|
|
261
|
-
.description("Print
|
|
262
|
-
.
|
|
260
|
+
.description("Print resolved prompts (global or per-project)")
|
|
261
|
+
.option("--worktree <path>", "Show merged prompts for a specific worktree")
|
|
262
|
+
.action(async (opts: { worktree?: string }) => {
|
|
263
263
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
264
|
-
|
|
264
|
+
clearPromptCache();
|
|
265
|
+
const loaded = loadPrompts(pluginConfig, opts.worktree);
|
|
265
266
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
resolvedPath = customPath.startsWith("~")
|
|
269
|
-
? customPath.replace("~", process.env.HOME ?? "")
|
|
270
|
-
: customPath;
|
|
267
|
+
if (opts.worktree) {
|
|
268
|
+
console.log(`\nResolved prompts for worktree: ${opts.worktree}\n`);
|
|
271
269
|
} else {
|
|
272
|
-
|
|
273
|
-
resolvedPath = join(pluginRoot, "prompts.yaml");
|
|
270
|
+
console.log(`\nGlobal resolved prompts\n`);
|
|
274
271
|
}
|
|
275
272
|
|
|
276
|
-
console.log(
|
|
277
|
-
|
|
278
|
-
try {
|
|
279
|
-
const content = readFileSyncFs(resolvedPath, "utf-8");
|
|
280
|
-
console.log(content);
|
|
281
|
-
} catch {
|
|
282
|
-
console.log("(file not found — using built-in defaults)\n");
|
|
283
|
-
// Show the loaded defaults
|
|
284
|
-
clearPromptCache();
|
|
285
|
-
const loaded = loadPrompts(pluginConfig);
|
|
286
|
-
console.log(JSON.stringify(loaded, null, 2));
|
|
287
|
-
}
|
|
273
|
+
console.log(JSON.stringify(loaded, null, 2));
|
|
274
|
+
console.log();
|
|
288
275
|
});
|
|
289
276
|
|
|
290
277
|
prompts
|
|
@@ -310,13 +297,14 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
310
297
|
|
|
311
298
|
prompts
|
|
312
299
|
.command("validate")
|
|
313
|
-
.description("Validate prompts.yaml structure")
|
|
314
|
-
.
|
|
300
|
+
.description("Validate prompts.yaml structure (global or per-project)")
|
|
301
|
+
.option("--worktree <path>", "Validate merged prompts for a specific worktree")
|
|
302
|
+
.action(async (opts: { worktree?: string }) => {
|
|
315
303
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
316
304
|
clearPromptCache();
|
|
317
305
|
|
|
318
306
|
try {
|
|
319
|
-
const loaded = loadPrompts(pluginConfig);
|
|
307
|
+
const loaded = loadPrompts(pluginConfig, opts.worktree);
|
|
320
308
|
const errors: string[] = [];
|
|
321
309
|
|
|
322
310
|
if (!loaded.worker?.system) errors.push("Missing worker.system");
|
|
@@ -336,13 +324,14 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
336
324
|
}
|
|
337
325
|
}
|
|
338
326
|
|
|
327
|
+
const label = opts.worktree ? `worktree ${opts.worktree}` : "global";
|
|
339
328
|
if (errors.length > 0) {
|
|
340
|
-
console.log(
|
|
329
|
+
console.log(`\nValidation FAILED (${label}):\n`);
|
|
341
330
|
for (const e of errors) console.log(` - ${e}`);
|
|
342
331
|
console.log();
|
|
343
332
|
process.exitCode = 1;
|
|
344
333
|
} else {
|
|
345
|
-
console.log(
|
|
334
|
+
console.log(`\nValidation PASSED (${label}) — all sections and template variables present.\n`);
|
|
346
335
|
}
|
|
347
336
|
} catch (err) {
|
|
348
337
|
console.error(`\nFailed to load prompts: ${err}\n`);
|
|
@@ -350,6 +339,85 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
350
339
|
}
|
|
351
340
|
});
|
|
352
341
|
|
|
342
|
+
prompts
|
|
343
|
+
.command("init")
|
|
344
|
+
.description("Scaffold per-project .claw/prompts.yaml in a worktree")
|
|
345
|
+
.argument("<worktree-path>", "Path to the worktree")
|
|
346
|
+
.action(async (worktreePath: string) => {
|
|
347
|
+
const { mkdirSync, writeFileSync: writeFS } = await import("node:fs");
|
|
348
|
+
const clawDir = join(worktreePath, ".claw");
|
|
349
|
+
const promptsFile = join(clawDir, "prompts.yaml");
|
|
350
|
+
|
|
351
|
+
if (existsSync(promptsFile)) {
|
|
352
|
+
console.log(`\n ${promptsFile} already exists.\n`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
mkdirSync(clawDir, { recursive: true });
|
|
357
|
+
writeFS(promptsFile, [
|
|
358
|
+
"# Per-project prompt overrides for Linear Agent pipeline.",
|
|
359
|
+
"# Only include sections/fields you want to override.",
|
|
360
|
+
"# Unspecified fields inherit from the global prompts.yaml.",
|
|
361
|
+
"#",
|
|
362
|
+
"# Available sections: worker, audit, rework",
|
|
363
|
+
"# Template variables: {{identifier}}, {{title}}, {{description}}, {{worktreePath}}, {{tier}}, {{attempt}}, {{gaps}}",
|
|
364
|
+
"",
|
|
365
|
+
"# worker:",
|
|
366
|
+
"# system: \"Custom system prompt for workers in this project.\"",
|
|
367
|
+
"# task: \"Implement issue {{identifier}}: {{title}}\\n\\n{{description}}\\n\\nWorktree: {{worktreePath}}\"",
|
|
368
|
+
"",
|
|
369
|
+
"# audit:",
|
|
370
|
+
"# system: \"Custom audit system prompt for this project.\"",
|
|
371
|
+
"",
|
|
372
|
+
"# rework:",
|
|
373
|
+
"# addendum: \"Custom rework addendum for this project.\"",
|
|
374
|
+
"",
|
|
375
|
+
].join("\n"), "utf-8");
|
|
376
|
+
|
|
377
|
+
console.log(`\n Created: ${promptsFile}`);
|
|
378
|
+
console.log(` Edit this file to customize prompts for this worktree.\n`);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
prompts
|
|
382
|
+
.command("diff")
|
|
383
|
+
.description("Show differences between global and per-project prompts")
|
|
384
|
+
.argument("<worktree-path>", "Path to the worktree")
|
|
385
|
+
.action(async (worktreePath: string) => {
|
|
386
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
387
|
+
clearPromptCache();
|
|
388
|
+
|
|
389
|
+
const global = loadPrompts(pluginConfig);
|
|
390
|
+
const merged = loadPrompts(pluginConfig, worktreePath);
|
|
391
|
+
|
|
392
|
+
const projectFile = join(worktreePath, ".claw", "prompts.yaml");
|
|
393
|
+
if (!existsSync(projectFile)) {
|
|
394
|
+
console.log(`\n No per-project prompts at ${projectFile}`);
|
|
395
|
+
console.log(` Run 'openclaw openclaw-linear prompts init ${worktreePath}' to create one.\n`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log(`\nPrompt diff: global vs ${worktreePath}\n`);
|
|
400
|
+
|
|
401
|
+
let hasDiffs = false;
|
|
402
|
+
for (const section of ["worker", "audit", "rework"] as const) {
|
|
403
|
+
const globalSection = global[section] as Record<string, string>;
|
|
404
|
+
const mergedSection = merged[section] as Record<string, string>;
|
|
405
|
+
for (const [key, val] of Object.entries(mergedSection)) {
|
|
406
|
+
if (globalSection[key] !== val) {
|
|
407
|
+
hasDiffs = true;
|
|
408
|
+
console.log(` ${section}.${key}:`);
|
|
409
|
+
console.log(` global: ${globalSection[key]?.slice(0, 100)}...`);
|
|
410
|
+
console.log(` project: ${val.slice(0, 100)}...`);
|
|
411
|
+
console.log();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!hasDiffs) {
|
|
417
|
+
console.log(" No differences — per-project prompts match global.\n");
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
353
421
|
// --- openclaw openclaw-linear notify ---
|
|
354
422
|
const notifyCmd = linear
|
|
355
423
|
.command("notify")
|
|
@@ -3,6 +3,7 @@ import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { ensureGitignore } from "../pipeline/artifacts.js";
|
|
6
|
+
import type { RepoConfig } from "./multi-repo.js";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
|
|
8
9
|
const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
|
|
@@ -117,6 +118,88 @@ export function createWorktree(
|
|
|
117
118
|
return { path: worktreePath, branch, resumed: false };
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
export interface MultiWorktreeResult {
|
|
122
|
+
/** Parent directory containing all repo worktrees for this issue. */
|
|
123
|
+
parentPath: string;
|
|
124
|
+
worktrees: Array<{
|
|
125
|
+
repoName: string;
|
|
126
|
+
path: string;
|
|
127
|
+
branch: string;
|
|
128
|
+
resumed: boolean;
|
|
129
|
+
}>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create worktrees for multiple repos.
|
|
134
|
+
*
|
|
135
|
+
* Layout: {baseDir}/{issueIdentifier}/{repoName}/
|
|
136
|
+
* Branch: codex/{issueIdentifier} (same branch name in each repo)
|
|
137
|
+
*
|
|
138
|
+
* Each individual repo worktree follows the same idempotent/resume logic
|
|
139
|
+
* as createWorktree: if the worktree or branch already exists, it resumes.
|
|
140
|
+
*/
|
|
141
|
+
export function createMultiWorktree(
|
|
142
|
+
identifier: string,
|
|
143
|
+
repos: RepoConfig[],
|
|
144
|
+
opts?: { baseDir?: string },
|
|
145
|
+
): MultiWorktreeResult {
|
|
146
|
+
const baseDir = resolveBaseDir(opts?.baseDir);
|
|
147
|
+
const parentPath = path.join(baseDir, identifier);
|
|
148
|
+
|
|
149
|
+
// Ensure parent directory exists
|
|
150
|
+
if (!existsSync(parentPath)) {
|
|
151
|
+
mkdirSync(parentPath, { recursive: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const branch = `codex/${identifier}`;
|
|
155
|
+
const worktrees: MultiWorktreeResult["worktrees"] = [];
|
|
156
|
+
|
|
157
|
+
for (const repo of repos) {
|
|
158
|
+
if (!existsSync(repo.path)) {
|
|
159
|
+
throw new Error(`Repo not found: ${repo.name} at ${repo.path}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const worktreePath = path.join(parentPath, repo.name);
|
|
163
|
+
|
|
164
|
+
// Fetch latest from origin (best effort)
|
|
165
|
+
try {
|
|
166
|
+
git(["fetch", "origin"], repo.path);
|
|
167
|
+
} catch {
|
|
168
|
+
// Offline or no remote — continue with local state
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Idempotent: if worktree already exists, resume it
|
|
172
|
+
if (existsSync(worktreePath)) {
|
|
173
|
+
try {
|
|
174
|
+
git(["rev-parse", "--git-dir"], worktreePath);
|
|
175
|
+
ensureGitignore(worktreePath);
|
|
176
|
+
worktrees.push({ repoName: repo.name, path: worktreePath, branch, resumed: true });
|
|
177
|
+
continue;
|
|
178
|
+
} catch {
|
|
179
|
+
// Directory exists but isn't a valid worktree — remove and recreate
|
|
180
|
+
try {
|
|
181
|
+
git(["worktree", "remove", "--force", worktreePath], repo.path);
|
|
182
|
+
} catch { /* best effort */ }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check if branch already exists (resume scenario)
|
|
187
|
+
const exists = branchExistsInRepo(branch, repo.path);
|
|
188
|
+
|
|
189
|
+
if (exists) {
|
|
190
|
+
git(["worktree", "add", worktreePath, branch], repo.path);
|
|
191
|
+
ensureGitignore(worktreePath);
|
|
192
|
+
worktrees.push({ repoName: repo.name, path: worktreePath, branch, resumed: true });
|
|
193
|
+
} else {
|
|
194
|
+
git(["worktree", "add", "-b", branch, worktreePath], repo.path);
|
|
195
|
+
ensureGitignore(worktreePath);
|
|
196
|
+
worktrees.push({ repoName: repo.name, path: worktreePath, branch, resumed: false });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { parentPath, worktrees };
|
|
201
|
+
}
|
|
202
|
+
|
|
120
203
|
/**
|
|
121
204
|
* Check if a branch exists in the repo.
|
|
122
205
|
*/
|
|
@@ -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
|
+
}
|