@calltelemetry/openclaw-linear 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +359 -195
  2. package/index.ts +10 -10
  3. package/openclaw.plugin.json +4 -1
  4. package/package.json +9 -2
  5. package/src/agent/agent.test.ts +127 -0
  6. package/src/{agent.ts → agent/agent.ts} +84 -7
  7. package/src/agent/watchdog.test.ts +266 -0
  8. package/src/agent/watchdog.ts +176 -0
  9. package/src/{cli.ts → infra/cli.ts} +32 -5
  10. package/src/{codex-worktree.ts → infra/codex-worktree.ts} +1 -1
  11. package/src/infra/doctor.test.ts +399 -0
  12. package/src/infra/doctor.ts +781 -0
  13. package/src/infra/notify.test.ts +169 -0
  14. package/src/{notify.ts → infra/notify.ts} +6 -1
  15. package/src/pipeline/active-session.test.ts +154 -0
  16. package/src/pipeline/artifacts.test.ts +383 -0
  17. package/src/{artifacts.ts → pipeline/artifacts.ts} +9 -1
  18. package/src/{dispatch-service.ts → pipeline/dispatch-service.ts} +1 -1
  19. package/src/pipeline/dispatch-state.test.ts +382 -0
  20. package/src/pipeline/pipeline.test.ts +226 -0
  21. package/src/{pipeline.ts → pipeline/pipeline.ts} +61 -7
  22. package/src/{tier-assess.ts → pipeline/tier-assess.ts} +1 -1
  23. package/src/{webhook.test.ts → pipeline/webhook.test.ts} +1 -1
  24. package/src/{webhook.ts → pipeline/webhook.ts} +8 -8
  25. package/src/{claude-tool.ts → tools/claude-tool.ts} +31 -5
  26. package/src/{cli-shared.ts → tools/cli-shared.ts} +5 -4
  27. package/src/{code-tool.ts → tools/code-tool.ts} +2 -2
  28. package/src/{codex-tool.ts → tools/codex-tool.ts} +31 -5
  29. package/src/{gemini-tool.ts → tools/gemini-tool.ts} +31 -5
  30. package/src/{orchestration-tools.ts → tools/orchestration-tools.ts} +1 -1
  31. package/src/client.ts +0 -94
  32. /package/src/{auth.ts → api/auth.ts} +0 -0
  33. /package/src/{linear-api.ts → api/linear-api.ts} +0 -0
  34. /package/src/{oauth-callback.ts → api/oauth-callback.ts} +0 -0
  35. /package/src/{active-session.ts → pipeline/active-session.ts} +0 -0
  36. /package/src/{dispatch-state.ts → pipeline/dispatch-state.ts} +0 -0
  37. /package/src/{tools.ts → tools/tools.ts} +0 -0
