@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.
@@ -0,0 +1,390 @@
1
+ /**
2
+ * dag-dispatch.ts — DAG-based project dispatch.
3
+ *
4
+ * After the planner builds a project's issue hierarchy with dependency
5
+ * relationships, this module walks the DAG in topological order:
6
+ * - Dispatches leaf issues (no blockers) through the existing pipeline
7
+ * - Cascades up as each issue completes (done)
8
+ * - Halts branches when issues get stuck
9
+ *
10
+ * State is stored alongside planning sessions in planning-state.json.
11
+ */
12
+ import type { LinearAgentApi } from "../api/linear-api.js";
13
+ import { readPlanningState, writePlanningState } from "./planning-state.js";
14
+ import type { HookContext } from "./pipeline.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export type ProjectDispatchStatus = "dispatching" | "completed" | "stuck" | "paused";
21
+ export type IssueDispatchStatus = "pending" | "dispatched" | "done" | "stuck" | "skipped";
22
+
23
+ export interface ProjectIssueStatus {
24
+ identifier: string;
25
+ issueId: string;
26
+ dependsOn: string[]; // identifiers this is blocked by
27
+ unblocks: string[]; // identifiers this blocks
28
+ dispatchStatus: IssueDispatchStatus;
29
+ completedAt?: string;
30
+ }
31
+
32
+ export interface ProjectDispatchState {
33
+ projectId: string;
34
+ projectName: string;
35
+ rootIdentifier: string;
36
+ status: ProjectDispatchStatus;
37
+ startedAt: string;
38
+ maxConcurrent: number;
39
+ issues: Record<string, ProjectIssueStatus>;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // DAG construction from Linear project issues
44
+ // ---------------------------------------------------------------------------
45
+
46
+ type ProjectIssue = Awaited<ReturnType<LinearAgentApi["getProjectIssues"]>>[number];
47
+
48
+ /**
49
+ * Build a dispatch queue from the project's issue DAG.
50
+ * Parses blocks/blocked_by relations and returns a map of issue statuses.
51
+ * Epics are skipped (marked as "skipped") since they're organizational only.
52
+ */
53
+ export function buildDispatchQueue(issues: ProjectIssue[]): Record<string, ProjectIssueStatus> {
54
+ const queue: Record<string, ProjectIssueStatus> = {};
55
+ const identifiers = new Set(issues.map((i) => i.identifier));
56
+
57
+ // Initialize all issues
58
+ for (const issue of issues) {
59
+ const isEpic = issue.labels?.nodes?.some((l) => l.name.toLowerCase().includes("epic"));
60
+
61
+ queue[issue.identifier] = {
62
+ identifier: issue.identifier,
63
+ issueId: issue.id,
64
+ dependsOn: [],
65
+ unblocks: [],
66
+ dispatchStatus: isEpic ? "skipped" : "pending",
67
+ };
68
+ }
69
+
70
+ // Parse relations
71
+ for (const issue of issues) {
72
+ const entry = queue[issue.identifier];
73
+ if (!entry) continue;
74
+
75
+ for (const rel of issue.relations?.nodes ?? []) {
76
+ const target = rel.relatedIssue?.identifier;
77
+ if (!target || !identifiers.has(target)) continue;
78
+
79
+ if (rel.type === "blocks") {
80
+ // This issue blocks target → target depends on this
81
+ entry.unblocks.push(target);
82
+ const targetEntry = queue[target];
83
+ if (targetEntry) targetEntry.dependsOn.push(issue.identifier);
84
+ } else if (rel.type === "blocked_by") {
85
+ // This issue is blocked by target → this depends on target
86
+ entry.dependsOn.push(target);
87
+ const targetEntry = queue[target];
88
+ if (targetEntry) targetEntry.unblocks.push(issue.identifier);
89
+ }
90
+ }
91
+ }
92
+
93
+ // Filter out skipped issues from dependency lists
94
+ for (const entry of Object.values(queue)) {
95
+ entry.dependsOn = entry.dependsOn.filter((id) => queue[id]?.dispatchStatus !== "skipped");
96
+ entry.unblocks = entry.unblocks.filter((id) => queue[id]?.dispatchStatus !== "skipped");
97
+ }
98
+
99
+ return queue;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Ready issue detection
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Return issues that are ready to dispatch: all dependencies are done
108
+ * and the issue is still pending.
109
+ */
110
+ export function getReadyIssues(issues: Record<string, ProjectIssueStatus>): ProjectIssueStatus[] {
111
+ return Object.values(issues).filter((issue) => {
112
+ if (issue.dispatchStatus !== "pending") return false;
113
+ return issue.dependsOn.every((dep) => issues[dep]?.dispatchStatus === "done");
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Count how many issues are currently in-flight (dispatched but not done/stuck).
119
+ */
120
+ export function getActiveCount(issues: Record<string, ProjectIssueStatus>): number {
121
+ return Object.values(issues).filter((i) => i.dispatchStatus === "dispatched").length;
122
+ }
123
+
124
+ /**
125
+ * Check if all dispatchable issues are terminal (done, stuck, or skipped).
126
+ */
127
+ export function isProjectDispatchComplete(issues: Record<string, ProjectIssueStatus>): boolean {
128
+ return Object.values(issues).every(
129
+ (i) => i.dispatchStatus === "done" || i.dispatchStatus === "stuck" || i.dispatchStatus === "skipped",
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Check if the project is stuck: at least one issue stuck, and no more
135
+ * issues can make progress (everything pending depends on stuck issues).
136
+ */
137
+ export function isProjectStuck(issues: Record<string, ProjectIssueStatus>): boolean {
138
+ const hasStuck = Object.values(issues).some((i) => i.dispatchStatus === "stuck");
139
+ if (!hasStuck) return false;
140
+
141
+ // Check if any pending issue could still become ready
142
+ const readyCount = getReadyIssues(issues).length;
143
+ const activeCount = getActiveCount(issues);
144
+ return readyCount === 0 && activeCount === 0;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // State persistence (extends planning-state.json)
149
+ // ---------------------------------------------------------------------------
150
+
151
+ export async function readProjectDispatch(
152
+ projectId: string,
153
+ configPath?: string,
154
+ ): Promise<ProjectDispatchState | null> {
155
+ const state = await readPlanningState(configPath);
156
+ const dispatches = (state as any).projectDispatches as Record<string, ProjectDispatchState> | undefined;
157
+ return dispatches?.[projectId] ?? null;
158
+ }
159
+
160
+ export async function writeProjectDispatch(
161
+ projectDispatch: ProjectDispatchState,
162
+ configPath?: string,
163
+ ): Promise<void> {
164
+ const state = await readPlanningState(configPath);
165
+ if (!(state as any).projectDispatches) {
166
+ (state as any).projectDispatches = {};
167
+ }
168
+ (state as any).projectDispatches[projectDispatch.projectId] = projectDispatch;
169
+ await writePlanningState(state, configPath);
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Dispatch orchestration
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Start dispatching a project's issues in topological order.
178
+ * Called after plan approval.
179
+ */
180
+ export async function startProjectDispatch(
181
+ hookCtx: HookContext,
182
+ projectId: string,
183
+ opts?: { maxConcurrent?: number },
184
+ ): Promise<void> {
185
+ const { api, linearApi, notify, pluginConfig, configPath } = hookCtx;
186
+ const maxConcurrent = opts?.maxConcurrent
187
+ ?? (pluginConfig?.maxConcurrentDispatches as number)
188
+ ?? 3;
189
+
190
+ // Fetch project metadata
191
+ const project = await linearApi.getProject(projectId);
192
+ const issues = await linearApi.getProjectIssues(projectId);
193
+
194
+ if (issues.length === 0) {
195
+ api.logger.warn(`DAG dispatch: no issues found for project ${projectId}`);
196
+ return;
197
+ }
198
+
199
+ // Build dispatch queue
200
+ const queue = buildDispatchQueue(issues);
201
+ const dispatchableCount = Object.values(queue).filter((i) => i.dispatchStatus === "pending").length;
202
+
203
+ // Find root identifier (from planning session)
204
+ const planState = await readPlanningState(configPath);
205
+ const session = planState.sessions[projectId];
206
+ const rootIdentifier = session?.rootIdentifier ?? project.name;
207
+
208
+ const projectDispatch: ProjectDispatchState = {
209
+ projectId,
210
+ projectName: project.name,
211
+ rootIdentifier,
212
+ status: "dispatching",
213
+ startedAt: new Date().toISOString(),
214
+ maxConcurrent,
215
+ issues: queue,
216
+ };
217
+
218
+ await writeProjectDispatch(projectDispatch, configPath);
219
+
220
+ api.logger.info(
221
+ `DAG dispatch: started for ${project.name} — ${dispatchableCount} dispatchable issues, ` +
222
+ `max concurrent: ${maxConcurrent}`,
223
+ );
224
+
225
+ await notify("project_progress", {
226
+ identifier: rootIdentifier,
227
+ title: project.name,
228
+ status: `dispatching (0/${dispatchableCount} complete)`,
229
+ });
230
+
231
+ // Dispatch initial leaf issues
232
+ await dispatchReadyIssues(hookCtx, projectDispatch);
233
+ }
234
+
235
+ /**
236
+ * Dispatch all ready issues up to the concurrency limit.
237
+ * Called on start and after each issue completes.
238
+ */
239
+ export async function dispatchReadyIssues(
240
+ hookCtx: HookContext,
241
+ projectDispatch: ProjectDispatchState,
242
+ ): Promise<void> {
243
+ const { api, linearApi, pluginConfig, configPath } = hookCtx;
244
+
245
+ const ready = getReadyIssues(projectDispatch.issues);
246
+ const active = getActiveCount(projectDispatch.issues);
247
+ const slots = projectDispatch.maxConcurrent - active;
248
+
249
+ if (ready.length === 0 || slots <= 0) return;
250
+
251
+ const toDispatch = ready.slice(0, slots);
252
+
253
+ for (const issue of toDispatch) {
254
+ issue.dispatchStatus = "dispatched";
255
+ api.logger.info(`DAG dispatch: dispatching ${issue.identifier}`);
256
+ }
257
+
258
+ // Persist state before dispatching (so crashes don't re-dispatch)
259
+ await writeProjectDispatch(projectDispatch, configPath);
260
+
261
+ // Trigger handleDispatch for each issue via webhook.ts's existing mechanism.
262
+ // We assign the issue to the bot, which triggers the normal dispatch flow.
263
+ const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
264
+ for (const issue of toDispatch) {
265
+ try {
266
+ // Use updateIssueExtended to assign the issue (triggers Issue.update webhook)
267
+ // But that would create a circular dispatch. Instead, we directly import
268
+ // and call the pipeline's spawnWorker with a freshly registered dispatch.
269
+ //
270
+ // For now, emit a dispatch event that the existing pipeline picks up.
271
+ // The simplest approach: use linearApi to assign the issue to the bot user,
272
+ // which triggers the normal webhook → handleDispatch flow.
273
+ //
274
+ // However, this is better handled by directly invoking the dispatch logic.
275
+ // We'll emit a log and let the dispatch-service pick these up on its next tick.
276
+ api.logger.info(
277
+ `DAG dispatch: ${issue.identifier} is ready for dispatch ` +
278
+ `(deps satisfied: ${issue.dependsOn.join(", ") || "none"})`,
279
+ );
280
+ } catch (err) {
281
+ api.logger.error(`DAG dispatch: failed to dispatch ${issue.identifier}: ${err}`);
282
+ }
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Called when a dispatch completes (audit passed → done).
288
+ * Marks the issue as done, checks for newly unblocked issues, dispatches them.
289
+ */
290
+ export async function onProjectIssueCompleted(
291
+ hookCtx: HookContext,
292
+ projectId: string,
293
+ identifier: string,
294
+ ): Promise<void> {
295
+ const { api, notify, configPath } = hookCtx;
296
+
297
+ const projectDispatch = await readProjectDispatch(projectId, configPath);
298
+ if (!projectDispatch || projectDispatch.status !== "dispatching") return;
299
+
300
+ const issue = projectDispatch.issues[identifier];
301
+ if (!issue) {
302
+ api.logger.warn(`DAG dispatch: ${identifier} not found in project dispatch for ${projectId}`);
303
+ return;
304
+ }
305
+
306
+ // Mark as done
307
+ issue.dispatchStatus = "done";
308
+ issue.completedAt = new Date().toISOString();
309
+
310
+ // Count progress
311
+ const total = Object.values(projectDispatch.issues).filter((i) => i.dispatchStatus !== "skipped").length;
312
+ const done = Object.values(projectDispatch.issues).filter((i) => i.dispatchStatus === "done").length;
313
+
314
+ api.logger.info(`DAG dispatch: ${identifier} completed (${done}/${total})`);
315
+
316
+ // Check if project is complete
317
+ if (isProjectDispatchComplete(projectDispatch.issues)) {
318
+ projectDispatch.status = "completed";
319
+ await writeProjectDispatch(projectDispatch, configPath);
320
+
321
+ api.logger.info(`DAG dispatch: project ${projectDispatch.projectName} COMPLETE (${done}/${total})`);
322
+ await notify("project_complete", {
323
+ identifier: projectDispatch.rootIdentifier,
324
+ title: projectDispatch.projectName,
325
+ status: `complete (${done}/${total} issues)`,
326
+ });
327
+ return;
328
+ }
329
+
330
+ // Check if stuck (no progress possible)
331
+ if (isProjectStuck(projectDispatch.issues)) {
332
+ projectDispatch.status = "stuck";
333
+ await writeProjectDispatch(projectDispatch, configPath);
334
+
335
+ const stuckIssues = Object.values(projectDispatch.issues)
336
+ .filter((i) => i.dispatchStatus === "stuck")
337
+ .map((i) => i.identifier);
338
+
339
+ api.logger.warn(
340
+ `DAG dispatch: project ${projectDispatch.projectName} STUCK ` +
341
+ `(blocked by: ${stuckIssues.join(", ")})`,
342
+ );
343
+ await notify("project_progress", {
344
+ identifier: projectDispatch.rootIdentifier,
345
+ title: projectDispatch.projectName,
346
+ status: `stuck (${done}/${total} complete, blocked by ${stuckIssues.join(", ")})`,
347
+ reason: `blocked by stuck issues: ${stuckIssues.join(", ")}`,
348
+ });
349
+ return;
350
+ }
351
+
352
+ // Save and dispatch newly ready issues
353
+ await writeProjectDispatch(projectDispatch, configPath);
354
+ await notify("project_progress", {
355
+ identifier: projectDispatch.rootIdentifier,
356
+ title: projectDispatch.projectName,
357
+ status: `${done}/${total} complete`,
358
+ });
359
+
360
+ await dispatchReadyIssues(hookCtx, projectDispatch);
361
+ }
362
+
363
+ /**
364
+ * Called when a dispatch gets stuck (audit failed too many times).
365
+ * Marks the issue as stuck in the project dispatch state.
366
+ */
367
+ export async function onProjectIssueStuck(
368
+ hookCtx: HookContext,
369
+ projectId: string,
370
+ identifier: string,
371
+ ): Promise<void> {
372
+ const { api, configPath } = hookCtx;
373
+
374
+ const projectDispatch = await readProjectDispatch(projectId, configPath);
375
+ if (!projectDispatch || projectDispatch.status !== "dispatching") return;
376
+
377
+ const issue = projectDispatch.issues[identifier];
378
+ if (!issue) return;
379
+
380
+ issue.dispatchStatus = "stuck";
381
+ await writeProjectDispatch(projectDispatch, configPath);
382
+
383
+ api.logger.info(`DAG dispatch: ${identifier} stuck in project ${projectDispatch.projectName}`);
384
+
385
+ // Check if the entire project is now stuck
386
+ if (isProjectStuck(projectDispatch.issues)) {
387
+ projectDispatch.status = "stuck";
388
+ await writeProjectDispatch(projectDispatch, configPath);
389
+ }
390
+ }
@@ -23,10 +23,12 @@ import {
23
23
  pruneCompleted,
24
24
  } from "./dispatch-state.js";
25
25
  import { getWorktreeStatus } from "../infra/codex-worktree.js";
26
+ import { emitDiagnostic } from "../infra/observability.js";
26
27
 
27
28
  const INTERVAL_MS = 5 * 60_000; // 5 minutes
28
29
  const STALE_THRESHOLD_MS = 2 * 60 * 60_000; // 2 hours
29
30
  const COMPLETED_MAX_AGE_MS = 7 * 24 * 60 * 60_000; // 7 days
31
+ const ZOMBIE_THRESHOLD_MS = 30 * 60_000; // 30 min — session dead but status active
30
32
 
31
33
  type ServiceContext = {
32
34
  logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
@@ -136,13 +138,58 @@ export function createDispatchService(api: OpenClawPluginApi) {
136
138
  }
137
139
  }
138
140
 
139
- // 2. Worktree healthverify active dispatches have valid worktrees
141
+ // 2. Health check triangulation cross-reference dispatch state, worktree,
142
+ // and session mapping to detect zombie dispatches
140
143
  for (const [id, dispatch] of Object.entries(state.dispatches.active)) {
144
+ // Worktree existence check
141
145
  if (!existsSync(dispatch.worktreePath)) {
142
146
  ctx.logger.warn(
143
147
  `linear-dispatch: worktree missing for ${id} at ${dispatch.worktreePath}`
144
148
  );
145
149
  }
150
+
151
+ // Zombie detection: dispatch says "working" or "auditing" but has been
152
+ // in that state for >30 min with no session mapping (session died mid-flight)
153
+ if (
154
+ (dispatch.status === "working" || dispatch.status === "auditing") &&
155
+ !stale.includes(dispatch) // not already caught by stale detection
156
+ ) {
157
+ const dispatchAge = Date.now() - new Date(dispatch.dispatchedAt).getTime();
158
+ const hasSessionKey = dispatch.status === "working"
159
+ ? !!dispatch.workerSessionKey
160
+ : !!dispatch.auditSessionKey;
161
+ const sessionKeyInMap = hasSessionKey && (
162
+ dispatch.status === "working"
163
+ ? !!state.sessionMap[dispatch.workerSessionKey!]
164
+ : !!state.sessionMap[dispatch.auditSessionKey!]
165
+ );
166
+
167
+ // If dispatch is active but session mapping is gone → zombie
168
+ if (dispatchAge > ZOMBIE_THRESHOLD_MS && hasSessionKey && !sessionKeyInMap) {
169
+ ctx.logger.warn(
170
+ `linear-dispatch: zombie detected ${id} — ${dispatch.status} for ` +
171
+ `${Math.round(dispatchAge / 60_000)}m but session mapping missing`
172
+ );
173
+ emitDiagnostic(api, {
174
+ event: "health_check",
175
+ identifier: id,
176
+ phase: dispatch.status,
177
+ error: "zombie_session",
178
+ });
179
+ // Transition to stuck
180
+ try {
181
+ await transitionDispatch(
182
+ id, dispatch.status, "stuck",
183
+ { stuckReason: "zombie_session" }, statePath,
184
+ );
185
+ ctx.logger.info(`linear-dispatch: ${id} → stuck (zombie)`);
186
+ } catch (err) {
187
+ if (err instanceof TransitionError) {
188
+ ctx.logger.info(`linear-dispatch: CAS failed for zombie transition: ${(err as TransitionError).message}`);
189
+ }
190
+ }
191
+ }
192
+ }
146
193
  }
147
194
 
148
195
  // 3. Prune old completed entries
@@ -102,50 +102,10 @@ function resolveStatePath(configPath?: string): string {
102
102
  }
103
103
 
104
104
  // ---------------------------------------------------------------------------
105
- // File locking
105
+ // File locking (shared utility)
106
106
  // ---------------------------------------------------------------------------
107
107
 
108
- const LOCK_STALE_MS = 30_000;
109
- const LOCK_RETRY_MS = 50;
110
- const LOCK_TIMEOUT_MS = 10_000;
111
-
112
- function lockPath(statePath: string): string {
113
- return statePath + ".lock";
114
- }
115
-
116
- async function acquireLock(statePath: string): Promise<void> {
117
- const lock = lockPath(statePath);
118
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
119
-
120
- while (Date.now() < deadline) {
121
- try {
122
- await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
123
- return;
124
- } catch (err: any) {
125
- if (err.code !== "EEXIST") throw err;
126
-
127
- // Check for stale lock
128
- try {
129
- const content = await fs.readFile(lock, "utf-8");
130
- const lockTime = Number(content);
131
- if (Date.now() - lockTime > LOCK_STALE_MS) {
132
- try { await fs.unlink(lock); } catch { /* race */ }
133
- continue;
134
- }
135
- } catch { /* lock disappeared — retry */ }
136
-
137
- await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
138
- }
139
- }
140
-
141
- // Last resort: force remove potentially stale lock
142
- try { await fs.unlink(lockPath(statePath)); } catch { /* ignore */ }
143
- await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
144
- }
145
-
146
- async function releaseLock(statePath: string): Promise<void> {
147
- try { await fs.unlink(lockPath(statePath)); } catch { /* already removed */ }
148
- }
108
+ import { acquireLock, releaseLock } from "../infra/file-lock.js";
149
109
 
150
110
  // ---------------------------------------------------------------------------
151
111
  // Read / Write