@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,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.
|
|
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
|
-
|
|
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
|