@calltelemetry/openclaw-linear 0.4.0 → 0.4.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/index.ts +4 -0
- package/openclaw.plugin.json +3 -1
- package/package.json +1 -1
- package/src/active-session.ts +40 -0
- package/src/code-tool.ts +2 -2
- package/src/codex-worktree.ts +162 -36
- package/src/dispatch-service.ts +113 -0
- package/src/dispatch-state.ts +265 -0
- package/src/pipeline.ts +311 -82
- package/src/tier-assess.ts +157 -0
- package/src/webhook.ts +223 -197
package/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createLinearTools } from "./src/tools.js";
|
|
|
6
6
|
import { handleLinearWebhook } from "./src/webhook.js";
|
|
7
7
|
import { handleOAuthCallback } from "./src/oauth-callback.js";
|
|
8
8
|
import { resolveLinearToken } from "./src/linear-api.js";
|
|
9
|
+
import { createDispatchService } from "./src/dispatch-service.js";
|
|
9
10
|
|
|
10
11
|
export default function register(api: OpenClawPluginApi) {
|
|
11
12
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
@@ -56,6 +57,9 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
56
57
|
},
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
// Register dispatch monitor service (stale detection, session hydration, cleanup)
|
|
61
|
+
api.registerService(createDispatchService(api));
|
|
62
|
+
|
|
59
63
|
// Narration Guard: catch short "Let me explore..." responses that narrate intent
|
|
60
64
|
// without actually calling tools, and append a warning for the user.
|
|
61
65
|
const NARRATION_PATTERNS = [
|
package/openclaw.plugin.json
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"codexBaseRepo": { "type": "string", "description": "Path to git repo for Codex worktrees", "default": "/home/claw/ai-workspace" },
|
|
18
18
|
"codexModel": { "type": "string", "description": "Default Codex model (optional — uses Codex default if omitted)" },
|
|
19
19
|
"codexTimeoutMs": { "type": "number", "description": "Default Codex timeout in milliseconds", "default": 600000 },
|
|
20
|
-
"enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true }
|
|
20
|
+
"enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true },
|
|
21
|
+
"worktreeBaseDir": { "type": "string", "description": "Base directory for persistent git worktrees (default: ~/.openclaw/worktrees)" },
|
|
22
|
+
"dispatchStatePath": { "type": "string", "description": "Path to dispatch state JSON file (default: ~/.openclaw/linear-dispatch-state.json)" }
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
}
|
package/package.json
CHANGED
package/src/active-session.ts
CHANGED
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
*
|
|
8
8
|
* This runs in the gateway process. Tool execution also happens in the gateway,
|
|
9
9
|
* so tools can read from this registry directly.
|
|
10
|
+
*
|
|
11
|
+
* The in-memory Map is the fast-path for tool lookups. On startup, the
|
|
12
|
+
* dispatch service calls hydrateFromDispatchState() to rebuild it from
|
|
13
|
+
* the persistent dispatch-state.json file.
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
import { readDispatchState } from "./dispatch-state.js";
|
|
17
|
+
|
|
12
18
|
export interface ActiveSession {
|
|
13
19
|
agentSessionId: string;
|
|
14
20
|
issueIdentifier: string;
|
|
@@ -64,3 +70,37 @@ export function getCurrentSession(): ActiveSession | null {
|
|
|
64
70
|
}
|
|
65
71
|
return null;
|
|
66
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Hydrate the in-memory session Map from dispatch-state.json.
|
|
76
|
+
* Called on startup by the dispatch service to restore sessions
|
|
77
|
+
* that were active before a gateway restart.
|
|
78
|
+
*
|
|
79
|
+
* Returns the number of sessions restored.
|
|
80
|
+
*/
|
|
81
|
+
export async function hydrateFromDispatchState(configPath?: string): Promise<number> {
|
|
82
|
+
const state = await readDispatchState(configPath);
|
|
83
|
+
const active = state.dispatches.active;
|
|
84
|
+
let restored = 0;
|
|
85
|
+
|
|
86
|
+
for (const [, dispatch] of Object.entries(active)) {
|
|
87
|
+
if (dispatch.status === "dispatched" || dispatch.status === "running") {
|
|
88
|
+
sessions.set(dispatch.issueId, {
|
|
89
|
+
agentSessionId: dispatch.agentSessionId ?? "",
|
|
90
|
+
issueIdentifier: dispatch.issueIdentifier,
|
|
91
|
+
issueId: dispatch.issueId,
|
|
92
|
+
startedAt: new Date(dispatch.dispatchedAt).getTime(),
|
|
93
|
+
});
|
|
94
|
+
restored++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return restored;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get the count of currently tracked sessions.
|
|
103
|
+
*/
|
|
104
|
+
export function getSessionCount(): number {
|
|
105
|
+
return sessions.size;
|
|
106
|
+
}
|
package/src/code-tool.ts
CHANGED
|
@@ -30,7 +30,7 @@ interface BackendConfig {
|
|
|
30
30
|
aliases?: string[];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
interface CodingToolsConfig {
|
|
33
|
+
export interface CodingToolsConfig {
|
|
34
34
|
codingTool?: string;
|
|
35
35
|
agentCodingTools?: Record<string, string>;
|
|
36
36
|
backends?: Record<string, BackendConfig>;
|
|
@@ -40,7 +40,7 @@ interface CodingToolsConfig {
|
|
|
40
40
|
* Load coding tool config from the plugin's coding-tools.json file.
|
|
41
41
|
* Falls back to empty config if the file doesn't exist or is invalid.
|
|
42
42
|
*/
|
|
43
|
-
function loadCodingConfig(): CodingToolsConfig {
|
|
43
|
+
export function loadCodingConfig(): CodingToolsConfig {
|
|
44
44
|
try {
|
|
45
45
|
// Resolve relative to the plugin root (one level up from src/)
|
|
46
46
|
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
package/src/codex-worktree.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, statSync, readdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
|
|
4
6
|
const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
|
|
7
|
+
const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
|
|
5
8
|
|
|
6
9
|
export interface WorktreeInfo {
|
|
7
10
|
path: string;
|
|
8
11
|
branch: string;
|
|
12
|
+
/** True if the worktree already existed and was resumed, not freshly created. */
|
|
13
|
+
resumed: boolean;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
export interface WorktreeStatus {
|
|
@@ -14,6 +19,19 @@ export interface WorktreeStatus {
|
|
|
14
19
|
lastCommit: string | null;
|
|
15
20
|
}
|
|
16
21
|
|
|
22
|
+
export interface WorktreeOptions {
|
|
23
|
+
/** Base git repo to create worktrees from. Default: /home/claw/ai-workspace */
|
|
24
|
+
baseRepo?: string;
|
|
25
|
+
/** Directory under which worktrees are created. Default: ~/.openclaw/worktrees */
|
|
26
|
+
baseDir?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveBaseDir(baseDir?: string): string {
|
|
30
|
+
if (!baseDir) return DEFAULT_WORKTREE_BASE_DIR;
|
|
31
|
+
if (baseDir.startsWith("~/")) return baseDir.replace("~", homedir());
|
|
32
|
+
return baseDir;
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
function git(args: string[], cwd: string): string {
|
|
18
36
|
return execFileSync("git", args, {
|
|
19
37
|
cwd,
|
|
@@ -22,42 +40,89 @@ function git(args: string[], cwd: string): string {
|
|
|
22
40
|
}).trim();
|
|
23
41
|
}
|
|
24
42
|
|
|
43
|
+
function gitLong(args: string[], cwd: string, timeout = 120_000): string {
|
|
44
|
+
return execFileSync("git", args, {
|
|
45
|
+
cwd,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
timeout,
|
|
48
|
+
}).trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
/**
|
|
26
|
-
* Create a git worktree for isolated
|
|
52
|
+
* Create a git worktree for isolated work on a Linear issue.
|
|
53
|
+
*
|
|
54
|
+
* Path: {baseDir}/{issueIdentifier}/ — deterministic, persistent.
|
|
27
55
|
* Branch: codex/{issueIdentifier}
|
|
28
|
-
*
|
|
56
|
+
*
|
|
57
|
+
* Idempotent: if the worktree already exists, returns it without recreating.
|
|
58
|
+
* If the branch exists but the worktree is gone, recreates the worktree from
|
|
59
|
+
* the existing branch (resume scenario).
|
|
29
60
|
*/
|
|
30
61
|
export function createWorktree(
|
|
31
62
|
issueIdentifier: string,
|
|
32
|
-
|
|
63
|
+
opts?: WorktreeOptions,
|
|
33
64
|
): WorktreeInfo {
|
|
34
|
-
const repo = baseRepo ?? DEFAULT_BASE_REPO;
|
|
65
|
+
const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
|
|
66
|
+
const baseDir = resolveBaseDir(opts?.baseDir);
|
|
67
|
+
|
|
35
68
|
if (!existsSync(repo)) {
|
|
36
69
|
throw new Error(`Base repo not found: ${repo}`);
|
|
37
70
|
}
|
|
38
71
|
|
|
72
|
+
// Ensure base directory exists
|
|
73
|
+
if (!existsSync(baseDir)) {
|
|
74
|
+
mkdirSync(baseDir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
39
77
|
const branch = `codex/${issueIdentifier}`;
|
|
40
|
-
const
|
|
41
|
-
const worktreePath = `/tmp/codex-${issueIdentifier}-${ts}`;
|
|
78
|
+
const worktreePath = path.join(baseDir, issueIdentifier);
|
|
42
79
|
|
|
43
|
-
//
|
|
80
|
+
// Fetch latest from origin (best effort) — do this early so both
|
|
81
|
+
// resume and fresh paths have up-to-date refs.
|
|
44
82
|
try {
|
|
45
83
|
git(["fetch", "origin"], repo);
|
|
46
84
|
} catch {
|
|
47
85
|
// Offline or no remote — continue with local state
|
|
48
86
|
}
|
|
49
87
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
88
|
+
// Idempotent: if worktree already exists, return it
|
|
89
|
+
if (existsSync(worktreePath)) {
|
|
90
|
+
try {
|
|
91
|
+
// Verify it's a valid git worktree
|
|
92
|
+
git(["rev-parse", "--git-dir"], worktreePath);
|
|
93
|
+
return { path: worktreePath, branch, resumed: true };
|
|
94
|
+
} catch {
|
|
95
|
+
// Directory exists but isn't a valid worktree — remove and recreate
|
|
96
|
+
try {
|
|
97
|
+
git(["worktree", "remove", "--force", worktreePath], repo);
|
|
98
|
+
} catch { /* best effort */ }
|
|
99
|
+
}
|
|
55
100
|
}
|
|
56
101
|
|
|
57
|
-
//
|
|
102
|
+
// Check if branch already exists (resume scenario)
|
|
103
|
+
const branchExists = branchExistsInRepo(branch, repo);
|
|
104
|
+
|
|
105
|
+
if (branchExists) {
|
|
106
|
+
// Recreate worktree from existing branch — preserves previous work
|
|
107
|
+
git(["worktree", "add", worktreePath, branch], repo);
|
|
108
|
+
return { path: worktreePath, branch, resumed: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fresh start: new branch off HEAD
|
|
58
112
|
git(["worktree", "add", "-b", branch, worktreePath], repo);
|
|
113
|
+
return { path: worktreePath, branch, resumed: false };
|
|
114
|
+
}
|
|
59
115
|
|
|
60
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Check if a branch exists in the repo.
|
|
118
|
+
*/
|
|
119
|
+
function branchExistsInRepo(branch: string, repo: string): boolean {
|
|
120
|
+
try {
|
|
121
|
+
const result = git(["branch", "--list", branch], repo);
|
|
122
|
+
return result.trim().length > 0;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
61
126
|
}
|
|
62
127
|
|
|
63
128
|
/**
|
|
@@ -117,19 +182,13 @@ export function removeWorktree(
|
|
|
117
182
|
}
|
|
118
183
|
|
|
119
184
|
if (opts?.deleteBranch) {
|
|
120
|
-
// Extract
|
|
185
|
+
// Extract issue identifier from worktree path to find matching branch
|
|
186
|
+
const dirName = path.basename(worktreePath);
|
|
187
|
+
const branch = `codex/${dirName}`;
|
|
121
188
|
try {
|
|
122
|
-
|
|
123
|
-
// Only delete if it looks like a codex branch
|
|
124
|
-
for (const b of branches.split("\n")) {
|
|
125
|
-
const name = b.trim().replace(/^\* /, "");
|
|
126
|
-
if (name && worktreePath.includes(name.replace("codex/", ""))) {
|
|
127
|
-
git(["branch", "-D", name], repo);
|
|
128
|
-
break;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
189
|
+
git(["branch", "-D", branch], repo);
|
|
131
190
|
} catch {
|
|
132
|
-
//
|
|
191
|
+
// Branch doesn't exist or already deleted
|
|
133
192
|
}
|
|
134
193
|
}
|
|
135
194
|
}
|
|
@@ -175,23 +234,23 @@ export function createPullRequest(
|
|
|
175
234
|
export interface WorktreeEntry {
|
|
176
235
|
path: string;
|
|
177
236
|
branch: string;
|
|
237
|
+
issueIdentifier: string;
|
|
178
238
|
ageMs: number;
|
|
179
239
|
hasChanges: boolean;
|
|
180
240
|
}
|
|
181
241
|
|
|
182
242
|
/**
|
|
183
|
-
* List all
|
|
243
|
+
* List all worktrees in the configured base directory.
|
|
184
244
|
*/
|
|
185
|
-
export function listWorktrees(
|
|
186
|
-
const
|
|
245
|
+
export function listWorktrees(opts?: WorktreeOptions): WorktreeEntry[] {
|
|
246
|
+
const baseDir = resolveBaseDir(opts?.baseDir);
|
|
187
247
|
const entries: WorktreeEntry[] = [];
|
|
188
248
|
|
|
189
|
-
|
|
249
|
+
if (!existsSync(baseDir)) return [];
|
|
250
|
+
|
|
190
251
|
let dirs: string[];
|
|
191
252
|
try {
|
|
192
|
-
dirs = readdirSync(
|
|
193
|
-
.filter((d) => d.startsWith("codex-"))
|
|
194
|
-
.map((d) => `/tmp/${d}`);
|
|
253
|
+
dirs = readdirSync(baseDir).map((d) => path.join(baseDir, d));
|
|
195
254
|
} catch {
|
|
196
255
|
return [];
|
|
197
256
|
}
|
|
@@ -202,6 +261,13 @@ export function listWorktrees(baseRepo?: string): WorktreeEntry[] {
|
|
|
202
261
|
const stat = statSync(dir);
|
|
203
262
|
if (!stat.isDirectory()) continue;
|
|
204
263
|
|
|
264
|
+
// Verify it's a git worktree
|
|
265
|
+
try {
|
|
266
|
+
git(["rev-parse", "--git-dir"], dir);
|
|
267
|
+
} catch {
|
|
268
|
+
continue; // Not a git worktree
|
|
269
|
+
}
|
|
270
|
+
|
|
205
271
|
let branch = "unknown";
|
|
206
272
|
try {
|
|
207
273
|
branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir);
|
|
@@ -216,6 +282,7 @@ export function listWorktrees(baseRepo?: string): WorktreeEntry[] {
|
|
|
216
282
|
entries.push({
|
|
217
283
|
path: dir,
|
|
218
284
|
branch,
|
|
285
|
+
issueIdentifier: path.basename(dir),
|
|
219
286
|
ageMs: Date.now() - stat.mtimeMs,
|
|
220
287
|
hasChanges,
|
|
221
288
|
});
|
|
@@ -227,16 +294,75 @@ export function listWorktrees(baseRepo?: string): WorktreeEntry[] {
|
|
|
227
294
|
return entries.sort((a, b) => b.ageMs - a.ageMs);
|
|
228
295
|
}
|
|
229
296
|
|
|
297
|
+
export interface PrepareResult {
|
|
298
|
+
pulled: boolean;
|
|
299
|
+
pullOutput?: string;
|
|
300
|
+
submodulesInitialized: boolean;
|
|
301
|
+
submoduleOutput?: string;
|
|
302
|
+
errors: string[];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Prepare a worktree for a code run:
|
|
307
|
+
* 1. Pull latest from origin for the issue branch (fast-forward only)
|
|
308
|
+
* 2. Initialize and update all git submodules recursively
|
|
309
|
+
*
|
|
310
|
+
* Safe to call on every run — idempotent. Failures are non-fatal;
|
|
311
|
+
* the code run proceeds even if pull or submodule init fails.
|
|
312
|
+
*/
|
|
313
|
+
export function prepareWorkspace(worktreePath: string, branch: string): PrepareResult {
|
|
314
|
+
const errors: string[] = [];
|
|
315
|
+
let pulled = false;
|
|
316
|
+
let pullOutput: string | undefined;
|
|
317
|
+
let submodulesInitialized = false;
|
|
318
|
+
let submoduleOutput: string | undefined;
|
|
319
|
+
|
|
320
|
+
// 1. Pull latest from origin (ff-only to avoid merge conflicts)
|
|
321
|
+
try {
|
|
322
|
+
// Check if remote branch exists before pulling
|
|
323
|
+
const remoteBranch = `origin/${branch}`;
|
|
324
|
+
try {
|
|
325
|
+
git(["rev-parse", "--verify", remoteBranch], worktreePath);
|
|
326
|
+
// Remote branch exists — pull latest
|
|
327
|
+
pullOutput = git(["pull", "--ff-only", "origin", branch], worktreePath);
|
|
328
|
+
pulled = true;
|
|
329
|
+
} catch {
|
|
330
|
+
// Remote branch doesn't exist yet (fresh issue branch) — nothing to pull
|
|
331
|
+
pullOutput = "remote branch not found, skipping pull";
|
|
332
|
+
}
|
|
333
|
+
} catch (err) {
|
|
334
|
+
const msg = `pull failed: ${err}`;
|
|
335
|
+
errors.push(msg);
|
|
336
|
+
pullOutput = msg;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 2. Initialize and update all submodules recursively
|
|
340
|
+
try {
|
|
341
|
+
submoduleOutput = gitLong(
|
|
342
|
+
["submodule", "update", "--init", "--recursive"],
|
|
343
|
+
worktreePath,
|
|
344
|
+
120_000, // submodule clone can take a while
|
|
345
|
+
);
|
|
346
|
+
submodulesInitialized = true;
|
|
347
|
+
} catch (err) {
|
|
348
|
+
const msg = `submodule init failed: ${err}`;
|
|
349
|
+
errors.push(msg);
|
|
350
|
+
submoduleOutput = msg;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { pulled, pullOutput, submodulesInitialized, submoduleOutput, errors };
|
|
354
|
+
}
|
|
355
|
+
|
|
230
356
|
/**
|
|
231
|
-
* Remove
|
|
357
|
+
* Remove worktrees older than maxAgeMs.
|
|
232
358
|
* Returns list of removed paths.
|
|
233
359
|
*/
|
|
234
360
|
export function pruneStaleWorktrees(
|
|
235
361
|
maxAgeMs: number = 24 * 60 * 60_000,
|
|
236
|
-
opts?:
|
|
362
|
+
opts?: WorktreeOptions & { dryRun?: boolean },
|
|
237
363
|
): { removed: string[]; skipped: string[]; errors: string[] } {
|
|
364
|
+
const worktrees = listWorktrees(opts);
|
|
238
365
|
const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
|
|
239
|
-
const worktrees = listWorktrees(repo);
|
|
240
366
|
const removed: string[] = [];
|
|
241
367
|
const skipped: string[] = [];
|
|
242
368
|
const errors: string[] = [];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch-service.ts — Background service for dispatch health monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Registered via api.registerService(). Runs on a 5-minute interval.
|
|
5
|
+
* Zero LLM tokens — all logic is deterministic code.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Hydrate active sessions from dispatch-state.json on startup
|
|
9
|
+
* - Detect stale dispatches (active >2h with no progress)
|
|
10
|
+
* - Verify worktree health for active dispatches
|
|
11
|
+
* - Prune completed dispatches older than 7 days
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
15
|
+
import { hydrateFromDispatchState } from "./active-session.js";
|
|
16
|
+
import {
|
|
17
|
+
readDispatchState,
|
|
18
|
+
listStaleDispatches,
|
|
19
|
+
removeActiveDispatch,
|
|
20
|
+
pruneCompleted,
|
|
21
|
+
} from "./dispatch-state.js";
|
|
22
|
+
import { getWorktreeStatus } from "./codex-worktree.js";
|
|
23
|
+
|
|
24
|
+
const INTERVAL_MS = 5 * 60_000; // 5 minutes
|
|
25
|
+
const STALE_THRESHOLD_MS = 2 * 60 * 60_000; // 2 hours
|
|
26
|
+
const COMPLETED_MAX_AGE_MS = 7 * 24 * 60 * 60_000; // 7 days
|
|
27
|
+
|
|
28
|
+
type ServiceContext = {
|
|
29
|
+
logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function createDispatchService(api: OpenClawPluginApi) {
|
|
33
|
+
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
|
|
35
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
36
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: "linear-dispatch-monitor",
|
|
40
|
+
|
|
41
|
+
start: async (ctx: ServiceContext) => {
|
|
42
|
+
// Hydrate active sessions on startup
|
|
43
|
+
try {
|
|
44
|
+
const restored = await hydrateFromDispatchState(statePath);
|
|
45
|
+
if (restored > 0) {
|
|
46
|
+
ctx.logger.info(`linear-dispatch: hydrated ${restored} active session(s) from dispatch state`);
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
ctx.logger.warn(`linear-dispatch: hydration failed: ${err}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ctx.logger.info(`linear-dispatch: service started (interval: ${INTERVAL_MS / 1000}s)`);
|
|
53
|
+
|
|
54
|
+
intervalId = setInterval(() => runTick(ctx), INTERVAL_MS);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
stop: async (ctx: ServiceContext) => {
|
|
58
|
+
if (intervalId) {
|
|
59
|
+
clearInterval(intervalId);
|
|
60
|
+
intervalId = null;
|
|
61
|
+
ctx.logger.info("linear-dispatch: service stopped");
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
async function runTick(ctx: ServiceContext): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
const state = await readDispatchState(statePath);
|
|
69
|
+
const activeCount = Object.keys(state.dispatches.active).length;
|
|
70
|
+
|
|
71
|
+
// Skip tick if nothing to do
|
|
72
|
+
if (activeCount === 0 && Object.keys(state.dispatches.completed).length === 0) return;
|
|
73
|
+
|
|
74
|
+
// 1. Stale dispatch detection
|
|
75
|
+
const stale = listStaleDispatches(state, STALE_THRESHOLD_MS);
|
|
76
|
+
for (const dispatch of stale) {
|
|
77
|
+
// Check if worktree still exists and has progress
|
|
78
|
+
if (existsSync(dispatch.worktreePath)) {
|
|
79
|
+
const status = getWorktreeStatus(dispatch.worktreePath);
|
|
80
|
+
if (status.hasUncommitted || status.lastCommit) {
|
|
81
|
+
// Worktree has activity — not truly stale, just slow
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
ctx.logger.warn(
|
|
86
|
+
`linear-dispatch: stale dispatch ${dispatch.issueIdentifier} ` +
|
|
87
|
+
`(dispatched ${dispatch.dispatchedAt}, status: ${dispatch.status})`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2. Worktree health — verify active dispatches have valid worktrees
|
|
92
|
+
for (const [id, dispatch] of Object.entries(state.dispatches.active)) {
|
|
93
|
+
if (!existsSync(dispatch.worktreePath)) {
|
|
94
|
+
ctx.logger.warn(
|
|
95
|
+
`linear-dispatch: worktree missing for ${id} at ${dispatch.worktreePath}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Prune old completed entries
|
|
101
|
+
const pruned = await pruneCompleted(COMPLETED_MAX_AGE_MS, statePath);
|
|
102
|
+
if (pruned > 0) {
|
|
103
|
+
ctx.logger.info(`linear-dispatch: pruned ${pruned} old completed dispatch(es)`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (activeCount > 0) {
|
|
107
|
+
ctx.logger.info(`linear-dispatch: tick — ${activeCount} active, ${stale.length} stale`);
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
ctx.logger.error(`linear-dispatch: tick failed: ${err}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|