@calltelemetry/openclaw-linear 0.4.0 → 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 +108 -1
- package/openclaw.plugin.json +6 -1
- package/package.json +5 -1
- package/prompts.yaml +61 -0
- package/src/active-session.ts +40 -0
- package/src/cli.ts +103 -0
- package/src/code-tool.ts +2 -2
- package/src/codex-worktree.ts +162 -36
- package/src/dispatch-service.ts +161 -0
- package/src/dispatch-state.ts +497 -0
- package/src/notify.ts +91 -0
- package/src/pipeline.ts +582 -198
- package/src/tier-assess.ts +157 -0
- package/src/webhook.ts +232 -197
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch-state.ts — File-backed persistent dispatch state (v2).
|
|
3
|
+
*
|
|
4
|
+
* Tracks active and completed dispatches across gateway restarts.
|
|
5
|
+
* Uses file-level locking to prevent concurrent read-modify-write races.
|
|
6
|
+
*
|
|
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
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Types
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export type Tier = "junior" | "medior" | "senior";
|
|
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
|
+
|
|
43
|
+
export interface ActiveDispatch {
|
|
44
|
+
issueId: string;
|
|
45
|
+
issueIdentifier: string;
|
|
46
|
+
worktreePath: string;
|
|
47
|
+
branch: string;
|
|
48
|
+
tier: Tier;
|
|
49
|
+
model: string;
|
|
50
|
+
status: DispatchStatus;
|
|
51
|
+
dispatchedAt: string;
|
|
52
|
+
agentSessionId?: string;
|
|
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"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CompletedDispatch {
|
|
63
|
+
issueIdentifier: string;
|
|
64
|
+
tier: Tier;
|
|
65
|
+
status: "done" | "failed";
|
|
66
|
+
completedAt: string;
|
|
67
|
+
prUrl?: string;
|
|
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;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DispatchState {
|
|
80
|
+
dispatches: {
|
|
81
|
+
active: Record<string, ActiveDispatch>;
|
|
82
|
+
completed: Record<string, CompletedDispatch>;
|
|
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[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Defaults
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
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
|
|
96
|
+
|
|
97
|
+
function resolveStatePath(configPath?: string): string {
|
|
98
|
+
if (!configPath) return DEFAULT_STATE_PATH;
|
|
99
|
+
if (configPath.startsWith("~/")) return configPath.replace("~", homedir());
|
|
100
|
+
return configPath;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// File locking
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
const LOCK_STALE_MS = 30_000;
|
|
108
|
+
const LOCK_RETRY_MS = 50;
|
|
109
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
110
|
+
|
|
111
|
+
function lockPath(statePath: string): string {
|
|
112
|
+
return statePath + ".lock";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function acquireLock(statePath: string): Promise<void> {
|
|
116
|
+
const lock = lockPath(statePath);
|
|
117
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
118
|
+
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
try {
|
|
121
|
+
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
|
|
122
|
+
return;
|
|
123
|
+
} catch (err: any) {
|
|
124
|
+
if (err.code !== "EEXIST") throw err;
|
|
125
|
+
|
|
126
|
+
// Check for stale lock
|
|
127
|
+
try {
|
|
128
|
+
const content = await fs.readFile(lock, "utf-8");
|
|
129
|
+
const lockTime = Number(content);
|
|
130
|
+
if (Date.now() - lockTime > LOCK_STALE_MS) {
|
|
131
|
+
try { await fs.unlink(lock); } catch { /* race */ }
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
} catch { /* lock disappeared — retry */ }
|
|
135
|
+
|
|
136
|
+
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Last resort: force remove potentially stale lock
|
|
141
|
+
try { await fs.unlink(lockPath(statePath)); } catch { /* ignore */ }
|
|
142
|
+
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function releaseLock(statePath: string): Promise<void> {
|
|
146
|
+
try { await fs.unlink(lockPath(statePath)); } catch { /* already removed */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Read / Write
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
function emptyState(): DispatchState {
|
|
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;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function readDispatchState(configPath?: string): Promise<DispatchState> {
|
|
178
|
+
const filePath = resolveStatePath(configPath);
|
|
179
|
+
try {
|
|
180
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
181
|
+
return migrateState(JSON.parse(raw));
|
|
182
|
+
} catch (err: any) {
|
|
183
|
+
if (err.code === "ENOENT") return emptyState();
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function writeDispatchState(filePath: string, data: DispatchState): Promise<void> {
|
|
189
|
+
const dir = path.dirname(filePath);
|
|
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
|
+
}
|
|
195
|
+
const tmpPath = filePath + ".tmp";
|
|
196
|
+
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
197
|
+
await fs.rename(tmpPath, filePath);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
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)
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
export async function registerDispatch(
|
|
346
|
+
issueIdentifier: string,
|
|
347
|
+
dispatch: ActiveDispatch,
|
|
348
|
+
configPath?: string,
|
|
349
|
+
): Promise<void> {
|
|
350
|
+
const filePath = resolveStatePath(configPath);
|
|
351
|
+
await acquireLock(filePath);
|
|
352
|
+
try {
|
|
353
|
+
const data = await readDispatchState(configPath);
|
|
354
|
+
// Ensure v2 fields have defaults
|
|
355
|
+
if (dispatch.attempt === undefined) dispatch.attempt = 0;
|
|
356
|
+
data.dispatches.active[issueIdentifier] = dispatch;
|
|
357
|
+
await writeDispatchState(filePath, data);
|
|
358
|
+
} finally {
|
|
359
|
+
await releaseLock(filePath);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function completeDispatch(
|
|
364
|
+
issueIdentifier: string,
|
|
365
|
+
result: Omit<CompletedDispatch, "issueIdentifier">,
|
|
366
|
+
configPath?: string,
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
const filePath = resolveStatePath(configPath);
|
|
369
|
+
await acquireLock(filePath);
|
|
370
|
+
try {
|
|
371
|
+
const data = await readDispatchState(configPath);
|
|
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
|
+
}
|
|
379
|
+
delete data.dispatches.active[issueIdentifier];
|
|
380
|
+
data.dispatches.completed[issueIdentifier] = {
|
|
381
|
+
issueIdentifier,
|
|
382
|
+
tier: active?.tier ?? result.tier,
|
|
383
|
+
status: result.status,
|
|
384
|
+
completedAt: result.completedAt,
|
|
385
|
+
prUrl: result.prUrl,
|
|
386
|
+
project: active?.project ?? result.project,
|
|
387
|
+
totalAttempts: active?.attempt ?? 0,
|
|
388
|
+
};
|
|
389
|
+
await writeDispatchState(filePath, data);
|
|
390
|
+
} finally {
|
|
391
|
+
await releaseLock(filePath);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export async function updateDispatchStatus(
|
|
396
|
+
issueIdentifier: string,
|
|
397
|
+
status: DispatchStatus,
|
|
398
|
+
configPath?: string,
|
|
399
|
+
): Promise<void> {
|
|
400
|
+
const filePath = resolveStatePath(configPath);
|
|
401
|
+
await acquireLock(filePath);
|
|
402
|
+
try {
|
|
403
|
+
const data = await readDispatchState(configPath);
|
|
404
|
+
const dispatch = data.dispatches.active[issueIdentifier];
|
|
405
|
+
if (dispatch) {
|
|
406
|
+
dispatch.status = status;
|
|
407
|
+
await writeDispatchState(filePath, data);
|
|
408
|
+
}
|
|
409
|
+
} finally {
|
|
410
|
+
await releaseLock(filePath);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function getActiveDispatch(
|
|
415
|
+
state: DispatchState,
|
|
416
|
+
issueIdentifier: string,
|
|
417
|
+
): ActiveDispatch | null {
|
|
418
|
+
return state.dispatches.active[issueIdentifier] ?? null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function listActiveDispatches(state: DispatchState): ActiveDispatch[] {
|
|
422
|
+
return Object.values(state.dispatches.active);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function listStaleDispatches(
|
|
426
|
+
state: DispatchState,
|
|
427
|
+
maxAgeMs: number,
|
|
428
|
+
): ActiveDispatch[] {
|
|
429
|
+
const now = Date.now();
|
|
430
|
+
return Object.values(state.dispatches.active).filter((d) => {
|
|
431
|
+
const age = now - new Date(d.dispatchedAt).getTime();
|
|
432
|
+
return age > maxAgeMs;
|
|
433
|
+
});
|
|
434
|
+
}
|
|
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
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Remove completed dispatches older than maxAgeMs.
|
|
449
|
+
* Returns the number of entries pruned.
|
|
450
|
+
*/
|
|
451
|
+
export async function pruneCompleted(
|
|
452
|
+
maxAgeMs: number,
|
|
453
|
+
configPath?: string,
|
|
454
|
+
): Promise<number> {
|
|
455
|
+
const filePath = resolveStatePath(configPath);
|
|
456
|
+
await acquireLock(filePath);
|
|
457
|
+
try {
|
|
458
|
+
const data = await readDispatchState(configPath);
|
|
459
|
+
const now = Date.now();
|
|
460
|
+
let pruned = 0;
|
|
461
|
+
for (const [key, entry] of Object.entries(data.dispatches.completed)) {
|
|
462
|
+
const age = now - new Date(entry.completedAt).getTime();
|
|
463
|
+
if (age > maxAgeMs) {
|
|
464
|
+
delete data.dispatches.completed[key];
|
|
465
|
+
pruned++;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (pruned > 0) await writeDispatchState(filePath, data);
|
|
469
|
+
return pruned;
|
|
470
|
+
} finally {
|
|
471
|
+
await releaseLock(filePath);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Remove an active dispatch (e.g. when worktree is gone and branch is gone).
|
|
477
|
+
*/
|
|
478
|
+
export async function removeActiveDispatch(
|
|
479
|
+
issueIdentifier: string,
|
|
480
|
+
configPath?: string,
|
|
481
|
+
): Promise<void> {
|
|
482
|
+
const filePath = resolveStatePath(configPath);
|
|
483
|
+
await acquireLock(filePath);
|
|
484
|
+
try {
|
|
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
|
+
}
|
|
492
|
+
delete data.dispatches.active[issueIdentifier];
|
|
493
|
+
await writeDispatchState(filePath, data);
|
|
494
|
+
} finally {
|
|
495
|
+
await releaseLock(filePath);
|
|
496
|
+
}
|
|
497
|
+
}
|
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
|
+
}
|