@calltelemetry/openclaw-linear 0.7.0 → 0.8.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. package/src/tools/orchestration-tools.test.ts +158 -0
@@ -8,21 +8,24 @@
8
8
  * Modeled on DevClaw's notify.ts pattern — the runtime handles token resolution,
9
9
  * formatting differences (markdown vs mrkdwn), and delivery per channel.
10
10
  */
11
- import type { PluginRuntime } from "openclaw/plugin-sdk";
11
+ import type { PluginRuntime, OpenClawPluginApi } from "openclaw/plugin-sdk";
12
+ import { emitDiagnostic } from "./observability.js";
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Types
15
16
  // ---------------------------------------------------------------------------
16
17
 
17
18
  export type NotifyKind =
18
- | "dispatch" // issue dispatched to worker
19
- | "working" // worker started
20
- | "auditing" // audit triggered
21
- | "audit_pass" // audit passed → done
22
- | "audit_fail" // audit failed → rework
23
- | "escalation" // 2x fail or stale → stuck
24
- | "stuck" // stale detection
25
- | "watchdog_kill"; // agent killed by inactivity watchdog
19
+ | "dispatch" // issue dispatched to worker
20
+ | "working" // worker started
21
+ | "auditing" // audit triggered
22
+ | "audit_pass" // audit passed → done
23
+ | "audit_fail" // audit failed → rework
24
+ | "escalation" // 2x fail or stale → stuck
25
+ | "stuck" // stale detection
26
+ | "watchdog_kill" // agent killed by inactivity watchdog
27
+ | "project_progress" // DAG dispatch progress update
28
+ | "project_complete"; // all project issues dispatched
26
29
 
