@calltelemetry/openclaw-linear 0.4.1 → 0.5.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 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 = [
@@ -93,15 +196,21 @@ export default function register(api: OpenClawPluginApi) {
93
196
  try {
94
197
  const raw = execFileSync(bin, ["--version"], {
95
198
  encoding: "utf8",
96
- timeout: 5_000,
199
+ timeout: 15_000,
97
200
  env: { ...process.env, CLAUDECODE: undefined } as any,
98
201
  }).trim();
99
202
  cliChecks[name] = raw || "unknown";
100
203
  } catch {
101
- cliChecks[name] = "not found";
102
- api.logger.warn(
103
- `${name} CLI not found at ${bin}. The ${name}_run tool will fail. Install with: ${installCmd}`,
104
- );
204
+ // Fallback: check if the file exists (execFileSync can fail in worker contexts)
205
+ try {
206
+ require("node:fs").accessSync(bin, require("node:fs").constants.X_OK);
207
+ cliChecks[name] = "installed (version check skipped)";
208
+ } catch {
209
+ cliChecks[name] = "not found";
210
+ api.logger.warn(
211
+ `${name} CLI not found at ${bin}. The ${name}_run tool will fail. Install with: ${installCmd}`,
212
+ );
213
+ }
105
214
  }
106
215
  }
107
216
 
@@ -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.4.1",
3
+ "version": "0.5.1",
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,79 @@
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
+ # Access model:
10
+ # Zoe (orchestrator) — linearis READ ONLY (issues read/list/search)
11
+ # Worker — NO linearis access. Return text output only.
12
+ # Auditor — linearis READ + WRITE (can update status, close, comment)
13
+
14
+ worker:
15
+ system: |
16
+ You are a coding worker implementing a Linear issue. Your ONLY job is to write code and return a text summary.
17
+ You do NOT have access to linearis or any Linear issue management tools.
18
+ Do NOT attempt to update, close, comment on, or modify the Linear issue in any way.
19
+ Do NOT mark the issue as Done — the audit system handles all issue lifecycle.
20
+ Just write code and return your implementation summary as text.
21
+ task: |
22
+ Implement issue {{identifier}}: {{title}}
23
+
24
+ Issue body:
25
+ {{description}}
26
+
27
+ Worktree: {{worktreePath}}
28
+
29
+ Instructions:
30
+ 1. Read the issue body carefully — it defines what needs to be done
31
+ 2. Plan your approach
32
+ 3. Implement the solution in the worktree
33
+ 4. Run tests to verify your changes
34
+ 5. Return a text summary of what you changed, what tests you ran, and any notes
35
+
36
+ Your text output will be captured automatically. Do NOT use linearis or attempt to post comments.
37
+
38
+ audit:
39
+ system: |
40
+ You are an independent auditor. Your job is to verify that work was completed correctly
41
+ and then update the Linear issue accordingly.
42
+ The Linear issue body is the SOURCE OF TRUTH for what "done" means.
43
+ Worker output is secondary evidence of what was attempted.
44
+ You must be thorough and objective. Do not rubber-stamp.
45
+
46
+ You have WRITE access to linearis. After auditing, you are responsible for:
47
+ - Posting an audit summary comment on the issue
48
+ - Updating the issue status if the audit passes
49
+ Use `linearis` CLI via exec for these operations.
50
+ task: |
51
+ Audit issue {{identifier}}: {{title}}
52
+
53
+ Issue body (source of truth):
54
+ {{description}}
55
+
56
+ Worktree: {{worktreePath}}
57
+
58
+ Checklist:
59
+ 1. Identify ALL acceptance criteria from the issue body
60
+ 2. Read worker comments: `linearis issues read {{identifier}}`
61
+ 3. Verify each acceptance criterion is addressed in the code
62
+ 4. Run tests in the worktree — verify they pass
63
+ 5. Check test coverage if expectations are stated in the issue
64
+ 6. Review the code diff for quality and correctness
65
+
66
+ After auditing:
67
+ - Post your audit findings as a comment: `linearis comments create {{identifier}} --body "..."`
68
+ - If PASS: update status: `linearis issues update {{identifier}} --status "Done"`
69
+
70
+ You MUST return a JSON verdict as the last line of your response:
71
+ {"pass": true/false, "criteria": ["list of criteria found"], "gaps": ["list of unmet criteria"], "testResults": "summary of test output"}
72
+
73
+ rework:
74
+ addendum: |
75
+ PREVIOUS AUDIT FAILED (attempt {{attempt}}). The auditor found these gaps:
76
+ {{gaps}}
77
+
78
+ Address these specific issues in your rework. Focus on the gaps listed above.
79
+ Remember: you do NOT have linearis access. Just fix the code and return a text summary.
@@ -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 === "running") {
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/agent.ts CHANGED
@@ -1,7 +1,36 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, readFileSync } from "node:fs";
2
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
5
  import type { LinearAgentApi, ActivityContent } from "./linear-api.js";
