@calltelemetry/openclaw-linear 0.8.7 → 0.9.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.
Files changed (36) hide show
  1. package/README.md +230 -89
  2. package/index.ts +36 -4
  3. package/package.json +1 -1
  4. package/src/__test__/webhook-scenarios.test.ts +1 -1
  5. package/src/gateway/dispatch-methods.test.ts +9 -9
  6. package/src/infra/commands.test.ts +5 -5
  7. package/src/infra/config-paths.test.ts +246 -0
  8. package/src/infra/doctor.ts +45 -36
  9. package/src/infra/notify.test.ts +49 -0
  10. package/src/infra/notify.ts +7 -2
  11. package/src/infra/observability.ts +1 -0
  12. package/src/infra/shared-profiles.test.ts +262 -0
  13. package/src/infra/shared-profiles.ts +116 -0
  14. package/src/infra/template.test.ts +86 -0
  15. package/src/infra/template.ts +18 -0
  16. package/src/infra/validation.test.ts +175 -0
  17. package/src/infra/validation.ts +52 -0
  18. package/src/pipeline/active-session.test.ts +2 -2
  19. package/src/pipeline/agent-end-hook.test.ts +305 -0
  20. package/src/pipeline/artifacts.test.ts +3 -3
  21. package/src/pipeline/dispatch-state.test.ts +111 -8
  22. package/src/pipeline/dispatch-state.ts +48 -13
  23. package/src/pipeline/e2e-dispatch.test.ts +2 -2
  24. package/src/pipeline/intent-classify.test.ts +20 -2
  25. package/src/pipeline/intent-classify.ts +14 -24
  26. package/src/pipeline/pipeline.ts +28 -11
  27. package/src/pipeline/planner.ts +1 -8
  28. package/src/pipeline/planning-state.ts +9 -0
  29. package/src/pipeline/tier-assess.test.ts +39 -39
  30. package/src/pipeline/tier-assess.ts +15 -33
  31. package/src/pipeline/webhook.test.ts +149 -1
  32. package/src/pipeline/webhook.ts +90 -62
  33. package/src/tools/dispatch-history-tool.test.ts +21 -20
  34. package/src/tools/dispatch-history-tool.ts +1 -1
  35. package/src/tools/linear-issues-tool.test.ts +115 -0
  36. package/src/tools/linear-issues-tool.ts +25 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * validation.ts — Shared validation utilities for Linear IDs and prompt text.
