@calltelemetry/openclaw-linear 0.4.1 → 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 +104 -1
- package/openclaw.plugin.json +4 -1
- package/package.json +5 -1
- package/prompts.yaml +61 -0
- package/src/active-session.ts +1 -1
- package/src/cli.ts +103 -0
- package/src/dispatch-service.ts +50 -2
- package/src/dispatch-state.ts +240 -8
- package/src/notify.ts +91 -0
- package/src/pipeline.ts +561 -406
- package/src/tier-assess.ts +1 -1
- package/src/webhook.ts +39 -30
package/index.ts
CHANGED
|
@@ -5,8 +5,11 @@ import { registerCli } from "./src/cli.js";
|
|
|
5
5
|
import { createLinearTools } from "./src/tools.js";
|
|
6
6
|
import { handleLinearWebhook } from "./src/webhook.js";
|
|
7
7
|
import { handleOAuthCallback } from "./src/oauth-callback.js";
|
|
8
|
-
import { resolveLinearToken } from "./src/linear-api.js";
|
|
8
|
+
import { LinearAgentApi, resolveLinearToken } from "./src/linear-api.js";
|
|
9
9
|
import { createDispatchService } from "./src/dispatch-service.js";
|
|
10
|
+
import { readDispatchState, lookupSessionMapping, getActiveDispatch } from "./src/dispatch-state.js";
|
|
11
|
+
import { triggerAudit, processVerdict, type HookContext } from "./src/pipeline.js";
|
|
12
|
+
import { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "./src/notify.js";
|
|
10
13
|
|
|
11
14
|
export default function register(api: OpenClawPluginApi) {
|
|
12
15
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
@@ -60,6 +63,106 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
60
63
|
// Register dispatch monitor service (stale detection, session hydration, cleanup)
|
|
61
64
|
api.registerService(createDispatchService(api));
|
|
62
65
|
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Dispatch pipeline v2: notifier + agent_end lifecycle hook
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
// Instantiate notifier (Discord if configured, otherwise noop)
|
|
71
|
+
const discordBotToken = (() => {
|
|
72
|
+
try {
|
|
73
|
+
const config = JSON.parse(
|
|
74
|
+
require("node:fs").readFileSync(
|
|
75
|
+
require("node:path").join(process.env.HOME ?? "/home/claw", ".openclaw", "openclaw.json"),
|
|
76
|
+
"utf8",
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
return config?.channels?.discord?.token as string | undefined;
|
|
80
|
+
} catch { return undefined; }
|
|
81
|
+
})();
|
|
82
|
+
const flowDiscordChannel = pluginConfig?.flowDiscordChannel as string | undefined;
|
|
83
|
+
|
|
84
|
+
const notify: NotifyFn = (discordBotToken && flowDiscordChannel)
|
|
85
|
+
? createDiscordNotifier(discordBotToken, flowDiscordChannel)
|
|
86
|
+
: createNoopNotifier();
|
|
87
|
+
|
|
88
|
+
if (flowDiscordChannel && discordBotToken) {
|
|
89
|
+
api.logger.info(`Linear dispatch: Discord notifications enabled (channel: ${flowDiscordChannel})`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Register agent_end hook — safety net for sessions_spawn sub-agents.
|
|
93
|
+
// In the current implementation, the worker→audit→verdict flow runs inline
|
|
94
|
+
// via spawnWorker() in pipeline.ts. This hook catches sessions_spawn agents
|
|
95
|
+
// (future upgrade path) and serves as a recovery mechanism.
|
|
96
|
+
api.on("agent_end", async (event: any, ctx: any) => {
|
|
97
|
+
try {
|
|
98
|
+
const sessionKey = ctx?.sessionKey ?? "";
|
|
99
|
+
if (!sessionKey) return;
|
|
100
|
+
|
|
101
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
102
|
+
const state = await readDispatchState(statePath);
|
|
103
|
+
const mapping = lookupSessionMapping(state, sessionKey);
|
|
104
|
+
if (!mapping) return; // Not a dispatch sub-agent
|
|
105
|
+
|
|
106
|
+
const dispatch = getActiveDispatch(state, mapping.dispatchId);
|
|
107
|
+
if (!dispatch) {
|
|
108
|
+
api.logger.info(`agent_end: dispatch ${mapping.dispatchId} no longer active`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Stale event rejection — only process if attempt matches
|
|
113
|
+
if (dispatch.attempt !== mapping.attempt) {
|
|
114
|
+
api.logger.info(
|
|
115
|
+
`agent_end: stale event for ${mapping.dispatchId} ` +
|
|
116
|
+
`(event attempt=${mapping.attempt}, current=${dispatch.attempt})`
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Create Linear API for hook context
|
|
122
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
123
|
+
if (!tokenInfo.accessToken) {
|
|
124
|
+
api.logger.error("agent_end: no Linear access token — cannot process dispatch event");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
|
|
128
|
+
refreshToken: tokenInfo.refreshToken,
|
|
129
|
+
expiresAt: tokenInfo.expiresAt,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const hookCtx: HookContext = {
|
|
133
|
+
api,
|
|
134
|
+
linearApi,
|
|
135
|
+
notify,
|
|
136
|
+
pluginConfig,
|
|
137
|
+
configPath: statePath,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Extract output from event
|
|
141
|
+
const output = typeof event?.output === "string"
|
|
142
|
+
? event.output
|
|
143
|
+
: (event?.messages ?? [])
|
|
144
|
+
.filter((m: any) => m?.role === "assistant")
|
|
145
|
+
.map((m: any) => typeof m?.content === "string" ? m.content : "")
|
|
146
|
+
.join("\n") || "";
|
|
147
|
+
|
|
148
|
+
if (mapping.phase === "worker") {
|
|
149
|
+
api.logger.info(`agent_end: worker completed for ${mapping.dispatchId} — triggering audit`);
|
|
150
|
+
await triggerAudit(hookCtx, dispatch, {
|
|
151
|
+
success: event?.success ?? true,
|
|
152
|
+
output,
|
|
153
|
+
}, sessionKey);
|
|
154
|
+
} else if (mapping.phase === "audit") {
|
|
155
|
+
api.logger.info(`agent_end: audit completed for ${mapping.dispatchId} — processing verdict`);
|
|
156
|
+
await processVerdict(hookCtx, dispatch, {
|
|
157
|
+
success: event?.success ?? true,
|
|
158
|
+
output,
|
|
159
|
+
}, sessionKey);
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
api.logger.error(`agent_end hook error: ${err}`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
63
166
|
// Narration Guard: catch short "Let me explore..." responses that narrate intent
|
|
64
167
|
// without actually calling tools, and append a warning for the user.
|
|
65
168
|
const NARRATION_PATTERNS = [
|
package/openclaw.plugin.json
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"codexTimeoutMs": { "type": "number", "description": "Default Codex timeout in milliseconds", "default": 600000 },
|
|
20
20
|
"enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true },
|
|
21
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)" }
|
|
22
|
+
"dispatchStatePath": { "type": "string", "description": "Path to dispatch state JSON file (default: ~/.openclaw/linear-dispatch-state.json)" },
|
|
23
|
+
"flowDiscordChannel": { "type": "string", "description": "Discord channel ID for dispatch lifecycle notifications (omit to disable)" },
|
|
24
|
+
"promptsPath": { "type": "string", "description": "Override path for prompts.yaml (default: ships with plugin)" },
|
|
25
|
+
"maxReworkAttempts": { "type": "number", "description": "Max audit failures before escalation", "default": 2 }
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calltelemetry/openclaw-linear",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"index.ts",
|
|
23
23
|
"src/",
|
|
24
24
|
"openclaw.plugin.json",
|
|
25
|
+
"prompts.yaml",
|
|
25
26
|
"README.md"
|
|
26
27
|
],
|
|
27
28
|
"publishConfig": {
|
|
@@ -35,5 +36,8 @@
|
|
|
35
36
|
"extensions": [
|
|
36
37
|
"./index.ts"
|
|
37
38
|
]
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"yaml": "^2.8.2"
|
|
38
42
|
}
|
|
39
43
|
}
|
package/prompts.yaml
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# prompts.yaml — Externalized phase prompts for the Linear dispatch pipeline.
|
|
2
|
+
#
|
|
3
|
+
# Template variables: {{identifier}}, {{title}}, {{description}},
|
|
4
|
+
# {{worktreePath}}, {{gaps}}, {{tier}}, {{attempt}}
|
|
5
|
+
#
|
|
6
|
+
# Edit these to customize worker/audit behavior without rebuilding the plugin.
|
|
7
|
+
# Override path via `promptsPath` in plugin config.
|
|
8
|
+
|
|
9
|
+
worker:
|
|
10
|
+
system: |
|
|
11
|
+
You are implementing a Linear issue. Your job is to plan and code the solution.
|
|
12
|
+
Post an implementation summary as a Linear comment when done.
|
|
13
|
+
DO NOT mark the issue as Done — that is handled by the audit system.
|
|
14
|
+
DO NOT attempt to change the issue status or labels.
|
|
15
|
+
task: |
|
|
16
|
+
Implement issue {{identifier}}: {{title}}
|
|
17
|
+
|
|
18
|
+
Issue body:
|
|
19
|
+
{{description}}
|
|
20
|
+
|
|
21
|
+
Worktree: {{worktreePath}}
|
|
22
|
+
|
|
23
|
+
Instructions:
|
|
24
|
+
1. Read the issue body carefully — it defines what needs to be done
|
|
25
|
+
2. Plan your approach
|
|
26
|
+
3. Implement the solution in the worktree
|
|
27
|
+
4. Run tests to verify your changes
|
|
28
|
+
5. Post an implementation summary as a comment on the Linear issue
|
|
29
|
+
6. Include what was changed, what tests were run, and any notes
|
|
30
|
+
|
|
31
|
+
audit:
|
|
32
|
+
system: |
|
|
33
|
+
You are an independent auditor. Your job is to verify that work was completed correctly.
|
|
34
|
+
The Linear issue body is the SOURCE OF TRUTH for what "done" means.
|
|
35
|
+
Worker comments are secondary evidence of what was attempted.
|
|
36
|
+
You must be thorough and objective. Do not rubber-stamp.
|
|
37
|
+
task: |
|
|
38
|
+
Audit issue {{identifier}}: {{title}}
|
|
39
|
+
|
|
40
|
+
Issue body (source of truth):
|
|
41
|
+
{{description}}
|
|
42
|
+
|
|
43
|
+
Worktree: {{worktreePath}}
|
|
44
|
+
|
|
45
|
+
Checklist:
|
|
46
|
+
1. Identify ALL acceptance criteria from the issue body
|
|
47
|
+
2. Read worker comments on the issue (use linearis)
|
|
48
|
+
3. Verify each acceptance criterion is addressed in the code
|
|
49
|
+
4. Run tests in the worktree — verify they pass
|
|
50
|
+
5. Check test coverage if expectations are stated in the issue
|
|
51
|
+
6. Review the code diff for quality and correctness
|
|
52
|
+
|
|
53
|
+
You MUST return a JSON verdict as the last line of your response:
|
|
54
|
+
{"pass": true/false, "criteria": ["list of criteria found"], "gaps": ["list of unmet criteria"], "testResults": "summary of test output"}
|
|
55
|
+
|
|
56
|
+
rework:
|
|
57
|
+
addendum: |
|
|
58
|
+
PREVIOUS AUDIT FAILED (attempt {{attempt}}). The auditor found these gaps:
|
|
59
|
+
{{gaps}}
|
|
60
|
+
|
|
61
|
+
Address these specific issues in your rework. Focus on the gaps listed above.
|
package/src/active-session.ts
CHANGED
|
@@ -84,7 +84,7 @@ export async function hydrateFromDispatchState(configPath?: string): Promise<num
|
|
|
84
84
|
let restored = 0;
|
|
85
85
|
|
|
86
86
|
for (const [, dispatch] of Object.entries(active)) {
|
|
87
|
-
if (dispatch.status === "dispatched" || dispatch.status === "
|
|
87
|
+
if (dispatch.status === "dispatched" || dispatch.status === "working") {
|
|
88
88
|
sessions.set(dispatch.issueId, {
|
|
89
89
|
agentSessionId: dispatch.agentSessionId ?? "",
|
|
90
90
|
issueIdentifier: dispatch.issueIdentifier,
|
package/src/cli.ts
CHANGED
|
@@ -6,9 +6,13 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
7
|
import { exec } from "node:child_process";
|
|
8
8
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { readFileSync as readFileSyncFs, existsSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
9
12
|
import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "./linear-api.js";
|
|
10
13
|
import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "./auth.js";
|
|
11
14
|
import { listWorktrees } from "./codex-worktree.js";
|
|
15
|
+
import { loadPrompts, clearPromptCache } from "./pipeline.js";
|
|
12
16
|
|
|
13
17
|
function prompt(question: string): Promise<string> {
|
|
14
18
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -239,4 +243,103 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
239
243
|
|
|
240
244
|
console.log(`\nTo remove one: openclaw openclaw-linear worktrees --prune <path>\n`);
|
|
241
245
|
});
|
|
246
|
+
|
|
247
|
+
// --- openclaw openclaw-linear prompts ---
|
|
248
|
+
const prompts = linear
|
|
249
|
+
.command("prompts")
|
|
250
|
+
.description("Manage pipeline prompt templates (prompts.yaml)");
|
|
251
|
+
|
|
252
|
+
prompts
|
|
253
|
+
.command("show")
|
|
254
|
+
.description("Print current prompts.yaml content")
|
|
255
|
+
.action(async () => {
|
|
256
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
257
|
+
const customPath = pluginConfig?.promptsPath as string | undefined;
|
|
258
|
+
|
|
259
|
+
let resolvedPath: string;
|
|
260
|
+
if (customPath) {
|
|
261
|
+
resolvedPath = customPath.startsWith("~")
|
|
262
|
+
? customPath.replace("~", process.env.HOME ?? "")
|
|
263
|
+
: customPath;
|
|
264
|
+
} else {
|
|
265
|
+
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
266
|
+
resolvedPath = join(pluginRoot, "prompts.yaml");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
console.log(`\nPrompts file: ${resolvedPath}\n`);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const content = readFileSyncFs(resolvedPath, "utf-8");
|
|
273
|
+
console.log(content);
|
|
274
|
+
} catch {
|
|
275
|
+
console.log("(file not found — using built-in defaults)\n");
|
|
276
|
+
// Show the loaded defaults
|
|
277
|
+
clearPromptCache();
|
|
278
|
+
const loaded = loadPrompts(pluginConfig);
|
|
279
|
+
console.log(JSON.stringify(loaded, null, 2));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
prompts
|
|
284
|
+
.command("path")
|
|
285
|
+
.description("Print the resolved prompts.yaml file path")
|
|
286
|
+
.action(async () => {
|
|
287
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
288
|
+
const customPath = pluginConfig?.promptsPath as string | undefined;
|
|
289
|
+
|
|
290
|
+
let resolvedPath: string;
|
|
291
|
+
if (customPath) {
|
|
292
|
+
resolvedPath = customPath.startsWith("~")
|
|
293
|
+
? customPath.replace("~", process.env.HOME ?? "")
|
|
294
|
+
: customPath;
|
|
295
|
+
} else {
|
|
296
|
+
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
297
|
+
resolvedPath = join(pluginRoot, "prompts.yaml");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const exists = existsSync(resolvedPath);
|
|
301
|
+
console.log(`${resolvedPath} ${exists ? "(exists)" : "(not found — using defaults)"}`);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
prompts
|
|
305
|
+
.command("validate")
|
|
306
|
+
.description("Validate prompts.yaml structure")
|
|
307
|
+
.action(async () => {
|
|
308
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
309
|
+
clearPromptCache();
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const loaded = loadPrompts(pluginConfig);
|
|
313
|
+
const errors: string[] = [];
|
|
314
|
+
|
|
315
|
+
if (!loaded.worker?.system) errors.push("Missing worker.system");
|
|
316
|
+
if (!loaded.worker?.task) errors.push("Missing worker.task");
|
|
317
|
+
if (!loaded.audit?.system) errors.push("Missing audit.system");
|
|
318
|
+
if (!loaded.audit?.task) errors.push("Missing audit.task");
|
|
319
|
+
if (!loaded.rework?.addendum) errors.push("Missing rework.addendum");
|
|
320
|
+
|
|
321
|
+
// Check for template variables
|
|
322
|
+
const requiredVars = ["{{identifier}}", "{{title}}", "{{description}}", "{{worktreePath}}"];
|
|
323
|
+
for (const v of requiredVars) {
|
|
324
|
+
if (!loaded.worker.task.includes(v)) {
|
|
325
|
+
errors.push(`worker.task missing template variable: ${v}`);
|
|
326
|
+
}
|
|
327
|
+
if (!loaded.audit.task.includes(v)) {
|
|
328
|
+
errors.push(`audit.task missing template variable: ${v}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (errors.length > 0) {
|
|
333
|
+
console.log("\nValidation FAILED:\n");
|
|
334
|
+
for (const e of errors) console.log(` - ${e}`);
|
|
335
|
+
console.log();
|
|
336
|
+
process.exitCode = 1;
|
|
337
|
+
} else {
|
|
338
|
+
console.log("\nValidation PASSED — all sections and template variables present.\n");
|
|
339
|
+
}
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error(`\nFailed to load prompts: ${err}\n`);
|
|
342
|
+
process.exitCode = 1;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
242
345
|
}
|
package/src/dispatch-service.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { hydrateFromDispatchState } from "./active-session.js";
|
|
|
16
16
|
import {
|
|
17
17
|
readDispatchState,
|
|
18
18
|
listStaleDispatches,
|
|
19
|
+
listRecoverableDispatches,
|
|
20
|
+
transitionDispatch,
|
|
21
|
+
TransitionError,
|
|
19
22
|
removeActiveDispatch,
|
|
20
23
|
pruneCompleted,
|
|
21
24
|
} from "./dispatch-state.js";
|
|
@@ -49,6 +52,27 @@ export function createDispatchService(api: OpenClawPluginApi) {
|
|
|
49
52
|
ctx.logger.warn(`linear-dispatch: hydration failed: ${err}`);
|
|
50
53
|
}
|
|
51
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
|
+
|
|
52
76
|
ctx.logger.info(`linear-dispatch: service started (interval: ${INTERVAL_MS / 1000}s)`);
|
|
53
77
|
|
|
54
78
|
intervalId = setInterval(() => runTick(ctx), INTERVAL_MS);
|
|
@@ -71,9 +95,14 @@ export function createDispatchService(api: OpenClawPluginApi) {
|
|
|
71
95
|
// Skip tick if nothing to do
|
|
72
96
|
if (activeCount === 0 && Object.keys(state.dispatches.completed).length === 0) return;
|
|
73
97
|
|
|
74
|
-
// 1. Stale dispatch detection
|
|
98
|
+
// 1. Stale dispatch detection — transition truly stale dispatches to "stuck"
|
|
75
99
|
const stale = listStaleDispatches(state, STALE_THRESHOLD_MS);
|
|
76
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
|
+
|
|
77
106
|
// Check if worktree still exists and has progress
|
|
78
107
|
if (existsSync(dispatch.worktreePath)) {
|
|
79
108
|
const status = getWorktreeStatus(dispatch.worktreePath);
|
|
@@ -82,10 +111,29 @@ export function createDispatchService(api: OpenClawPluginApi) {
|
|
|
82
111
|
continue;
|
|
83
112
|
}
|
|
84
113
|
}
|
|
114
|
+
|
|
85
115
|
ctx.logger.warn(
|
|
86
116
|
`linear-dispatch: stale dispatch ${dispatch.issueIdentifier} ` +
|
|
87
|
-
`(dispatched ${dispatch.dispatchedAt}, status: ${dispatch.status})
|
|
117
|
+
`(dispatched ${dispatch.dispatchedAt}, status: ${dispatch.status}) — transitioning to stuck`,
|
|
88
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
|
+
}
|
|
89
137
|
}
|
|
90
138
|
|
|
91
139
|
// 2. Worktree health — verify active dispatches have valid worktrees
|