4
6
 
7
+ // ---------------------------------------------------------------------------
8
+ // Agent directory resolution (config-based, not ext API which ignores agentId)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ interface AgentDirs {
12
+ workspaceDir: string;
13
+ agentDir: string;
14
+ }
15
+
16
+ function resolveAgentDirs(agentId: string, config: Record<string, any>): AgentDirs {
17
+ const home = process.env.HOME ?? "/home/claw";
18
+ const agentList = config?.agents?.list as Array<Record<string, any>> | undefined;
19
+ const agentEntry = agentList?.find((a) => a.id === agentId);
20
+
21
+ // Workspace: agent-specific override → agents.defaults.workspace → fallback
22
+ const workspaceDir = agentEntry?.workspace
23
+ ?? config?.agents?.defaults?.workspace
24
+ ?? join(home, ".openclaw", "workspace");
25
+
26
+ // Agent runtime dir: always ~/.openclaw/agents/{agentId}/agent
27
+ // (matches OpenClaw's internal structure)
28
+ const agentDir = join(home, ".openclaw", "agents", agentId, "agent");
29
+ mkdirSync(agentDir, { recursive: true });
30
+
31
+ return { workspaceDir, agentDir };
32
+ }
33
+
5
34
  // Import extensionAPI for embedded agent runner (internal, not in public SDK)
6
35
  let _extensionAPI: typeof import("/home/claw/.npm-global/lib/node_modules/openclaw/dist/extensionAPI.js") | null = null;
7
36
  async function getExtensionAPI() {
@@ -66,17 +95,22 @@ async function runEmbedded(
66
95
  ): Promise<AgentRunResult> {
67
96
  const ext = await getExtensionAPI();
68
97
 
69
- const workspaceDir = ext.resolveAgentWorkspaceDir({ agentId });
70
- const sessionFile = ext.resolveSessionFilePath(sessionId);
71
- const agentDir = ext.resolveAgentDir({ agentId });
98
+ // Load config so we can resolve agent dirs and providers correctly.
99
+ const config = await api.runtime.config.loadConfig();
100
+ const configAny = config as Record<string, any>;
101
+
102
+ // Resolve workspace and agent dirs from config (ext API ignores agentId).
103
+ const dirs = resolveAgentDirs(agentId, configAny);
104
+ const { workspaceDir, agentDir } = dirs;
72
105
  const runId = randomUUID();
73
106
 
74
- // Load config so embedded runner can resolve providers, API keys, etc.
75
- const config = await api.runtime.config.loadConfig();
107
+ // Build session file path under the correct agent's sessions directory.
108
+ const sessionsDir = join(agentDir, "sessions");
109
+ try { mkdirSync(sessionsDir, { recursive: true }); } catch {}
110
+ const sessionFile = join(sessionsDir, `${sessionId}.jsonl`);
76
111
 
77
112
  // Resolve model/provider from config — default is anthropic which requires
78
113
  // a separate API key. Our agents use openrouter.
79
- const configAny = config as Record<string, any>;
80
114
  const agentList = configAny?.agents?.list as Array<Record<string, any>> | undefined;
81
115
  const agentEntry = agentList?.find((a) => a.id === agentId);
82
116
  const modelRef: string =
@@ -89,7 +123,7 @@ async function runEmbedded(
89
123
  const provider = slashIdx > 0 ? modelRef.slice(0, slashIdx) : ext.DEFAULT_PROVIDER;
90
124
  const model = slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
91
125
 
92
- api.logger.info(`Embedded agent run: agent=${agentId} session=${sessionId} runId=${runId} provider=${provider} model=${model}`);
126
+ api.logger.info(`Embedded agent run: agent=${agentId} session=${sessionId} runId=${runId} provider=${provider} model=${model} workspaceDir=${workspaceDir} agentDir=${agentDir}`);
93
127
 
94
128
  const emit = (content: ActivityContent) => {
95
129
  streaming.linearApi.emitActivity(streaming.agentSessionId, content).catch((err) => {
@@ -138,7 +138,6 @@ export async function runClaude(
138
138
  if (model ?? pluginConfig?.claudeModel) {
139
139
  args.push("--model", (model ?? pluginConfig?.claudeModel) as string);
140
140
  }
141
- args.push("-C", workingDir);
142
141
  args.push("-p", prompt);
143
142
 
144
143
  api.logger.info(`Claude exec: ${CLAUDE_BIN} ${args.join(" ").slice(0, 200)}...`);
@@ -150,6 +149,7 @@ export async function runClaude(
150
149
 
151
150
  const child = spawn(CLAUDE_BIN, args, {
152
151
  stdio: ["ignore", "pipe", "pipe"],
152
+ cwd: workingDir,
153
153
  env,
154
154
  timeout: 0,
155
155
  });
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
  }
@@ -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