27
30
  export interface NotifyPayload {
28
31
  identifier: string;
@@ -51,6 +54,26 @@ export interface NotifyTarget {
51
54
  export interface NotificationsConfig {
52
55
  targets?: NotifyTarget[];
53
56
  events?: Partial<Record<NotifyKind, boolean>>;
57
+ /** Opt-in: send rich embeds (Discord) and HTML (Telegram) instead of plain text. */
58
+ richFormat?: boolean;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Rich message types (Discord embeds + Telegram HTML)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export interface DiscordEmbed {
66
+ title?: string;
67
+ description?: string;
68
+ color?: number;
69
+ fields?: Array<{ name: string; value: string; inline?: boolean }>;
70
+ footer?: { text: string };
71
+ }
72
+
73
+ export interface RichMessage {
74
+ text: string;
75
+ discord?: { embeds: DiscordEmbed[] };
76
+ telegram?: { html: string };
54
77
  }
55
78
 
56
79
  // ---------------------------------------------------------------------------
@@ -59,58 +82,134 @@ export interface NotificationsConfig {
59
82
 
60
83
  export function formatMessage(kind: NotifyKind, payload: NotifyPayload): string {
61
84
  const id = payload.identifier;
85
+ const attempt = (payload.attempt ?? 0) + 1; // 1-based for humans
62
86
  switch (kind) {
63
87
  case "dispatch":
64
- return `${id} dispatched — ${payload.title}`;
88
+ return `${id} started — ${payload.title}`;
65
89
  case "working":
66
- return `${id} worker started (attempt ${payload.attempt ?? 0})`;
90
+ return `${id} working on it (attempt ${attempt})`;
67
91
  case "auditing":
68
- return `${id} audit in progress`;
92
+ return `${id} checking the work...`;
69
93
  case "audit_pass":
70
- return `${id} passed audit. PR ready.`;
94
+ return `✅ ${id} done! Ready for review.`;
71
95
  case "audit_fail": {
72
- const gaps = payload.verdict?.gaps?.join(", ") ?? "unspecified";
73
- return `${id} failed audit (attempt ${payload.attempt ?? 0}). Gaps: ${gaps}`;
96
+ const issues = payload.verdict?.gaps?.join(", ") ?? "unspecified";
97
+ return `${id} needs more work (attempt ${attempt}). Issues: ${issues}`;
74
98
  }
75
99
  case "escalation":
76
- return `🚨 ${id} needs human review — ${payload.reason ?? "audit failed 2x"}`;
100
+ return `🚨 ${id} needs your helpcouldn't fix it after ${attempt} ${attempt === 1 ? "try" : "tries"}`;
77
101
  case "stuck":
78
- return `⏰ ${id} stuck — ${payload.reason ?? "stale 2h"}`;
102
+ return `⏰ ${id} stuck — ${payload.reason ?? "inactive for 2h"}`;
79
103
  case "watchdog_kill":
80
- return `⚡ ${id} killed by watchdog (${payload.reason ?? "no I/O for 120s"}). ${
81
- payload.attempt != null ? `Retrying (attempt ${payload.attempt}).` : "Will retry."
104
+ return `⚡ ${id} timed out (${payload.reason ?? "no activity for 120s"}). ${
105
+ payload.attempt != null ? `Retrying (attempt ${attempt}).` : "Will retry."
82
106
  }`;
107
+ case "project_progress":
108
+ return `📊 ${payload.title} (${id}): ${payload.status}`;
109
+ case "project_complete":
110
+ return `✅ ${payload.title} (${id}): ${payload.status}`;
83
111
  default:
84
112
  return `${id} — ${kind}: ${payload.status}`;
85
113
  }
86
114
  }
87
115
 
116
+ // ---------------------------------------------------------------------------
117
+ // Rich message formatter (Discord embeds + Telegram HTML)
118
+ // ---------------------------------------------------------------------------
119
+
120
+ const EVENT_COLORS: Record<string, number> = {
121
+ dispatch: 0x3498db, // blue
122
+ working: 0x3498db, // blue
123
+ auditing: 0xf39c12, // yellow
124
+ audit_pass: 0x2ecc71, // green
125
+ audit_fail: 0xe74c3c, // red
126
+ escalation: 0xe74c3c, // red
127
+ stuck: 0xe67e22, // orange
128
+ watchdog_kill: 0x9b59b6, // purple
129
+ project_progress: 0x3498db,
130
+ project_complete: 0x2ecc71,
131
+ };
132
+
133
+ export function formatRichMessage(kind: NotifyKind, payload: NotifyPayload): RichMessage {
134
+ const text = formatMessage(kind, payload);
135
+ const color = EVENT_COLORS[kind] ?? 0x95a5a6;
136
+
137
+ // Discord embed
138
+ const fields: DiscordEmbed["fields"] = [];
139
+ if (payload.attempt != null) fields.push({ name: "Attempt", value: String((payload.attempt ?? 0) + 1), inline: true });
140
+ if (payload.status) fields.push({ name: "Status", value: payload.status, inline: true });
141
+ if (payload.verdict?.gaps?.length) {
142
+ fields.push({ name: "Issues to fix", value: payload.verdict.gaps.join("\n").slice(0, 1024) });
143
+ }
144
+ if (payload.reason) fields.push({ name: "Reason", value: payload.reason });
145
+
146
+ const embed: DiscordEmbed = {
147
+ title: `${payload.identifier} — ${kind.replace(/_/g, " ")}`,
148
+ description: payload.title,
149
+ color,
150
+ fields: fields.length > 0 ? fields : undefined,
151
+ footer: { text: `Linear Agent • ${kind}` },
152
+ };
153
+
154
+ // Telegram HTML
155
+ const htmlParts: string[] = [
156
+ `<b>${escapeHtml(payload.identifier)}</b> — ${escapeHtml(kind.replace(/_/g, " "))}`,
157
+ `<i>${escapeHtml(payload.title)}</i>`,
158
+ ];
159
+ if (payload.attempt != null) htmlParts.push(`Attempt: <code>${(payload.attempt ?? 0) + 1}</code>`);
160
+ if (payload.status) htmlParts.push(`Status: <code>${escapeHtml(payload.status)}</code>`);
161
+ if (payload.verdict?.gaps?.length) {
162
+ htmlParts.push(`Issues to fix:\n${payload.verdict.gaps.map(g => `• ${escapeHtml(g)}`).join("\n")}`);
163
+ }
164
+ if (payload.reason) htmlParts.push(`Reason: ${escapeHtml(payload.reason)}`);
165
+
166
+ return {
167
+ text,
168
+ discord: { embeds: [embed] },
169
+ telegram: { html: htmlParts.join("\n") },
170
+ };
171
+ }
172
+
173
+ function escapeHtml(s: string): string {
174
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
175
+ }
176
+
88
177
  // ---------------------------------------------------------------------------
89
178
  // Unified send — routes to OpenClaw runtime channel API
90
179
  // ---------------------------------------------------------------------------
91
180
 
92
181
  export async function sendToTarget(
93
182
  target: NotifyTarget,
94
- message: string,
183
+ message: string | RichMessage,
95
184
  runtime: PluginRuntime,
96
185
  ): Promise<void> {
97
186
  const ch = target.channel;
98
187
  const to = target.target;
188
+ const isRich = typeof message !== "string";
189
+ const plainText = isRich ? message.text : message;
99
190
 
100
191
  if (ch === "discord") {
101
- await runtime.channel.discord.sendMessageDiscord(to, message);
192
+ if (isRich && message.discord) {
193
+ await runtime.channel.discord.sendMessageDiscord(to, plainText, { embeds: message.discord.embeds });
194
+ } else {
195
+ await runtime.channel.discord.sendMessageDiscord(to, plainText);
196
+ }
102
197
  } else if (ch === "slack") {
103
- await runtime.channel.slack.sendMessageSlack(to, message, {
198
+ await runtime.channel.slack.sendMessageSlack(to, plainText, {
104
199
  accountId: target.accountId,
105
200
  });
106
201
  } else if (ch === "telegram") {
107
- await runtime.channel.telegram.sendMessageTelegram(to, message, { silent: true });
202
+ if (isRich && message.telegram) {
203
+ await runtime.channel.telegram.sendMessageTelegram(to, message.telegram.html, { silent: true, textMode: "html" });
204
+ } else {
205
+ await runtime.channel.telegram.sendMessageTelegram(to, plainText, { silent: true });
206
+ }
108
207
  } else if (ch === "signal") {
109
- await runtime.channel.signal.sendMessageSignal(to, message);
208
+ await runtime.channel.signal.sendMessageSignal(to, plainText);
110
209
  } else {
111
210
  // Fallback: use CLI for any channel the runtime doesn't expose directly
112
211
  const { execFileSync } = await import("node:child_process");
113
- execFileSync("openclaw", ["message", "send", "--channel", ch, "--target", to, "--message", message, "--json"], {
212
+ execFileSync("openclaw", ["message", "send", "--channel", ch, "--target", to, "--message", plainText, "--json"], {
114
213
  timeout: 30_000,
115
214
  stdio: "ignore",
116
215
  });
@@ -131,6 +230,7 @@ export function parseNotificationsConfig(
131
230
  return {
132
231
  targets: raw?.targets ?? [],
133
232
  events: raw?.events ?? {},
233
+ richFormat: raw?.richFormat ?? false,
134
234
  };
135
235
  }
136
236
 
@@ -143,16 +243,19 @@ export function parseNotificationsConfig(
143
243
  export function createNotifierFromConfig(
144
244
  pluginConfig: Record<string, unknown> | undefined,
145
245
  runtime: PluginRuntime,
246
+ api?: OpenClawPluginApi,
146
247
  ): NotifyFn {
147
248
  const config = parseNotificationsConfig(pluginConfig);
148
249
 
149
250
  if (!config.targets?.length) return createNoopNotifier();
150
251
 
252
+ const useRich = config.richFormat === true;
253
+
151
254
  return async (kind, payload) => {
152
255
  // Check event toggle — default is enabled (true)
153
256
  if (config.events?.[kind] === false) return;
154
257
 
155
- const message = formatMessage(kind, payload);
258
+ const message = useRich ? formatRichMessage(kind, payload) : formatMessage(kind, payload);
156
259
 
157
260
  await Promise.allSettled(
158
261
  config.targets!.map(async (target) => {
@@ -160,6 +263,14 @@ export function createNotifierFromConfig(
160
263
  await sendToTarget(target, message, runtime);
161
264
  } catch (err) {
162
265
  console.error(`Notify error (${target.channel}:${target.target}):`, err);
266
+ if (api) {
267
+ emitDiagnostic(api, {
268
+ event: "notify_failed",
269
+ identifier: payload.identifier,
270
+ phase: kind,
271
+ error: String(err),
272
+ });
273
+ }
163
274
  }
164
275
  }),
165
276
  );
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { emitDiagnostic, type DiagnosticPayload } from "./observability.ts";
3
+
4
+ function makeApi(infoFn = vi.fn()) {
5
+ return { logger: { info: infoFn } } as any;
6
+ }
7
+
8
+ describe("emitDiagnostic", () => {
9
+ it("emits JSON with [linear:diagnostic] prefix via api.logger.info", () => {
10
+ const info = vi.fn();
11
+ const api = makeApi(info);
12
+ emitDiagnostic(api, { event: "webhook_received", identifier: "ISS-42" });
13
+ expect(info).toHaveBeenCalledOnce();
14
+ const line = info.mock.calls[0][0] as string;
15
+ expect(line).toMatch(/^\[linear:diagnostic\] \{/);
16
+ const json = JSON.parse(line.replace("[linear:diagnostic] ", ""));
17
+ expect(json.event).toBe("webhook_received");
18
+ expect(json.identifier).toBe("ISS-42");
19
+ });
20
+
21
+ it("includes all payload fields in JSON output", () => {
22
+ const info = vi.fn();
23
+ const api = makeApi(info);
24
+ const payload: DiagnosticPayload = {
25
+ event: "dispatch_started",
26
+ identifier: "ISS-99",
27
+ issueId: "abc-123",
28
+ phase: "planning",
29
+ from: "triage",
30
+ to: "execution",
31
+ attempt: 2,
32
+ tier: "gold",
33
+ webhookType: "Comment",
34
+ webhookAction: "create",
35
+ channel: "discord",
36
+ target: "kaylee",
37
+ error: "none",
38
+ durationMs: 1234,
39
+ };
40
+ emitDiagnostic(api, payload);
41
+ const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
42
+ expect(json).toMatchObject(payload);
43
+ });
44
+
45
+ it("works with partial payload (only event + identifier)", () => {
46
+ const info = vi.fn();
47
+ const api = makeApi(info);
48
+ emitDiagnostic(api, { event: "health_check" });
49
+ const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
50
+ expect(json.event).toBe("health_check");
51
+ expect(json.identifier).toBeUndefined();
52
+ });
53
+
54
+ it("never throws even if logger throws", () => {
55
+ const api = makeApi(() => { throw new Error("logger exploded"); });
56
+ expect(() => {
57
+ emitDiagnostic(api, { event: "notify_failed", identifier: "ISS-1" });
58
+ }).not.toThrow();
59
+ });
60
+
61
+ it("includes timestamp-relevant fields — payload is faithfully serialized", () => {
62
+ const info = vi.fn();
63
+ const api = makeApi(info);
64
+ const now = Date.now();
65
+ emitDiagnostic(api, { event: "phase_transition", identifier: "ISS-7", timestamp: now } as any);
66
+ const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
67
+ expect(json.timestamp).toBe(now);
68
+ });
69
+
70
+ it("handles payload with special characters", () => {
71
+ const info = vi.fn();
72
+ const api = makeApi(info);
73
+ emitDiagnostic(api, {
74
+ event: "notify_sent",
75
+ identifier: 'ISS-"special"',
76
+ error: "line1\nline2\ttab",
77
+ channel: "<script>alert('xss')</script>",
78
+ });
79
+ expect(info).toHaveBeenCalledOnce();
80
+ const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
81
+ expect(json.identifier).toBe('ISS-"special"');
82
+ expect(json.error).toBe("line1\nline2\ttab");
83
+ expect(json.channel).toBe("<script>alert('xss')</script>");
84
+ });
85
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * observability.ts — Structured diagnostic event logging.
3
+ *
4
+ * Emits structured JSON log lines via api.logger for lifecycle telemetry.
5
+ * Consumers (log aggregators, monitoring) can parse these for dashboards.
6
+ *
7
+ * Pattern: `[linear:diagnostic] {...json...}`
8
+ */
9
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
10
+
11
+ export type DiagnosticEvent =
12
+ | "webhook_received"
13
+ | "dispatch_started"
14
+ | "phase_transition"
15
+ | "audit_triggered"
16
+ | "verdict_processed"
17
+ | "watchdog_kill"
18
+ | "notify_sent"
19
+ | "notify_failed"
20
+ | "health_check";
21
+
22
+ export interface DiagnosticPayload {
23
+ event: DiagnosticEvent;
24
+ identifier?: string;
25
+ issueId?: string;
26
+ phase?: string;
27
+ from?: string;
28
+ to?: string;
29
+ attempt?: number;
30
+ tier?: string;
31
+ webhookType?: string;
32
+ webhookAction?: string;
33
+ channel?: string;
34
+ target?: string;
35
+ error?: string;
36
+ durationMs?: number;
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ const PREFIX = "[linear:diagnostic]";
41
+
42
+ export function emitDiagnostic(api: OpenClawPluginApi, payload: DiagnosticPayload): void {
43
+ try {
44
+ api.logger.info(`${PREFIX} ${JSON.stringify(payload)}`);
45
+ } catch {
46
+ // Never throw from telemetry
47
+ }
48
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ createRetryPolicy,
4
+ createCircuitBreaker,
5
+ withResilience,
6
+ resetDefaultPolicy,
7
+ } from "./resilience.js";
8
+ import { BrokenCircuitError } from "cockatiel";
9
+
10
+ beforeEach(() => {
11
+ resetDefaultPolicy();
12
+ });
13
+
14
+ describe("createRetryPolicy", () => {
15
+ it("retries on transient failure then succeeds", async () => {
16
+ let calls = 0;
17
+ const policy = createRetryPolicy({ attempts: 3, initialDelay: 10, maxDelay: 20 });
18
+ const result = await policy.execute(async () => {
19
+ calls++;
20
+ if (calls < 3) throw new Error("transient");
21
+ return "ok";
22
+ });
23
+ expect(result).toBe("ok");
24
+ expect(calls).toBe(3);
25
+ });
26
+
27
+ it("throws after exhausting retries", async () => {
28
+ const policy = createRetryPolicy({ attempts: 2, initialDelay: 10, maxDelay: 20 });
29
+ await expect(
30
+ policy.execute(async () => {
31
+ throw new Error("permanent");
32
+ }),
33
+ ).rejects.toThrow("permanent");
34
+ });
35
+ });
36
+
37
+ describe("createCircuitBreaker", () => {
38
+ it("opens after consecutive failures", async () => {
39
+ const breaker = createCircuitBreaker({ threshold: 3, halfOpenAfter: 60_000 });
40
+
41
+ // Fail 3 times to trip the breaker
42
+ for (let i = 0; i < 3; i++) {
43
+ try {
44
+ await breaker.execute(async () => {
45
+ throw new Error("fail");
46
+ });
47
+ } catch {}
48
+ }
49
+
50
+ // Next call should fail fast with BrokenCircuitError
51
+ await expect(
52
+ breaker.execute(async () => "should not run"),
53
+ ).rejects.toThrow(BrokenCircuitError);
54
+ });
55
+
56
+ it("allows calls when under threshold", async () => {
57
+ const breaker = createCircuitBreaker({ threshold: 5, halfOpenAfter: 60_000 });
58
+
59
+ // Fail twice then succeed — should not trip
60
+ let calls = 0;
61
+ for (let i = 0; i < 2; i++) {
62
+ try {
63
+ await breaker.execute(async () => {
64
+ throw new Error("fail");
65
+ });
66
+ } catch {}
67
+ }
68
+
69
+ const result = await breaker.execute(async () => {
70
+ calls++;
71
+ return "ok";
72
+ });
73
+ expect(result).toBe("ok");
74
+ expect(calls).toBe(1);
75
+ });
76
+ });
77
+
78
+ describe("withResilience", () => {
79
+ it("returns result on success", async () => {
80
+ const result = await withResilience(async () => 42);
81
+ expect(result).toBe(42);
82
+ });
83
+
84
+ it("retries transient failures", async () => {
85
+ let calls = 0;
86
+ const result = await withResilience(async () => {
87
+ calls++;
88
+ if (calls < 2) throw new Error("transient");
89
+ return "recovered";
90
+ });
91
+ expect(result).toBe("recovered");
92
+ expect(calls).toBe(2);
93
+ });
94
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * resilience.ts — Retry + circuit breaker for external API calls.
3
+ *
4
+ * Wraps functions with exponential backoff retry and a circuit breaker
5
+ * that opens after consecutive failures to prevent cascading overload.
6
+ */
7
+ import {
8
+ retry,
9
+ handleAll,
10
+ ExponentialBackoff,
11
+ CircuitBreakerPolicy,
12
+ circuitBreaker,
13
+ ConsecutiveBreaker,
14
+ wrap,
15
+ type IPolicy,
16
+ } from "cockatiel";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Retry policy
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const DEFAULT_RETRY_ATTEMPTS = 3;
23
+ const DEFAULT_BACKOFF = { initialDelay: 500, maxDelay: 5_000 };
24
+
25
+ /**
26
+ * Create a retry policy with exponential backoff.
27
+ */
28
+ export function createRetryPolicy(opts?: {
29
+ attempts?: number;
30
+ initialDelay?: number;
31
+ maxDelay?: number;
32
+ }): IPolicy {
33
+ const attempts = opts?.attempts ?? DEFAULT_RETRY_ATTEMPTS;
34
+ const initialDelay = opts?.initialDelay ?? DEFAULT_BACKOFF.initialDelay;
35
+ const maxDelay = opts?.maxDelay ?? DEFAULT_BACKOFF.maxDelay;
36
+
37
+ return retry(handleAll, {
38
+ maxAttempts: attempts,
39
+ backoff: new ExponentialBackoff({
40
+ initialDelay,
41
+ maxDelay,
42
+ }),
43
+ });
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Circuit breaker
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const DEFAULT_BREAKER_THRESHOLD = 5;
51
+ const DEFAULT_HALF_OPEN_AFTER = 30_000;
52
+
53
+ /**
54
+ * Create a circuit breaker that opens after consecutive failures.
55
+ */
56
+ export function createCircuitBreaker(opts?: {
57
+ threshold?: number;
58
+ halfOpenAfter?: number;
59
+ }): CircuitBreakerPolicy {
60
+ const threshold = opts?.threshold ?? DEFAULT_BREAKER_THRESHOLD;
61
+ const halfOpenAfter = opts?.halfOpenAfter ?? DEFAULT_HALF_OPEN_AFTER;
62
+
63
+ return circuitBreaker(handleAll, {
64
+ breaker: new ConsecutiveBreaker(threshold),
65
+ halfOpenAfter,
66
+ });
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Combined policy
71
+ // ---------------------------------------------------------------------------
72
+
73
+ let _defaultPolicy: IPolicy | null = null;
74
+
75
+ /**
76
+ * Get the default combined retry + circuit breaker policy (singleton).
77
+ * 3 retries with exponential backoff (500ms → 5s) + circuit breaker
78
+ * (opens after 5 consecutive failures, half-opens after 30s).
79
+ */
80
+ export function getDefaultPolicy(): IPolicy {
81
+ if (!_defaultPolicy) {
82
+ const retryPolicy = createRetryPolicy();
83
+ const breaker = createCircuitBreaker();
84
+ _defaultPolicy = wrap(retryPolicy, breaker);
85
+ }
86
+ return _defaultPolicy;
87
+ }
88
+
89
+ /**
90
+ * Execute a function with the default retry + circuit breaker policy.
91
+ */
92
+ export async function withResilience<T>(fn: () => Promise<T>): Promise<T> {
93
+ return getDefaultPolicy().execute(fn);
94
+ }
95
+
96
+ /**
97
+ * Reset the default policy singleton (for testing).
98
+ */
99
+ export function resetDefaultPolicy(): void {
100
+ _defaultPolicy = null;
101
+ }
@@ -337,11 +337,13 @@ describe("buildSummaryFromArtifacts", () => {
337
337
  // ---------------------------------------------------------------------------
338
338
 
339
339
  describe("writeDispatchMemory", () => {
340
- it("creates memory/ dir and writes file", () => {
340
+ it("creates memory/ dir and writes file with frontmatter", () => {
341
341
  const tmp = makeTmpDir();
342
342
  writeDispatchMemory("API-100", "summary content", tmp);
343
343
  const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
344
- expect(content).toBe("summary content");
344
+ expect(content).toContain("---\n");
345
+ expect(content).toContain('issue: "API-100"');
346
+ expect(content).toContain("summary content");
345
347
  });
346
348
 
347
349
  it("overwrites on second call", () => {
@@ -349,7 +351,28 @@ describe("writeDispatchMemory", () => {
349
351
  writeDispatchMemory("API-100", "first", tmp);
350
352
  writeDispatchMemory("API-100", "second", tmp);
351
353
  const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
352
- expect(content).toBe("second");
354
+ expect(content).toContain("second");
355
+ expect(content).not.toContain("first");
356
+ });
357
+
358
+ it("includes custom metadata in frontmatter", () => {
359
+ const tmp = makeTmpDir();
360
+ writeDispatchMemory("CT-50", "done summary", tmp, {
361
+ title: "Fix login bug",
362
+ tier: "senior",
363
+ status: "done",
364
+ project: "Auth",
365
+ attempts: 2,
366
+ model: "kimi-k2.5",
367
+ });
368
+ const content = readFileSync(join(tmp, "memory", "dispatch-CT-50.md"), "utf-8");
369
+ expect(content).toContain('title: "Fix login bug"');
370
+ expect(content).toContain('tier: "senior"');
371
+ expect(content).toContain('status: "done"');
372
+ expect(content).toContain('project: "Auth"');
373
+ expect(content).toContain("attempts: 2");
374
+ expect(content).toContain('model: "kimi-k2.5"');
375
+ expect(content).toContain("done summary");
353
376
  });
354
377
  });
355
378