@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.
@@ -1,11 +1,15 @@
1
1
  /**
2
- * dispatch-state.ts — File-backed persistent dispatch state.
2
+ * dispatch-state.ts — File-backed persistent dispatch state (v2).
3
3
  *
4
4
  * Tracks active and completed dispatches across gateway restarts.
5
5
  * Uses file-level locking to prevent concurrent read-modify-write races.
6
6
  *
7
- * Pattern borrowed from DevClaw's projects.ts — atomic writes with
8
- * exclusive lock, stale lock detection, retry loop.
7
+ * v2 additions:
8
+ * - Atomic compare-and-swap (CAS) transitions
9
+ * - Session-to-dispatch map for agent_end hook lookup
10
+ * - Monotonic attempt counter for stale-event rejection
11
+ * - "stuck" as terminal state with reason
12
+ * - No separate "rework" state — rework is "working" with attempt > 0
9
13
  */
10
14
  import fs from "node:fs/promises";
11
15
  import { existsSync, mkdirSync } from "node:fs";
@@ -18,6 +22,24 @@ import { homedir } from "node:os";
18
22
 
19
23
  export type Tier = "junior" | "medior" | "senior";
20
24
 
25
+ export type DispatchStatus =
26
+ | "dispatched"
27
+ | "working"
28
+ | "auditing"
29
+ | "done"
30
+ | "failed"
31
+ | "stuck";
32
+
33
+ /** Valid CAS transitions: from → allowed next states */
34
+ const VALID_TRANSITIONS: Record<DispatchStatus, DispatchStatus[]> = {
35
+ dispatched: ["working", "failed", "stuck"],
36
+ working: ["auditing", "failed", "stuck"],
37
+ auditing: ["done", "working", "stuck"], // working = rework (attempt++)
38
+ done: [], // terminal
39
+ failed: [], // terminal
40
+ stuck: [], // terminal
41
+ };
42
+
21
43
  export interface ActiveDispatch {
22
44
  issueId: string;
23
45
  issueIdentifier: string;
@@ -25,10 +47,16 @@ export interface ActiveDispatch {
25
47
  branch: string;
26
48
  tier: Tier;
27
49
  model: string;
28
- status: "dispatched" | "running" | "failed";
50
+ status: DispatchStatus;
29
51
  dispatchedAt: string;
30
52
  agentSessionId?: string;
31
53
  project?: string;
54
+
55
+ // v2 fields
56
+ attempt: number; // monotonic: 0 on first run, increments on rework
57
+ workerSessionKey?: string; // session key for current worker sub-agent
58
+ auditSessionKey?: string; // session key for current audit sub-agent
59
+ stuckReason?: string; // only set when status === "stuck"
32
60
  }
33
61
 
34
62
  export interface CompletedDispatch {
@@ -38,6 +66,14 @@ export interface CompletedDispatch {
38
66
  completedAt: string;
39
67
  prUrl?: string;
40
68
  project?: string;
69
+ totalAttempts?: number;
70
+ }
71
+
72
+ /** Maps session keys to dispatch context for agent_end hook lookup */
73
+ export interface SessionMapping {
74
+ dispatchId: string; // issueIdentifier
75
+ phase: "worker" | "audit";
76
+ attempt: number;
41
77
  }
42
78
 
43
79
  export interface DispatchState {
@@ -45,6 +81,10 @@ export interface DispatchState {
45
81
  active: Record<string, ActiveDispatch>;
46
82
  completed: Record<string, CompletedDispatch>;
47
83
  };
84
+ /** Session key → dispatch mapping for agent_end hook */
85
+ sessionMap: Record<string, SessionMapping>;
86
+ /** Set of processed event keys for idempotency */
87
+ processedEvents: string[];
48
88
  }
49
89
 
50
90
  // ---------------------------------------------------------------------------
@@ -52,6 +92,7 @@ export interface DispatchState {
52
92
  // ---------------------------------------------------------------------------
53
93
 
54
94
  const DEFAULT_STATE_PATH = path.join(homedir(), ".openclaw", "linear-dispatch-state.json");
95
+ const MAX_PROCESSED_EVENTS = 200; // Keep last N events for dedup
55
96
 
56
97
  function resolveStatePath(configPath?: string): string {
57
98
  if (!configPath) return DEFAULT_STATE_PATH;
@@ -110,14 +151,34 @@ async function releaseLock(statePath: string): Promise<void> {
110
151
  // ---------------------------------------------------------------------------
111
152
 
112
153
  function emptyState(): DispatchState {
113
- return { dispatches: { active: {}, completed: {} } };
154
+ return {
155
+ dispatches: { active: {}, completed: {} },
156
+ sessionMap: {},
157
+ processedEvents: [],
158
+ };
159
+ }
160
+
161
+ /** Migrate v1 state (no sessionMap/processedEvents) to v2 */
162
+ function migrateState(raw: any): DispatchState {
163
+ const state = raw as DispatchState;
164
+ if (!state.sessionMap) state.sessionMap = {};
165
+ if (!state.processedEvents) state.processedEvents = [];
166
+ // Ensure all active dispatches have attempt field
167
+ for (const d of Object.values(state.dispatches.active)) {
168
+ if ((d as any).attempt === undefined) (d as any).attempt = 0;
169
+ }
170
+ // Migrate old status "running" → "working"
171
+ for (const d of Object.values(state.dispatches.active)) {
172
+ if ((d as any).status === "running") (d as any).status = "working";
173
+ }
174
+ return state;
114
175
  }
115
176
 
116
177
  export async function readDispatchState(configPath?: string): Promise<DispatchState> {
117
178
  const filePath = resolveStatePath(configPath);
118
179
  try {
119
180
  const raw = await fs.readFile(filePath, "utf-8");
120
- return JSON.parse(raw) as DispatchState;
181
+ return migrateState(JSON.parse(raw));
121
182
  } catch (err: any) {
122
183
  if (err.code === "ENOENT") return emptyState();
123
184
  throw err;
@@ -127,13 +188,158 @@ export async function readDispatchState(configPath?: string): Promise<DispatchSt
127
188
  async function writeDispatchState(filePath: string, data: DispatchState): Promise<void> {
128
189
  const dir = path.dirname(filePath);
129
190
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
191
+ // Trim processedEvents to avoid unbounded growth
192
+ if (data.processedEvents.length > MAX_PROCESSED_EVENTS) {
193
+ data.processedEvents = data.processedEvents.slice(-MAX_PROCESSED_EVENTS);
194
+ }
130
195
  const tmpPath = filePath + ".tmp";
131
196
  await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
132
197
  await fs.rename(tmpPath, filePath);
133
198
  }
134
199
 
135
200
  // ---------------------------------------------------------------------------
136
- // Operations (all use file locking)
201
+ // Atomic transitions (CAS)
202
+ // ---------------------------------------------------------------------------
203
+
204
+ export class TransitionError extends Error {
205
+ constructor(
206
+ public dispatchId: string,
207
+ public fromStatus: DispatchStatus,
208
+ public toStatus: DispatchStatus,
209
+ public actualStatus: DispatchStatus,
210
+ ) {
211
+ super(
212
+ `CAS transition failed for ${dispatchId}: ` +
213
+ `expected ${fromStatus} → ${toStatus}, but current status is ${actualStatus}`,
214
+ );
215
+ this.name = "TransitionError";
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Atomic compare-and-swap status transition.
221
+ * Rejects if current status doesn't match `fromStatus`.
222
+ * Returns the updated dispatch.
223
+ */
224
+ export async function transitionDispatch(
225
+ issueIdentifier: string,
226
+ fromStatus: DispatchStatus,
227
+ toStatus: DispatchStatus,
228
+ updates?: Partial<Pick<ActiveDispatch, "workerSessionKey" | "auditSessionKey" | "stuckReason" | "attempt">>,
229
+ configPath?: string,
230
+ ): Promise<ActiveDispatch> {
231
+ const filePath = resolveStatePath(configPath);
232
+ await acquireLock(filePath);
233
+ try {
234
+ const data = await readDispatchState(configPath);
235
+ const dispatch = data.dispatches.active[issueIdentifier];
236
+ if (!dispatch) {
237
+ throw new Error(`No active dispatch for ${issueIdentifier}`);
238
+ }
239
+ if (dispatch.status !== fromStatus) {
240
+ throw new TransitionError(issueIdentifier, fromStatus, toStatus, dispatch.status);
241
+ }
242
+ const allowed = VALID_TRANSITIONS[fromStatus];
243
+ if (!allowed.includes(toStatus)) {
244
+ throw new Error(`Invalid transition: ${fromStatus} → ${toStatus}`);
245
+ }
246
+
247
+ dispatch.status = toStatus;
248
+ if (updates) {
249
+ if (updates.workerSessionKey !== undefined) dispatch.workerSessionKey = updates.workerSessionKey;
250
+ if (updates.auditSessionKey !== undefined) dispatch.auditSessionKey = updates.auditSessionKey;
251
+ if (updates.stuckReason !== undefined) dispatch.stuckReason = updates.stuckReason;
252
+ if (updates.attempt !== undefined) dispatch.attempt = updates.attempt;
253
+ }
254
+
255
+ await writeDispatchState(filePath, data);
256
+ return dispatch;
257
+ } finally {
258
+ await releaseLock(filePath);
259
+ }
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Session map operations
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Register a session key → dispatch mapping.
268
+ * Called when spawning a worker or audit sub-agent.
269
+ */
270
+ export async function registerSessionMapping(
271
+ sessionKey: string,
272
+ mapping: SessionMapping,
273
+ configPath?: string,
274
+ ): Promise<void> {
275
+ const filePath = resolveStatePath(configPath);
276
+ await acquireLock(filePath);
277
+ try {
278
+ const data = await readDispatchState(configPath);
279
+ data.sessionMap[sessionKey] = mapping;
280
+ await writeDispatchState(filePath, data);
281
+ } finally {
282
+ await releaseLock(filePath);
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Lookup a session key in the map.
288
+ * Used by agent_end hook to identify dispatch context.
289
+ */
290
+ export function lookupSessionMapping(
291
+ state: DispatchState,
292
+ sessionKey: string,
293
+ ): SessionMapping | null {
294
+ return state.sessionMap[sessionKey] ?? null;
295
+ }
296
+
297
+ /**
298
+ * Remove a session mapping (cleanup after processing).
299
+ */
300
+ export async function removeSessionMapping(
301
+ sessionKey: string,
302
+ configPath?: string,
303
+ ): Promise<void> {
304
+ const filePath = resolveStatePath(configPath);
305
+ await acquireLock(filePath);
306
+ try {
307
+ const data = await readDispatchState(configPath);
308
+ delete data.sessionMap[sessionKey];
309
+ await writeDispatchState(filePath, data);
310
+ } finally {
311
+ await releaseLock(filePath);
312
+ }
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Idempotency
317
+ // ---------------------------------------------------------------------------
318
+
319
+ /**
320
+ * Check if an event has already been processed. If not, mark it.
321
+ * Returns true if the event is NEW (should be processed).
322
+ * Returns false if it's a duplicate (skip).
323
+ */
324
+ export async function markEventProcessed(
325
+ eventKey: string,
326
+ configPath?: string,
327
+ ): Promise<boolean> {
328
+ const filePath = resolveStatePath(configPath);
329
+ await acquireLock(filePath);
330
+ try {
331
+ const data = await readDispatchState(configPath);
332
+ if (data.processedEvents.includes(eventKey)) return false;
333
+ data.processedEvents.push(eventKey);
334
+ await writeDispatchState(filePath, data);
335
+ return true;
336
+ } finally {
337
+ await releaseLock(filePath);
338
+ }
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Legacy-compatible operations (still used by existing code)
137
343
  // ---------------------------------------------------------------------------
138
344
 
139
345
  export async function registerDispatch(
@@ -145,6 +351,8 @@ export async function registerDispatch(
145
351
  await acquireLock(filePath);
146
352
  try {
147
353
  const data = await readDispatchState(configPath);
354
+ // Ensure v2 fields have defaults
355
+ if (dispatch.attempt === undefined) dispatch.attempt = 0;
148
356
  data.dispatches.active[issueIdentifier] = dispatch;
149
357
  await writeDispatchState(filePath, data);
150
358
  } finally {
@@ -162,6 +370,12 @@ export async function completeDispatch(
162
370
  try {
163
371
  const data = await readDispatchState(configPath);
164
372
  const active = data.dispatches.active[issueIdentifier];
373
+ // Clean up session mappings for this dispatch
374
+ for (const [key, mapping] of Object.entries(data.sessionMap)) {
375
+ if (mapping.dispatchId === issueIdentifier) {
376
+ delete data.sessionMap[key];
377
+ }
378
+ }
165
379
  delete data.dispatches.active[issueIdentifier];
166
380
  data.dispatches.completed[issueIdentifier] = {
167
381
  issueIdentifier,
@@ -170,6 +384,7 @@ export async function completeDispatch(
170
384
  completedAt: result.completedAt,
171
385
  prUrl: result.prUrl,
172
386
  project: active?.project ?? result.project,
387
+ totalAttempts: active?.attempt ?? 0,
173
388
  };
174
389
  await writeDispatchState(filePath, data);
175
390
  } finally {
@@ -179,7 +394,7 @@ export async function completeDispatch(
179
394
 
180
395
  export async function updateDispatchStatus(
181
396
  issueIdentifier: string,
182
- status: ActiveDispatch["status"],
397
+ status: DispatchStatus,
183
398
  configPath?: string,
184
399
  ): Promise<void> {
185
400
  const filePath = resolveStatePath(configPath);
@@ -218,6 +433,17 @@ export function listStaleDispatches(
218
433
  });
219
434
  }
220
435
 
436
+ /**
437
+ * Find dispatches that need recovery after restart:
438
+ * - Status "working" with a workerSessionKey but no auditSessionKey
439
+ * (worker completed but audit wasn't triggered before crash)
440
+ */
441
+ export function listRecoverableDispatches(state: DispatchState): ActiveDispatch[] {
442
+ return Object.values(state.dispatches.active).filter((d) =>
443
+ d.status === "working" && d.workerSessionKey && !d.auditSessionKey,
444
+ );
445
+ }
446
+
221
447
  /**
222
448
  * Remove completed dispatches older than maxAgeMs.
223
449
  * Returns the number of entries pruned.
@@ -257,6 +483,12 @@ export async function removeActiveDispatch(
257
483
  await acquireLock(filePath);
258
484
  try {
259
485
  const data = await readDispatchState(configPath);
486
+ // Clean up session mappings for this dispatch
487
+ for (const [key, mapping] of Object.entries(data.sessionMap)) {
488
+ if (mapping.dispatchId === issueIdentifier) {
489
+ delete data.sessionMap[key];
490
+ }
491
+ }
260
492
  delete data.dispatches.active[issueIdentifier];
261
493
  await writeDispatchState(filePath, data);
262
494
  } finally {
package/src/notify.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * notify.ts — Simple notification function for dispatch lifecycle events.
3
+ *
4
+ * One concrete Discord implementation + noop fallback.
5
+ * No abstract class — add provider abstraction only when a second
6
+ * backend (Slack, email) actually exists.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type NotifyKind =
14
+ | "dispatch" // issue dispatched to worker
15
+ | "working" // worker started
16
+ | "auditing" // audit triggered
17
+ | "audit_pass" // audit passed → done
18
+ | "audit_fail" // audit failed → rework
19
+ | "escalation" // 2x fail or stale → stuck
20
+ | "stuck"; // stale detection
21
+
22
+ export interface NotifyPayload {
23
+ identifier: string;
24
+ title: string;
25
+ status: string;
26
+ attempt?: number;
27
+ reason?: string;
28
+ verdict?: { pass: boolean; gaps?: string[] };
29
+ }
30
+
31
+ export type NotifyFn = (kind: NotifyKind, payload: NotifyPayload) => Promise<void>;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Discord implementation
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const DISCORD_API = "https://discord.com/api/v10";
38
+
39
+ function formatDiscordMessage(kind: NotifyKind, payload: NotifyPayload): string {
40
+ const prefix = `**${payload.identifier}**`;
41
+ switch (kind) {
42
+ case "dispatch":
43
+ return `${prefix} dispatched — ${payload.title}`;
44
+ case "working":
45
+ return `${prefix} worker started (attempt ${payload.attempt ?? 0})`;
46
+ case "auditing":
47
+ return `${prefix} audit in progress`;
48
+ case "audit_pass":
49
+ return `${prefix} passed audit. PR ready.`;
50
+ case "audit_fail": {
51
+ const gaps = payload.verdict?.gaps?.join(", ") ?? "unspecified";
52
+ return `${prefix} failed audit (attempt ${payload.attempt ?? 0}). Gaps: ${gaps}`;
53
+ }
54
+ case "escalation":
55
+ return `🚨 ${prefix} needs human review — ${payload.reason ?? "audit failed 2x"}`;
56
+ case "stuck":
57
+ return `⏰ ${prefix} stuck — ${payload.reason ?? "stale 2h"}`;
58
+ default:
59
+ return `${prefix} — ${kind}: ${payload.status}`;
60
+ }
61
+ }
62
+
63
+ export function createDiscordNotifier(botToken: string, channelId: string): NotifyFn {
64
+ return async (kind, payload) => {
65
+ const message = formatDiscordMessage(kind, payload);
66
+ try {
67
+ const res = await fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
68
+ method: "POST",
69
+ headers: {
70
+ Authorization: `Bot ${botToken}`,
71
+ "Content-Type": "application/json",
72
+ },
73
+ body: JSON.stringify({ content: message }),
74
+ });
75
+ if (!res.ok) {
76
+ const body = await res.text().catch(() => "");
77
+ console.error(`Discord notify failed (${res.status}): ${body}`);
78
+ }
79
+ } catch (err) {
80
+ console.error("Discord notify error:", err);
81
+ }
82
+ };
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Noop fallback
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export function createNoopNotifier(): NotifyFn {
90
+ return async () => {};
91
+ }