package/index.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
- import { registerLinearProvider } from "./src/auth.js";
4
- import { registerCli } from "./src/cli.js";
5
- import { createLinearTools } from "./src/tools.js";
6
- import { handleLinearWebhook } from "./src/webhook.js";
7
- import { handleOAuthCallback } from "./src/oauth-callback.js";
8
- import { LinearAgentApi, resolveLinearToken } from "./src/linear-api.js";
9
- import { createDispatchService } from "./src/dispatch-service.js";
10
- import { readDispatchState, lookupSessionMapping, getActiveDispatch } from "./src/dispatch-state.js";
11
- import { triggerAudit, processVerdict, type HookContext } from "./src/pipeline.js";
12
- import { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "./src/notify.js";
3
+ import { registerLinearProvider } from "./src/api/auth.js";
4
+ import { registerCli } from "./src/infra/cli.js";
5
+ import { createLinearTools } from "./src/tools/tools.js";
6
+ import { handleLinearWebhook } from "./src/pipeline/webhook.js";
7
+ import { handleOAuthCallback } from "./src/api/oauth-callback.js";
8
+ import { LinearAgentApi, resolveLinearToken } from "./src/api/linear-api.js";
9
+ import { createDispatchService } from "./src/pipeline/dispatch-service.js";
10
+ import { readDispatchState, lookupSessionMapping, getActiveDispatch } from "./src/pipeline/dispatch-state.js";
11
+ import { triggerAudit, processVerdict, type HookContext } from "./src/pipeline/pipeline.js";
12
+ import { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "./src/infra/notify.js";
13
13
 
14
14
  export default function register(api: OpenClawPluginApi) {
15
15
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
@@ -22,7 +22,10 @@
22
22
  "dispatchStatePath": { "type": "string", "description": "Path to dispatch state JSON file (default: ~/.openclaw/linear-dispatch-state.json)" },
23
23
  "flowDiscordChannel": { "type": "string", "description": "Discord channel ID for dispatch lifecycle notifications (omit to disable)" },
24
24
  "promptsPath": { "type": "string", "description": "Override path for prompts.yaml (default: ships with plugin)" },
25
- "maxReworkAttempts": { "type": "number", "description": "Max audit failures before escalation", "default": 2 }
25
+ "maxReworkAttempts": { "type": "number", "description": "Max audit failures before escalation", "default": 2 },
26
+ "inactivitySec": { "type": "number", "description": "Kill sessions with no I/O for this many seconds (default: 120)", "default": 120 },
27
+ "maxTotalSec": { "type": "number", "description": "Max total runtime for agent sessions in seconds (default: 7200)", "default": 7200 },
28
+ "toolTimeoutSec": { "type": "number", "description": "Max runtime for a single code_run CLI invocation in seconds (default: 600)", "default": 600 }
26
29
  }
27
30
  }
28
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,12 +25,19 @@
25
25
  "prompts.yaml",
26
26
  "README.md"
27
27
  ],
28
+ "scripts": {
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "test:coverage": "vitest run --coverage"
32
+ },
28
33
  "publishConfig": {
29
34
  "access": "public"
30
35
  },
31
36
  "devDependencies": {
37
+ "@vitest/coverage-v8": "^4.0.18",
32
38
  "openclaw": "^2026.2.13",
33
- "typescript": "^5.9.3"
39
+ "typescript": "^5.9.3",
40
+ "vitest": "^4.0.18"
34
41
  },
