@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.
- package/README.md +359 -195
- package/index.ts +10 -10
- package/openclaw.plugin.json +4 -1
- package/package.json +9 -2
- package/src/agent/agent.test.ts +127 -0
- package/src/{agent.ts → agent/agent.ts} +84 -7
- package/src/agent/watchdog.test.ts +266 -0
- package/src/agent/watchdog.ts +176 -0
- package/src/{cli.ts → infra/cli.ts} +32 -5
- package/src/{codex-worktree.ts → infra/codex-worktree.ts} +1 -1
- package/src/infra/doctor.test.ts +399 -0
- package/src/infra/doctor.ts +781 -0
- package/src/infra/notify.test.ts +169 -0
- package/src/{notify.ts → infra/notify.ts} +6 -1
- package/src/pipeline/active-session.test.ts +154 -0
- package/src/pipeline/artifacts.test.ts +383 -0
- package/src/{artifacts.ts → pipeline/artifacts.ts} +9 -1
- package/src/{dispatch-service.ts → pipeline/dispatch-service.ts} +1 -1
- package/src/pipeline/dispatch-state.test.ts +382 -0
- package/src/pipeline/pipeline.test.ts +226 -0
- package/src/{pipeline.ts → pipeline/pipeline.ts} +61 -7
- package/src/{tier-assess.ts → pipeline/tier-assess.ts} +1 -1
- package/src/{webhook.test.ts → pipeline/webhook.test.ts} +1 -1
- package/src/{webhook.ts → pipeline/webhook.ts} +8 -8
- package/src/{claude-tool.ts → tools/claude-tool.ts} +31 -5
- package/src/{cli-shared.ts → tools/cli-shared.ts} +5 -4
- package/src/{code-tool.ts → tools/code-tool.ts} +2 -2
- package/src/{codex-tool.ts → tools/codex-tool.ts} +31 -5
- package/src/{gemini-tool.ts → tools/gemini-tool.ts} +31 -5
- package/src/{orchestration-tools.ts → tools/orchestration-tools.ts} +1 -1
- package/src/client.ts +0 -94
- /package/src/{auth.ts → api/auth.ts} +0 -0
- /package/src/{linear-api.ts → api/linear-api.ts} +0 -0
- /package/src/{oauth-callback.ts → api/oauth-callback.ts} +0 -0
- /package/src/{active-session.ts → pipeline/active-session.ts} +0 -0
- /package/src/{dispatch-state.ts → pipeline/dispatch-state.ts} +0 -0
- /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;
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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 "
|
|
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
|
|
58
|
-
*
|
|
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
|
|
72
|
+
const maxAttempts = 2;
|
|
69
73
|
|
|
70
|
-
|
|
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
|
+
});
|