@calltelemetry/openclaw-linear 0.4.0 → 0.5.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 +263 -249
- package/index.ts +108 -1
- package/openclaw.plugin.json +6 -1
- package/package.json +5 -1
- package/prompts.yaml +61 -0
- package/src/active-session.ts +40 -0
- package/src/cli.ts +103 -0
- package/src/code-tool.ts +2 -2
- package/src/codex-worktree.ts +162 -36
- package/src/dispatch-service.ts +161 -0
- package/src/dispatch-state.ts +497 -0
- package/src/notify.ts +91 -0
- package/src/pipeline.ts +582 -198
- package/src/tier-assess.ts +157 -0
- package/src/webhook.ts +232 -197
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,161 @@
|
|
|
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
|
+
listRecoverableDispatches,
|
|
20
|
+
transitionDispatch,
|
|
21
|
+
TransitionError,
|
|
22
|
+
removeActiveDispatch,
|
|
23
|
+
pruneCompleted,
|
|
24
|
+
} from "./dispatch-state.js";
|
|
25
|
+
import { getWorktreeStatus } from "./codex-worktree.js";
|
|
26
|
+
|
|
27
|
+
const INTERVAL_MS = 5 * 60_000; // 5 minutes
|
|
28
|
+
const STALE_THRESHOLD_MS = 2 * 60 * 60_000; // 2 hours
|
|
29
|
+
const COMPLETED_MAX_AGE_MS = 7 * 24 * 60 * 60_000; // 7 days
|
|
30
|
+
|
|
31
|
+
type ServiceContext = {
|
|
32
|
+
logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createDispatchService(api: OpenClawPluginApi) {
|
|
36
|
+
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
37
|
+
|
|
38
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
39
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: "linear-dispatch-monitor",
|
|
43
|
+
|
|
44
|
+
start: async (ctx: ServiceContext) => {
|
|
45
|
+
// Hydrate active sessions on startup
|
|
46
|
+
try {
|
|
47
|
+
const restored = await hydrateFromDispatchState(statePath);
|
|
48
|
+
if (restored > 0) {
|
|
49
|
+
ctx.logger.info(`linear-dispatch: hydrated ${restored} active session(s) from dispatch state`);
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
ctx.logger.warn(`linear-dispatch: hydration failed: ${err}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Recovery scan: find dispatches stuck in "working" with a workerSessionKey
|
|
56
|
+
// but no auditSessionKey (worker completed but audit wasn't triggered before crash)
|
|
57
|
+
try {
|
|
58
|
+
const state = await readDispatchState(statePath);
|
|
59
|
+
const recoverable = listRecoverableDispatches(state);
|
|
60
|
+
for (const d of recoverable) {
|
|
61
|
+
ctx.logger.warn(
|
|
62
|
+
`linear-dispatch: recoverable dispatch ${d.issueIdentifier} ` +
|
|
63
|
+
`(status: ${d.status}, attempt: ${d.attempt}, workerKey: ${d.workerSessionKey}, auditKey: ${d.auditSessionKey ?? "none"})`,
|
|
64
|
+
);
|
|
65
|
+
// Mark as stuck for manual review — automated recovery requires
|
|
66
|
+
// re-triggering audit which needs the full HookContext (Linear API, notifier).
|
|
67
|
+
// The dispatch monitor logs a warning; operator can re-dispatch.
|
|
68
|
+
}
|
|
69
|
+
if (recoverable.length > 0) {
|
|
70
|
+
ctx.logger.warn(`linear-dispatch: ${recoverable.length} dispatch(es) need recovery — consider re-dispatching`);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
ctx.logger.warn(`linear-dispatch: recovery scan failed: ${err}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ctx.logger.info(`linear-dispatch: service started (interval: ${INTERVAL_MS / 1000}s)`);
|
|
77
|
+
|
|
78
|
+
intervalId = setInterval(() => runTick(ctx), INTERVAL_MS);
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
stop: async (ctx: ServiceContext) => {
|
|
82
|
+
if (intervalId) {
|
|
83
|
+
clearInterval(intervalId);
|
|
84
|
+
intervalId = null;
|
|
85
|
+
ctx.logger.info("linear-dispatch: service stopped");
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
async function runTick(ctx: ServiceContext): Promise<void> {
|
|
91
|
+
try {
|
|
92
|
+
const state = await readDispatchState(statePath);
|
|
93
|
+
const activeCount = Object.keys(state.dispatches.active).length;
|
|
94
|
+
|
|
95
|
+
// Skip tick if nothing to do
|
|
96
|
+
if (activeCount === 0 && Object.keys(state.dispatches.completed).length === 0) return;
|
|
97
|
+
|
|
98
|
+
// 1. Stale dispatch detection — transition truly stale dispatches to "stuck"
|
|
99
|
+
const stale = listStaleDispatches(state, STALE_THRESHOLD_MS);
|
|
100
|
+
for (const dispatch of stale) {
|
|
101
|
+
// Skip terminal states
|
|
102
|
+
if (dispatch.status === "done" || dispatch.status === "failed" || dispatch.status === "stuck") {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if worktree still exists and has progress
|
|
107
|
+
if (existsSync(dispatch.worktreePath)) {
|
|
108
|
+
const status = getWorktreeStatus(dispatch.worktreePath);
|
|
109
|
+
if (status.hasUncommitted || status.lastCommit) {
|
|
110
|
+
// Worktree has activity — not truly stale, just slow
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ctx.logger.warn(
|
|
116
|
+
`linear-dispatch: stale dispatch ${dispatch.issueIdentifier} ` +
|
|
117
|
+
`(dispatched ${dispatch.dispatchedAt}, status: ${dispatch.status}) — transitioning to stuck`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Try to transition to stuck
|
|
121
|
+
try {
|
|
122
|
+
await transitionDispatch(
|
|
123
|
+
dispatch.issueIdentifier,
|
|
124
|
+
dispatch.status,
|
|
125
|
+
"stuck",
|
|
126
|
+
{ stuckReason: `stale_${Math.round((Date.now() - new Date(dispatch.dispatchedAt).getTime()) / 3_600_000)}h` },
|
|
127
|
+
statePath,
|
|
128
|
+
);
|
|
129
|
+
ctx.logger.info(`linear-dispatch: ${dispatch.issueIdentifier} marked as stuck`);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof TransitionError) {
|
|
132
|
+
ctx.logger.info(`linear-dispatch: CAS failed for stale transition: ${(err as TransitionError).message}`);
|
|
133
|
+
} else {
|
|
134
|
+
ctx.logger.error(`linear-dispatch: stale transition error: ${err}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. Worktree health — verify active dispatches have valid worktrees
|
|
140
|
+
for (const [id, dispatch] of Object.entries(state.dispatches.active)) {
|
|
141
|
+
if (!existsSync(dispatch.worktreePath)) {
|
|
142
|
+
ctx.logger.warn(
|
|
143
|
+
`linear-dispatch: worktree missing for ${id} at ${dispatch.worktreePath}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 3. Prune old completed entries
|
|
149
|
+
const pruned = await pruneCompleted(COMPLETED_MAX_AGE_MS, statePath);
|
|
150
|
+
if (pruned > 0) {
|
|
151
|
+
ctx.logger.info(`linear-dispatch: pruned ${pruned} old completed dispatch(es)`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (activeCount > 0) {
|
|
155
|
+
ctx.logger.info(`linear-dispatch: tick — ${activeCount} active, ${stale.length} stale`);
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
ctx.logger.error(`linear-dispatch: tick failed: ${err}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|