35
42
  "openclaw": {
36
43
  "extensions": [
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+
3
+ // Mock dependencies so we can control runAgentOnce behavior
4
+ const mockRunEmbedded = vi.fn();
5
+ const mockRunSubprocess = vi.fn();
6
+ const mockGetExtensionAPI = vi.fn();
7
+ const mockResolveWatchdogConfig = vi.fn().mockReturnValue({
8
+ inactivityMs: 120_000,
9
+ maxTotalMs: 7_200_000,
10
+ toolTimeoutMs: 600_000,
11
+ });
12
+
13
+ vi.mock("./watchdog.js", () => ({
14
+ InactivityWatchdog: class {
15
+ wasKilled = false;
16
+ silenceMs = 0;
17
+ start() {}
18
+ tick() {}
19
+ stop() {}
20
+ },
21
+ resolveWatchdogConfig: (...args: any[]) => mockResolveWatchdogConfig(...args),
22
+ }));
23
+
24
+ // We need to test the runAgent retry wrapper. Since runAgentOnce is internal,
25
+ // we test through runAgent by controlling the embedded/subprocess behavior.
26
+ // The simplest approach: mock the entire module internals via the extension API.
27
+
28
+ vi.mock("node:fs", async (importOriginal) => {
29
+ const actual = await importOriginal<typeof import("node:fs")>();
30
+ return {
31
+ ...actual,
32
+ readFileSync: vi.fn().mockReturnValue("{}"),
33
+ mkdirSync: vi.fn(),
34
+ };
35
+ });
36
+
37
+ import { runAgent } from "./agent.js";
38
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
39
+
40
+ function createApi(): OpenClawPluginApi {
41
+ return {
42
+ logger: {
43
+ info: vi.fn(),
44
+ warn: vi.fn(),
45
+ error: vi.fn(),
46
+ debug: vi.fn(),
47
+ },
48
+ runtime: {
49
+ config: {
50
+ loadConfig: vi.fn().mockReturnValue({ agents: { list: [] } }),
51
+ },
52
+ system: {
53
+ runCommandWithTimeout: vi.fn().mockResolvedValue({
54
+ code: 0,
55
+ stdout: JSON.stringify({ result: { payloads: [{ text: "subprocess output" }] } }),
56
+ stderr: "",
57
+ }),
58
+ },
59
+ },
60
+ pluginConfig: {},
61
+ } as unknown as OpenClawPluginApi;
62
+ }
63
+
64
+ describe("runAgent retry wrapper", () => {
65
+ it("returns success on first attempt when no watchdog kill", async () => {
66
+ const api = createApi();
67
+ // Mock subprocess fallback (no streaming → uses subprocess)
68
+ (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
69
+ code: 0,
70
+ stdout: JSON.stringify({ result: { payloads: [{ text: "done" }] } }),
71
+ stderr: "",
72
+ });
73
+
74
+ const result = await runAgent({
75
+ api,
76
+ agentId: "test-agent",
77
+ sessionId: "session-1",
78
+ message: "do something",
79
+ });
80
+
81
+ expect(result.success).toBe(true);
82
+ expect(result.output).toContain("done");
83
+ // Should only call once
84
+ expect((api.runtime.system as any).runCommandWithTimeout).toHaveBeenCalledOnce();
85
+ });
86
+
87
+ it("returns failure without retry on normal (non-watchdog) failure", async () => {
88
+ const api = createApi();
89
+ (api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
90
+ code: 1,
91
+ stdout: "",
92
+ stderr: "some error",
93
+ });
94
+
95
+ const result = await runAgent({
96
+ api,
97
+ agentId: "test-agent",
98
+ sessionId: "session-1",
99
+ message: "do something",
100
+ });
101
+
102
+ expect(result.success).toBe(false);
103
+ expect(result.watchdogKilled).toBeUndefined();
104
+ // Only one attempt — no retry for non-watchdog failures
105
+ expect((api.runtime.system as any).runCommandWithTimeout).toHaveBeenCalledOnce();
106
+ });
107
+
108
+ it("does not retry on success", async () => {
109
+ const api = createApi();
110
+ const runCmd = vi.fn().mockResolvedValue({
111
+ code: 0,
112
+ stdout: JSON.stringify({ result: { payloads: [{ text: "all good" }] } }),
113
+ stderr: "",
114
+ });
115
+ (api.runtime.system as any).runCommandWithTimeout = runCmd;
116
+
117
+ const result = await runAgent({
118
+ api,
119
+ agentId: "test-agent",
120
+ sessionId: "session-1",
121
+ message: "do something",
122
+ });
123
+
124
+ expect(result.success).toBe(true);
125
+ expect(runCmd).toHaveBeenCalledOnce();
126
+ });
127
+ });
@@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
  import { mkdirSync, readFileSync } from "node:fs";