3
+ *
4
+ * Used by linear-issues-tool.ts to validate input before making API calls,
5
+ * and by pipeline components to sanitize text before embedding in prompts.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // UUID validation
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+
14
+ export function isValidUuid(id: string): boolean {
15
+ return UUID_REGEX.test(id);
16
+ }
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Linear issue ID validation
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Linear issue IDs are either short identifiers like "ENG-123" or UUIDs.
24
+ */
25
+ const ISSUE_ID_REGEX = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
26
+
27
+ export function isValidIssueId(id: string): boolean {
28
+ return ISSUE_ID_REGEX.test(id) || isValidUuid(id);
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Team ID validation
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export function isValidTeamId(id: string): boolean {
36
+ return isValidUuid(id);
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Prompt sanitization
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Sanitize text before embedding in agent prompts.
45
+ * Truncates to prevent token budget abuse and escapes template patterns
46
+ * so user-supplied text cannot inject {{variable}} placeholders.
47
+ */
48
+ export function sanitizeForPrompt(text: string, maxLength = 4000): string {
49
+ return text
50
+ .replace(/\{\{.*?\}\}/g, "{ {escaped} }")
51
+ .slice(0, maxLength);
52
+ }
@@ -110,7 +110,7 @@ describe("hydrateFromDispatchState", () => {
110
110
  issueIdentifier: "API-300",
111
111
  worktreePath: "/tmp/wt/API-300",
112
112
  branch: "codex/API-300",
113
- tier: "junior",
113
+ tier: "small",
114
114
  model: "test",
115
115
  status: "working",
116
116
  dispatchedAt: "2026-01-01T00:00:00Z",
@@ -121,7 +121,7 @@ describe("hydrateFromDispatchState", () => {
121
121
  issueIdentifier: "API-301",
122
122
  worktreePath: "/tmp/wt/API-301",
123
123
  branch: "codex/API-301",
124
- tier: "junior",
124
+ tier: "small",
125
125
  model: "test",
126
126
  status: "done",
127
127
  dispatchedAt: "2026-01-01T00:00:00Z",
@@ -0,0 +1,305 @@
1
+ /**
2
+ * agent-end-hook.test.ts — Tests for the agent_end hook escalation behavior.
3
+ *
4
+ * Verifies that when triggerAudit or processVerdict throws, the hook:
5
+ * 1. Marks the dispatch as "stuck" via transitionDispatch
6
+ * 2. Sends an escalation notification
7
+ * 3. Does not crash if escalation itself fails
8
+ */
9
+ import { describe, it, expect, beforeEach, vi } from "vitest";
10
+ import { mkdtempSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mocks (vi.hoisted to ensure they're available before vi.mock)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const { triggerAuditMock, processVerdictMock } = vi.hoisted(() => ({
19
+ triggerAuditMock: vi.fn(),
20
+ processVerdictMock: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("./pipeline.js", () => ({
24
+ triggerAudit: triggerAuditMock,
25
+ processVerdict: processVerdictMock,
26
+ }));
27
+
28
+ vi.mock("../api/linear-api.js", () => ({
29
+ LinearAgentApi: class {},
30
+ resolveLinearToken: vi.fn().mockReturnValue({
31
+ accessToken: "test-token",
32
+ source: "env",
33
+ refreshToken: "refresh",
34
+ expiresAt: Date.now() + 3600_000,
35
+ }),
36
+ }));
37
+
38
+ vi.mock("../infra/notify.js", () => ({
39
+ createNotifierFromConfig: vi.fn(() => vi.fn().mockResolvedValue(undefined)),
40
+ }));
41
+
42
+ vi.mock("../infra/observability.js", () => ({
43
+ emitDiagnostic: vi.fn(),
44
+ }));
45
+
46
+ vi.mock("openclaw/plugin-sdk", () => ({}));
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Imports (AFTER mocks)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ import {
53
+ registerDispatch,
54
+ readDispatchState,
55
+ getActiveDispatch,
56
+ registerSessionMapping,
57
+ transitionDispatch,
58
+ type ActiveDispatch,
59
+ } from "./dispatch-state.js";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function tmpStatePath(): string {
66
+ const dir = mkdtempSync(join(tmpdir(), "claw-agent-end-"));
67
+ return join(dir, "state.json");
68
+ }
69
+
70
+ function makeDispatch(overrides?: Partial<ActiveDispatch>): ActiveDispatch {
71
+ return {
72
+ issueId: "uuid-1",
73
+ issueIdentifier: "API-100",
74
+ issueTitle: "Fix the thing",
75
+ worktreePath: "/tmp/wt/API-100",
76
+ branch: "codex/API-100",
77
+ tier: "small",
78
+ model: "test-model",
79
+ status: "working",
80
+ dispatchedAt: new Date().toISOString(),
81
+ attempt: 0,
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Simulate the agent_end hook's catch-block escalation logic.
88
+ *
89
+ * This extracts the escalation path from index.ts so we can test it
90
+ * in isolation without bootstrapping the entire plugin registration.
91
+ */
92
+ async function simulateAgentEndEscalation(opts: {
93
+ statePath: string;
94
+ sessionKey: string;
95
+ error: Error;
96
+ notify: ReturnType<typeof vi.fn>;
97
+ logger: {
98
+ info: ReturnType<typeof vi.fn>;
99
+ warn: ReturnType<typeof vi.fn>;
100
+ error: ReturnType<typeof vi.fn>;
101
+ };
102
+ }): Promise<void> {
103
+ const { statePath, sessionKey, error, notify, logger } = opts;
104
+
105
+ // This mirrors the catch block in index.ts agent_end hook
106
+ logger.error(`agent_end hook error: ${error}`);
107
+ try {
108
+ const state = await readDispatchState(statePath);
109
+ const { lookupSessionMapping } = await import("./dispatch-state.js");
110
+ const mapping = sessionKey ? lookupSessionMapping(state, sessionKey) : null;
111
+ if (mapping) {
112
+ const dispatch = getActiveDispatch(state, mapping.dispatchId);
113
+ if (dispatch && dispatch.status !== "done" && dispatch.status !== "stuck" && dispatch.status !== "failed") {
114
+ const stuckReason = `Hook error: ${error instanceof Error ? error.message : String(error)}`.slice(0, 500);
115
+ await transitionDispatch(
116
+ mapping.dispatchId,
117
+ dispatch.status as any,
118
+ "stuck",
119
+ { stuckReason },
120
+ statePath,
121
+ );
122
+ await notify("escalation", {
123
+ identifier: dispatch.issueIdentifier,
124
+ title: dispatch.issueTitle ?? "Unknown",
125
+ status: "stuck",
126
+ reason: `Dispatch failed in ${mapping.phase} phase: ${stuckReason}`,
127
+ }).catch(() => {});
128
+ }
129
+ }
130
+ } catch (escalateErr) {
131
+ logger.error(`agent_end escalation also failed: ${escalateErr}`);
132
+ }
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Tests
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe("agent_end hook escalation", () => {
140
+ let statePath: string;
141
+ let notifyMock: ReturnType<typeof vi.fn>;
142
+ let logger: { info: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
143
+
144
+ beforeEach(() => {
145
+ statePath = tmpStatePath();
146
+ notifyMock = vi.fn().mockResolvedValue(undefined);
147
+ logger = {
148
+ info: vi.fn(),
149
+ warn: vi.fn(),
150
+ error: vi.fn(),
151
+ };
152
+ vi.clearAllMocks();
153
+ });
154
+
155
+ it("marks dispatch as stuck when audit throws", async () => {
156
+ // Setup: register a dispatch in "working" status with a session mapping
157
+ await registerDispatch("API-100", makeDispatch({ status: "working" }), statePath);
158
+ await registerSessionMapping("sess-worker-1", {
159
+ dispatchId: "API-100",
160
+ phase: "worker",
161
+ attempt: 0,
162
+ }, statePath);
163
+
164
+ // Simulate the hook error
165
+ await simulateAgentEndEscalation({
166
+ statePath,
167
+ sessionKey: "sess-worker-1",
168
+ error: new Error("triggerAudit exploded"),
169
+ notify: notifyMock,
170
+ logger,
171
+ });
172
+
173
+ // Verify: dispatch should now be "stuck"
174
+ const state = await readDispatchState(statePath);
175
+ const dispatch = getActiveDispatch(state, "API-100");
176
+ expect(dispatch).not.toBeNull();
177
+ expect(dispatch!.status).toBe("stuck");
178
+ expect(dispatch!.stuckReason).toBe("Hook error: triggerAudit exploded");
179
+ });
180
+
181
+ it("sends escalation notification with correct payload", async () => {
182
+ await registerDispatch("API-200", makeDispatch({
183
+ issueIdentifier: "API-200",
184
+ issueTitle: "Auth regression",
185
+ status: "auditing",
186
+ }), statePath);
187
+ await registerSessionMapping("sess-audit-1", {
188
+ dispatchId: "API-200",
189
+ phase: "audit",
190
+ attempt: 0,
191
+ }, statePath);
192
+
193
+ await simulateAgentEndEscalation({
194
+ statePath,
195
+ sessionKey: "sess-audit-1",
196
+ error: new Error("processVerdict failed"),
197
+ notify: notifyMock,
198
+ logger,
199
+ });
200
+
201
+ expect(notifyMock).toHaveBeenCalledWith("escalation", expect.objectContaining({
202
+ identifier: "API-200",
203
+ title: "Auth regression",
204
+ status: "stuck",
205
+ reason: expect.stringContaining("audit phase"),
206
+ }));
207
+ });
208
+
209
+ it("does not crash when escalation itself fails", async () => {
210
+ await registerDispatch("API-300", makeDispatch({
211
+ issueIdentifier: "API-300",
212
+ status: "working",
213
+ }), statePath);
214
+ await registerSessionMapping("sess-worker-2", {
215
+ dispatchId: "API-300",
216
+ phase: "worker",
217
+ attempt: 0,
218
+ }, statePath);
219
+
220
+ // Make notify throw
221
+ notifyMock.mockRejectedValueOnce(new Error("Discord is down"));
222
+
223
+ // Should not throw even though notify fails — the .catch(() => {}) eats it
224
+ await expect(
225
+ simulateAgentEndEscalation({
226
+ statePath,
227
+ sessionKey: "sess-worker-2",
228
+ error: new Error("worker failed"),
229
+ notify: notifyMock,
230
+ logger,
231
+ }),
232
+ ).resolves.not.toThrow();
233
+
234
+ // Dispatch should still be marked stuck
235
+ const state = await readDispatchState(statePath);
236
+ expect(getActiveDispatch(state, "API-300")!.status).toBe("stuck");
237
+ });
238
+
239
+ it("skips escalation for already-terminal dispatches", async () => {
240
+ await registerDispatch("API-400", makeDispatch({
241
+ issueIdentifier: "API-400",
242
+ status: "done",
243
+ }), statePath);
244
+ await registerSessionMapping("sess-done", {
245
+ dispatchId: "API-400",
246
+ phase: "worker",
247
+ attempt: 0,
248
+ }, statePath);
249
+
250
+ await simulateAgentEndEscalation({
251
+ statePath,
252
+ sessionKey: "sess-done",
253
+ error: new Error("late error"),
254
+ notify: notifyMock,
255
+ logger,
256
+ });
257
+
258
+ // Notify should NOT have been called (dispatch is already terminal)
259
+ expect(notifyMock).not.toHaveBeenCalled();
260
+
261
+ // Status should still be "done" (unchanged)
262
+ const state = await readDispatchState(statePath);
263
+ expect(getActiveDispatch(state, "API-400")!.status).toBe("done");
264
+ });
265
+
266
+ it("skips escalation when no session mapping found", async () => {
267
+ await simulateAgentEndEscalation({
268
+ statePath,
269
+ sessionKey: "unknown-session-key",
270
+ error: new Error("some error"),
271
+ notify: notifyMock,
272
+ logger,
273
+ });
274
+
275
+ // Only the initial error log, no escalation error
276
+ expect(notifyMock).not.toHaveBeenCalled();
277
+ expect(logger.error).toHaveBeenCalledTimes(1); // Just the initial error
278
+ });
279
+
280
+ it("truncates long error messages to 500 chars", async () => {
281
+ await registerDispatch("API-500", makeDispatch({
282
+ issueIdentifier: "API-500",
283
+ status: "working",
284
+ }), statePath);
285
+ await registerSessionMapping("sess-long", {
286
+ dispatchId: "API-500",
287
+ phase: "worker",
288
+ attempt: 0,
289
+ }, statePath);
290
+
291
+ const longMessage = "x".repeat(1000);
292
+ await simulateAgentEndEscalation({
293
+ statePath,
294
+ sessionKey: "sess-long",
295
+ error: new Error(longMessage),
296
+ notify: notifyMock,
297
+ logger,
298
+ });
299
+
300
+ const state = await readDispatchState(statePath);
301
+ const dispatch = getActiveDispatch(state, "API-500")!;
302
+ expect(dispatch.stuckReason!.length).toBeLessThanOrEqual(500);
303
+ expect(dispatch.stuckReason).toContain("Hook error:");
304
+ });
305
+ });
@@ -33,7 +33,7 @@ function makeManifest(overrides?: Partial<ClawManifest>): ClawManifest {
33
33
  issueIdentifier: "API-100",
34
34
  issueTitle: "Fix login bug",
35
35
  issueId: "id-123",
36
- tier: "junior",
36
+ tier: "small",
37
37
  model: "test-model",
38
38
  dispatchedAt: "2026-01-01T00:00:00Z",
39
39
  worktreePath: "/tmp/test",
@@ -359,7 +359,7 @@ describe("writeDispatchMemory", () => {
359
359
  const tmp = makeTmpDir();
360
360
  writeDispatchMemory("CT-50", "done summary", tmp, {
361
361
  title: "Fix login bug",
362
- tier: "senior",
362
+ tier: "high",
363
363
  status: "done",
364
364
  project: "Auth",
365
365
  attempts: 2,
@@ -367,7 +367,7 @@ describe("writeDispatchMemory", () => {
367
367
  });
368
368
  const content = readFileSync(join(tmp, "memory", "dispatch-CT-50.md"), "utf-8");
369
369
  expect(content).toContain('title: "Fix login bug"');
370
- expect(content).toContain('tier: "senior"');
370
+ expect(content).toContain('tier: "high"');
371
371
  expect(content).toContain('status: "done"');
372
372
  expect(content).toContain('project: "Auth"');
373
373
  expect(content).toContain("attempts: 2");
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from "vitest";
2
- import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, writeFileSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import {
@@ -17,6 +17,7 @@ import {
17
17
  removeSessionMapping,
18
18
  markEventProcessed,
19
19
  pruneCompleted,
20
+ pruneCompletedDispatches,
20
21
  removeActiveDispatch,
21
22
  TransitionError,
22
23
  type ActiveDispatch,
@@ -33,7 +34,7 @@ function makeDispatch(overrides?: Partial<ActiveDispatch>): ActiveDispatch {
33
34
  issueIdentifier: "API-100",
34
35
  worktreePath: "/tmp/wt/API-100",
35
36
  branch: "codex/API-100",
36
- tier: "junior",
37
+ tier: "small",
37
38
  model: "test-model",
38
39
  status: "dispatched",
39
40
  dispatchedAt: new Date().toISOString(),
@@ -50,11 +51,29 @@ describe("readDispatchState", () => {
50
51
  it("returns empty state when file missing", async () => {
51
52
  const p = tmpStatePath();
52
53
  const state = await readDispatchState(p);
54
+ expect(state.version).toBe(2);
53
55
  expect(state.dispatches.active).toEqual({});
54
56
  expect(state.dispatches.completed).toEqual({});
55
57
  expect(state.sessionMap).toEqual({});
56
58
  expect(state.processedEvents).toEqual([]);
57
59
  });
60
+
61
+ it("recovers from corrupted state file", async () => {
62
+ const p = tmpStatePath();
63
+ // Write invalid JSON
64
+ writeFileSync(p, "{{{{not valid json!!!!", "utf-8");
65
+ const state = await readDispatchState(p);
66
+ expect(state.version).toBe(2);
67
+ expect(state.dispatches.active).toEqual({});
68
+ expect(state.dispatches.completed).toEqual({});
69
+ expect(state.sessionMap).toEqual({});
70
+ expect(state.processedEvents).toEqual([]);
71
+ // Corrupted file should have been renamed
72
+ const dir = join(p, "..");
73
+ const files = readdirSync(dir);
74
+ const corrupted = files.filter((f: string) => f.includes(".corrupted."));
75
+ expect(corrupted.length).toBe(1);
76
+ });
58
77
  });
59
78
 
60
79
  describe("registerDispatch", () => {
@@ -223,7 +242,7 @@ describe("completeDispatch", () => {
223
242
  const p = tmpStatePath();
224
243
  await registerDispatch("API-100", makeDispatch(), p);
225
244
  await completeDispatch("API-100", {
226
- tier: "junior",
245
+ tier: "small",
227
246
  status: "done",
228
247
  completedAt: new Date().toISOString(),
229
248
  }, p);
@@ -239,7 +258,7 @@ describe("completeDispatch", () => {
239
258
  await registerSessionMapping("sess-w", { dispatchId: "API-100", phase: "worker", attempt: 0 }, p);
240
259
  await registerSessionMapping("sess-a", { dispatchId: "API-100", phase: "audit", attempt: 0 }, p);
241
260
  await completeDispatch("API-100", {
242
- tier: "junior",
261
+ tier: "small",
243
262
  status: "done",
244
263
  completedAt: new Date().toISOString(),
245
264
  }, p);
@@ -311,7 +330,7 @@ describe("pruneCompleted", () => {
311
330
  const p = tmpStatePath();
312
331
  await registerDispatch("DONE-1", makeDispatch({ issueIdentifier: "DONE-1" }), p);
313
332
  await completeDispatch("DONE-1", {
314
- tier: "junior",
333
+ tier: "small",
315
334
  status: "done",
316
335
  completedAt: new Date(Date.now() - 2 * 24 * 60 * 60_000).toISOString(), // 2 days ago
317
336
  }, p);
@@ -323,7 +342,7 @@ describe("pruneCompleted", () => {
323
342
  const p = tmpStatePath();
324
343
  await registerDispatch("DONE-2", makeDispatch({ issueIdentifier: "DONE-2" }), p);
325
344
  await completeDispatch("DONE-2", {
326
- tier: "junior",
345
+ tier: "small",
327
346
  status: "done",
328
347
  completedAt: new Date().toISOString(),
329
348
  }, p);
@@ -337,9 +356,9 @@ describe("pruneCompleted", () => {
337
356
  // ---------------------------------------------------------------------------
338
357
 
339
358
  describe("migration", () => {
340
- it("adds missing sessionMap and processedEvents", async () => {
359
+ it("migrates v1 state to v2 (adds version, sessionMap, processedEvents)", async () => {
341
360
  const p = tmpStatePath();
342
- // Write v1 state with no v2 fields
361
+ // Write v1 state with no v2 fields (no version field)
343
362
  writeFileSync(p, JSON.stringify({
344
363
  dispatches: {
345
364
  active: { "X-1": makeDispatch({ issueIdentifier: "X-1" }) },
@@ -347,6 +366,7 @@ describe("migration", () => {
347
366
  },
348
367
  }), "utf-8");
349
368
  const state = await readDispatchState(p);
369
+ expect(state.version).toBe(2);
350
370
  expect(state.sessionMap).toEqual({});
351
371
  expect(state.processedEvents).toEqual([]);
352
372
  });
@@ -361,8 +381,35 @@ describe("migration", () => {
361
381
  processedEvents: [],
362
382
  }), "utf-8");
363
383
  const state = await readDispatchState(p);
384
+ expect(state.version).toBe(2);
364
385
  expect(getActiveDispatch(state, "X-2")!.status).toBe("working");
365
386
  });
387
+
388
+ it("rejects unknown version", async () => {
389
+ const p = tmpStatePath();
390
+ writeFileSync(p, JSON.stringify({
391
+ version: 99,
392
+ dispatches: { active: {}, completed: {} },
393
+ sessionMap: {},
394
+ processedEvents: [],
395
+ }), "utf-8");
396
+ await expect(readDispatchState(p)).rejects.toThrow("Unknown dispatch state version: 99");
397
+ });
398
+
399
+ it("passes through v2 state unchanged", async () => {
400
+ const p = tmpStatePath();
401
+ const d = makeDispatch({ issueIdentifier: "X-3" });
402
+ writeFileSync(p, JSON.stringify({
403
+ version: 2,
404
+ dispatches: { active: { "X-3": d }, completed: {} },
405
+ sessionMap: {},
406
+ processedEvents: ["evt-old"],
407
+ }), "utf-8");
408
+ const state = await readDispatchState(p);
409
+ expect(state.version).toBe(2);
410
+ expect(getActiveDispatch(state, "X-3")).not.toBeNull();
411
+ expect(state.processedEvents).toEqual(["evt-old"]);
412
+ });
366
413
  });
367
414
 
368
415
  // ---------------------------------------------------------------------------
@@ -380,3 +427,59 @@ describe("removeActiveDispatch", () => {
380
427
  expect(lookupSessionMapping(state, "sess-rm")).toBeNull();
381
428
  });
382
429
  });
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // pruneCompletedDispatches (convenience wrapper)
433
+ // ---------------------------------------------------------------------------
434
+
435
+ describe("pruneCompletedDispatches", () => {
436
+ it("prunes old completed dispatches", async () => {
437
+ const p = tmpStatePath();
438
+ // Create and complete a dispatch with a timestamp 10 days ago
439
+ await registerDispatch("OLD-GC", makeDispatch({ issueIdentifier: "OLD-GC" }), p);
440
+ await completeDispatch("OLD-GC", {
441
+ tier: "small",
442
+ status: "done",
443
+ completedAt: new Date(Date.now() - 10 * 24 * 60 * 60_000).toISOString(), // 10 days ago
444
+ }, p);
445
+ // Also add a recent completed dispatch
446
+ await registerDispatch("NEW-GC", makeDispatch({ issueIdentifier: "NEW-GC" }), p);
447
+ await completeDispatch("NEW-GC", {
448
+ tier: "high",
449
+ status: "done",
450
+ completedAt: new Date().toISOString(), // now
451
+ }, p);
452
+
453
+ // Prune with 7-day default
454
+ const pruned = await pruneCompletedDispatches(7 * 24 * 60 * 60_000, p);
455
+ expect(pruned).toBe(1);
456
+
457
+ // Verify old one gone, recent one survives
458
+ const state = await readDispatchState(p);
459
+ expect(state.dispatches.completed["OLD-GC"]).toBeUndefined();
460
+ expect(state.dispatches.completed["NEW-GC"]).toBeDefined();
461
+ });
462
+
463
+ it("preserves recent completed dispatches", async () => {
464
+ const p = tmpStatePath();
465
+ await registerDispatch("FRESH-1", makeDispatch({ issueIdentifier: "FRESH-1" }), p);
466
+ await completeDispatch("FRESH-1", {
467
+ tier: "small",
468
+ status: "done",
469
+ completedAt: new Date().toISOString(),
470
+ }, p);
471
+ await registerDispatch("FRESH-2", makeDispatch({ issueIdentifier: "FRESH-2" }), p);
472
+ await completeDispatch("FRESH-2", {
473
+ tier: "medium",
474
+ status: "failed",
475
+ completedAt: new Date(Date.now() - 3 * 24 * 60 * 60_000).toISOString(), // 3 days ago
476
+ }, p);
477
+
478
+ const pruned = await pruneCompletedDispatches(7 * 24 * 60 * 60_000, p);
479
+ expect(pruned).toBe(0);
480
+
481
+ const state = await readDispatchState(p);
482
+ expect(state.dispatches.completed["FRESH-1"]).toBeDefined();
483
+ expect(state.dispatches.completed["FRESH-2"]).toBeDefined();
484
+ });
485
+ });