4
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
5
- import type { LinearAgentApi, ActivityContent } from "./linear-api.js";
5
+ import type { LinearAgentApi, ActivityContent } from "../api/linear-api.js";
6
+ import { InactivityWatchdog, resolveWatchdogConfig } from "./watchdog.js";
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // Agent directory resolution (config-based, not ext API which ignores agentId)
@@ -46,6 +47,7 @@ async function getExtensionAPI() {
46
47
  export interface AgentRunResult {
47
48
  success: boolean;
48
49
  output: string;
50
+ watchdogKilled?: boolean;
49
51
  }
50
52
 
51
53
  export interface AgentStreamCallbacks {
@@ -54,8 +56,10 @@ export interface AgentStreamCallbacks {
54
56
  }
55
57
 
56
58
  /**
57
- * Run an agent using the embedded runner with streaming callbacks.
58
- * Falls back to subprocess if the embedded runner is unavailable.
59
+ * Run an agent with automatic retry on watchdog kill.
60
+ *
61
+ * Tries embedded runner first (if streaming callbacks provided), falls back
62
+ * to subprocess. If the inactivity watchdog kills the run, retries once.
59
63
  */
60
64
  export async function runAgent(params: {
61
65
  api: OpenClawPluginApi;
@@ -65,14 +69,54 @@ export async function runAgent(params: {
65
69
  timeoutMs?: number;
66
70
  streaming?: AgentStreamCallbacks;
67
71
  }): Promise<AgentRunResult> {
68
- const { api, agentId, sessionId, message, timeoutMs = 5 * 60_000, streaming } = params;
72
+ const maxAttempts = 2;
69
73
 
70
- api.logger.info(`Dispatching agent ${agentId} for session ${sessionId}`);
74
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
75
+ const result = await runAgentOnce(params);
76
+
77
+ if (result.success || !result.watchdogKilled || attempt === maxAttempts - 1) {
78
+ return result;
79
+ }
80
+
81
+ params.api.logger.warn(
82
+ `Agent ${params.agentId} killed by watchdog, retrying (attempt ${attempt + 1}/${maxAttempts})`,
83
+ );
84
+
85
+ // Emit Linear activity about the retry if streaming
86
+ if (params.streaming) {
87
+ params.streaming.linearApi.emitActivity(params.streaming.agentSessionId, {
88
+ type: "error",
89
+ body: `Agent killed by inactivity watchdog — no I/O for the configured threshold. Retrying...`,
90
+ }).catch(() => {});
91
+ }
92
+ }
93
+
94
+ // Unreachable, but TypeScript needs it
95
+ return { success: false, output: "Watchdog retry exhausted" };
96
+ }
97
+
98
+ /**
99
+ * Single attempt to run an agent (no retry logic).
100
+ */
101
+ async function runAgentOnce(params: {
102
+ api: OpenClawPluginApi;
103
+ agentId: string;
104
+ sessionId: string;
105
+ message: string;
106
+ timeoutMs?: number;
107
+ streaming?: AgentStreamCallbacks;
108
+ }): Promise<AgentRunResult> {
109
+ const { api, agentId, sessionId, message, streaming } = params;
110
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
111
+ const wdConfig = resolveWatchdogConfig(agentId, pluginConfig);
112
+ const timeoutMs = params.timeoutMs ?? wdConfig.maxTotalMs;
113
+
114
+ api.logger.info(`Dispatching agent ${agentId} for session ${sessionId} (timeout=${Math.round(timeoutMs / 1000)}s, inactivity=${Math.round(wdConfig.inactivityMs / 1000)}s)`);
71
115
 
72
116
  // Try embedded runner first (has streaming callbacks)
73
117
  if (streaming) {
74
118
  try {
75
- return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming);
119
+ return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs);
76
120
  } catch (err) {
77
121
  api.logger.warn(`Embedded runner failed, falling back to subprocess: ${err}`);
78
122
  }
@@ -83,7 +127,7 @@ export async function runAgent(params: {
83
127
  }
84
128
 
85
129
  /**
86
- * Embedded agent runner with real-time streaming to Linear.
130
+ * Embedded agent runner with real-time streaming to Linear and inactivity watchdog.
87
131
  */
88
132
  async function runEmbedded(
89
133
  api: OpenClawPluginApi,
@@ -92,6 +136,7 @@ async function runEmbedded(
92
136
  message: string,
93
137
  timeoutMs: number,
94
138
  streaming: AgentStreamCallbacks,
139
+ inactivityMs: number,
95
140
  ): Promise<AgentRunResult> {
96
141
  const ext = await getExtensionAPI();
97
142
 
@@ -131,9 +176,23 @@ async function runEmbedded(
131
176
  });
132
177
  };
133
178
 
179
+ // --- Inactivity watchdog ---
180
+ const controller = new AbortController();
181
+ const watchdog = new InactivityWatchdog({
182
+ inactivityMs,
183
+ label: `embedded:${agentId}:${sessionId}`,
184
+ logger: api.logger,
185
+ onKill: () => {
186
+ controller.abort();
187
+ try { ext.abortEmbeddedPiRun(sessionId); } catch {}
188
+ },
189
+ });
190
+
134
191
  // Track last emitted tool to avoid duplicates
135
192
  let lastToolAction = "";
136
193
 
194
+ watchdog.start();
195
+
137
196
  const result = await ext.runEmbeddedPiAgent({
138
197
  sessionId,
139
198
  sessionFile,
@@ -146,11 +205,13 @@ async function runEmbedded(
146
205
  config,
147
206
  provider,
148
207
  model,
208
+ abortSignal: controller.signal,
149
209
  shouldEmitToolResult: () => true,
150
210
  shouldEmitToolOutput: () => true,
151
211
 
152
212
  // Stream reasoning/thinking to Linear
153
213
  onReasoningStream: (payload) => {
214
+ watchdog.tick();
154
215
  const text = payload.text?.trim();
155
216
  if (text && text.length > 10) {
156
217
  emit({ type: "thought", body: text.slice(0, 500) });
@@ -159,6 +220,7 @@ async function runEmbedded(
159
220
 
160
221
  // Stream tool results to Linear
161
222
  onToolResult: (payload) => {
223
+ watchdog.tick();
162
224
  const text = payload.text?.trim();
163
225
  if (text) {
164
226
  // Truncate tool results for activity display
@@ -169,6 +231,7 @@ async function runEmbedded(
169
231
 
170
232
  // Raw agent events — capture tool starts/ends
171
233
  onAgentEvent: (evt) => {
234
+ watchdog.tick();
172
235
  const { stream, data } = evt;
173
236
 
174
237
  if (stream !== "tool") return;
@@ -191,11 +254,14 @@ async function runEmbedded(
191
254
 
192
255
  // Partial assistant text (for long responses)
193
256
  onPartialReply: (payload) => {
257
+ watchdog.tick();
194
258
  // We don't emit every partial chunk to avoid flooding Linear
195
259
  // The final response will be posted as a comment
196
260
  },
197
261
  });
198
262
 
263
+ watchdog.stop();
264
+
199
265
  // Extract output text from payloads
200
266
  const payloads = result.payloads ?? [];
201
267
  const outputText = payloads
@@ -203,6 +269,17 @@ async function runEmbedded(
203
269
  .filter(Boolean)
204
270
  .join("\n\n");
205
271
 
272
+ // Check if watchdog killed the run
273
+ if (watchdog.wasKilled) {
274
+ const silenceSec = Math.round(watchdog.silenceMs / 1000);
275
+ api.logger.warn(`Embedded agent killed by watchdog: agent=${agentId} session=${sessionId} silence=${silenceSec}s`);
276
+ return {
277
+ success: false,
278
+ output: outputText || `Agent killed by inactivity watchdog after ${silenceSec}s of silence.`,
279
+ watchdogKilled: true,
280
+ };
281
+ }
282
+
206
283
  if (result.meta?.error) {
207
284
  api.logger.error(`Embedded agent error: ${result.meta.error.kind}: ${result.meta.error.message}`);
208
285
  return { success: false, output: outputText || result.meta.error.message };
@@ -0,0 +1,266 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Hoist the mock for readFileSync so resolveWatchdogConfig tests can control it
4
+ const { mockReadFileSync } = vi.hoisted(() => ({
5
+ mockReadFileSync: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("node:fs", async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import("node:fs")>();
10
+ return {
11
+ ...actual,
12
+ readFileSync: (...args: any[]) => mockReadFileSync(...args),
13
+ };
14
+ });
15
+
16
+ import { InactivityWatchdog, resolveWatchdogConfig, DEFAULT_INACTIVITY_SEC, DEFAULT_MAX_TOTAL_SEC, DEFAULT_TOOL_TIMEOUT_SEC } from "./watchdog.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // InactivityWatchdog
20
+ // ---------------------------------------------------------------------------
21
+
22
+ describe("InactivityWatchdog", () => {
23
+ beforeEach(() => {
24
+ vi.useFakeTimers();
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.useRealTimers();
29
+ });
30
+
31
+ const makeLogger = () => ({
32
+ info: vi.fn(),
33
+ warn: vi.fn(),
34
+ });
35
+
36
+ it("fires onKill after inactivity threshold", () => {
37
+ const onKill = vi.fn();
38
+ const wd = new InactivityWatchdog({
39
+ inactivityMs: 5_000,
40
+ label: "test",
41
+ logger: makeLogger(),
42
+ onKill,
43
+ });
44
+
45
+ wd.start();
46
+ vi.advanceTimersByTime(6_000);
47
+
48
+ expect(onKill).toHaveBeenCalledOnce();
49
+ expect(onKill).toHaveBeenCalledWith("inactivity");
50
+ expect(wd.wasKilled).toBe(true);
51
+
52
+ wd.stop();
53
+ });
54
+
55
+ it("tick() resets the timer", () => {
56
+ const onKill = vi.fn();
57
+ const wd = new InactivityWatchdog({
58
+ inactivityMs: 5_000,
59
+ label: "test",
60
+ logger: makeLogger(),
61
+ onKill,
62
+ });
63
+
64
+ wd.start();
65
+ vi.advanceTimersByTime(4_000);
66
+ expect(onKill).not.toHaveBeenCalled();
67
+
68
+ wd.tick(); // reset at 4s
69
+
70
+ vi.advanceTimersByTime(4_000); // now at 8s total, 4s since tick
71
+ expect(onKill).not.toHaveBeenCalled();
72
+
73
+ vi.advanceTimersByTime(2_000); // now 6s since tick → should fire
74
+ expect(onKill).toHaveBeenCalledOnce();
75
+
76
+ wd.stop();
77
+ });
78
+
79
+ it("stop() prevents kill", () => {
80
+ const onKill = vi.fn();
81
+ const wd = new InactivityWatchdog({
82
+ inactivityMs: 5_000,
83
+ label: "test",
84
+ logger: makeLogger(),
85
+ onKill,
86
+ });
87
+
88
+ wd.start();
89
+ vi.advanceTimersByTime(2_000);
90
+ wd.stop();
91
+
92
+ vi.advanceTimersByTime(10_000);
93
+ expect(onKill).not.toHaveBeenCalled();
94
+ expect(wd.wasKilled).toBe(false);
95
+ });
96
+
97
+ it("wasKilled is true after kill", () => {
98
+ const onKill = vi.fn();
99
+ const wd = new InactivityWatchdog({
100
+ inactivityMs: 2_000,
101
+ label: "test",
102
+ logger: makeLogger(),
103
+ onKill,
104
+ });
105
+
106
+ wd.start();
107
+ expect(wd.wasKilled).toBe(false);
108
+
109
+ vi.advanceTimersByTime(3_000);
110
+ expect(wd.wasKilled).toBe(true);
111
+
112
+ wd.stop();
113
+ });
114
+
115
+ it("silenceMs tracks time since last activity", () => {
116
+ const onKill = vi.fn();
117
+ const wd = new InactivityWatchdog({
118
+ inactivityMs: 60_000,
119
+ label: "test",
120
+ logger: makeLogger(),
121
+ onKill,
122
+ });
123
+
124
+ wd.start();
125
+ vi.advanceTimersByTime(3_000);
126
+
127
+ // silenceMs should be roughly 3000 (fake timers keep Date.now in sync)
128
+ expect(wd.silenceMs).toBeGreaterThanOrEqual(3_000);
129
+
130
+ wd.tick();
131
+ expect(wd.silenceMs).toBeLessThan(100); // just ticked
132
+
133
+ wd.stop();
134
+ });
135
+
136
+ it("start() is idempotent", () => {
137
+ const onKill = vi.fn();
138
+ const wd = new InactivityWatchdog({
139
+ inactivityMs: 5_000,
140
+ label: "test",
141
+ logger: makeLogger(),
142
+ onKill,
143
+ });
144
+
145
+ wd.start();
146
+ wd.start(); // second call should be no-op
147
+ wd.start(); // third call
148
+
149
+ vi.advanceTimersByTime(6_000);
150
+ // Should only fire once, not multiple times from duplicate timers
151
+ expect(onKill).toHaveBeenCalledOnce();
152
+
153
+ wd.stop();
154
+ });
155
+
156
+ it("handles async onKill errors gracefully", () => {
157
+ const onKill = vi.fn().mockRejectedValue(new Error("kill failed"));
158
+ const logger = makeLogger();
159
+ const wd = new InactivityWatchdog({
160
+ inactivityMs: 2_000,
161
+ label: "test",
162
+ logger,
163
+ onKill,
164
+ });
165
+
166
+ wd.start();
167
+ vi.advanceTimersByTime(3_000);
168
+
169
+ expect(onKill).toHaveBeenCalledOnce();
170
+ expect(wd.wasKilled).toBe(true);
171
+ // Error is caught and logged, not thrown
172
+ expect(logger.warn).toHaveBeenCalled();
173
+
174
+ wd.stop();
175
+ });
176
+
177
+ it("handles sync onKill errors gracefully", () => {
178
+ const onKill = vi.fn().mockImplementation(() => {
179
+ throw new Error("sync kill error");
180
+ });
181
+ const logger = makeLogger();
182
+ const wd = new InactivityWatchdog({
183
+ inactivityMs: 2_000,
184
+ label: "test",
185
+ logger,
186
+ onKill,
187
+ });
188
+
189
+ wd.start();
190
+ // Should not throw
191
+ vi.advanceTimersByTime(3_000);
192
+
193
+ expect(onKill).toHaveBeenCalledOnce();
194
+ expect(wd.wasKilled).toBe(true);
195
+ expect(logger.warn).toHaveBeenCalled();
196
+
197
+ wd.stop();
198
+ });
199
+ });
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // resolveWatchdogConfig
203
+ // ---------------------------------------------------------------------------
204
+
205
+ describe("resolveWatchdogConfig", () => {
206
+ afterEach(() => {
207
+ mockReadFileSync.mockReset();
208
+ });
209
+
210
+ it("reads from agent-profiles.json", () => {
211
+ mockReadFileSync.mockReturnValue(JSON.stringify({
212
+ agents: {
213
+ zoe: {
214
+ watchdog: { inactivitySec: 180, maxTotalSec: 7200, toolTimeoutSec: 900 },
215
+ },
216
+ },
217
+ }));
218
+
219
+ const config = resolveWatchdogConfig("zoe");
220
+ expect(config.inactivityMs).toBe(180_000);
221
+ expect(config.maxTotalMs).toBe(7_200_000);
222
+ expect(config.toolTimeoutMs).toBe(900_000);
223
+ });
224
+
225
+ it("falls back to plugin config when agent profile not found", () => {
226
+ mockReadFileSync.mockImplementation(() => {
227
+ throw new Error("file not found");
228
+ });
229
+
230
+ const config = resolveWatchdogConfig("unknown-agent", {
231
+ inactivitySec: 60,
232
+ maxTotalSec: 3600,
233
+ toolTimeoutSec: 300,
234
+ });
235
+
236
+ expect(config.inactivityMs).toBe(60_000);
237
+ expect(config.maxTotalMs).toBe(3_600_000);
238
+ expect(config.toolTimeoutMs).toBe(300_000);
239
+ });
240
+
241
+ it("falls back to defaults when no config available", () => {
242
+ mockReadFileSync.mockImplementation(() => {
243
+ throw new Error("file not found");
244
+ });
245
+
246
+ const config = resolveWatchdogConfig("nonexistent");
247
+ expect(config.inactivityMs).toBe(DEFAULT_INACTIVITY_SEC * 1000);
248
+ expect(config.maxTotalMs).toBe(DEFAULT_MAX_TOTAL_SEC * 1000);
249
+ expect(config.toolTimeoutMs).toBe(DEFAULT_TOOL_TIMEOUT_SEC * 1000);
250
+ });
251
+
252
+ it("converts seconds to ms", () => {
253
+ mockReadFileSync.mockReturnValue(JSON.stringify({
254
+ agents: {
255
+ mal: {
256
+ watchdog: { inactivitySec: 60, maxTotalSec: 600, toolTimeoutSec: 300 },
257
+ },
258
+ },
259
+ }));
260
+
261
+ const config = resolveWatchdogConfig("mal");
262
+ expect(config.inactivityMs).toBe(60 * 1000);
263
+ expect(config.maxTotalMs).toBe(600 * 1000);
264
+ expect(config.toolTimeoutMs).toBe(300 * 1000);
265
+ });
266
+ });