@calltelemetry/openclaw-linear 0.9.3 → 0.9.4
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/package.json +1 -1
- package/src/__test__/smoke-linear-api.test.ts +2 -1
- package/src/__test__/webhook-scenarios.test.ts +3 -0
- package/src/agent/agent.ts +9 -7
- package/src/agent/watchdog.ts +1 -1
- package/src/api/linear-api.ts +2 -1
- package/src/api/oauth-callback.ts +2 -1
- package/src/infra/cli.ts +2 -2
- package/src/infra/codex-worktree.ts +2 -2
- package/src/infra/config-paths.test.ts +3 -0
- package/src/infra/doctor.test.ts +621 -1
- package/src/infra/multi-repo.test.ts +11 -9
- package/src/infra/multi-repo.ts +3 -2
- package/src/infra/shared-profiles.ts +2 -1
- package/src/infra/template.test.ts +2 -2
- package/src/pipeline/active-session.test.ts +96 -1
- package/src/pipeline/active-session.ts +60 -0
- package/src/pipeline/artifacts.ts +1 -1
- package/src/pipeline/pipeline.test.ts +3 -0
- package/src/pipeline/webhook-dedup.test.ts +3 -0
- package/src/pipeline/webhook.test.ts +2088 -2
- package/src/pipeline/webhook.ts +24 -6
- package/src/tools/claude-tool.ts +1 -1
- package/src/tools/cli-shared.test.ts +3 -0
- package/src/tools/cli-shared.ts +3 -1
- package/src/tools/code-tool.test.ts +3 -0
- package/src/tools/code-tool.ts +1 -1
- package/src/tools/codex-tool.ts +1 -1
- package/src/tools/gemini-tool.ts +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { resolveRepos, isMultiRepo, validateRepoPath, type RepoResolution } from "./multi-repo.ts";
|
|
3
5
|
|
|
4
6
|
vi.mock("node:fs", async (importOriginal) => {
|
|
@@ -46,12 +48,12 @@ describe("resolveRepos", () => {
|
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
it("falls back to config repos when no markers/labels", () => {
|
|
49
|
-
const config = { codexBaseRepo: "/
|
|
51
|
+
const config = { codexBaseRepo: "/tmp/test/myproject" };
|
|
50
52
|
const result = resolveRepos("Plain description", [], config);
|
|
51
53
|
expect(result.source).toBe("config_default");
|
|
52
54
|
expect(result.repos).toHaveLength(1);
|
|
53
55
|
expect(result.repos[0].name).toBe("default");
|
|
54
|
-
expect(result.repos[0].path).toBe("/
|
|
56
|
+
expect(result.repos[0].path).toBe("/tmp/test/myproject");
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
it("body markers take priority over labels", () => {
|
|
@@ -69,7 +71,7 @@ describe("resolveRepos", () => {
|
|
|
69
71
|
expect(result.source).toBe("config_default");
|
|
70
72
|
expect(result.repos).toHaveLength(1);
|
|
71
73
|
expect(result.repos[0].name).toBe("default");
|
|
72
|
-
expect(result.repos[0].path).toBe("
|
|
74
|
+
expect(result.repos[0].path).toBe(path.join(homedir(), "ai-workspace"));
|
|
73
75
|
});
|
|
74
76
|
|
|
75
77
|
it("handles empty description + no labels (single repo fallback)", () => {
|
|
@@ -104,8 +106,8 @@ describe("isMultiRepo", () => {
|
|
|
104
106
|
it("returns true for 2+ repos", () => {
|
|
105
107
|
const resolution: RepoResolution = {
|
|
106
108
|
repos: [
|
|
107
|
-
{ name: "api", path: "/
|
|
108
|
-
{ name: "frontend", path: "/
|
|
109
|
+
{ name: "api", path: "/tmp/test/api" },
|
|
110
|
+
{ name: "frontend", path: "/tmp/test/frontend" },
|
|
109
111
|
],
|
|
110
112
|
source: "issue_body",
|
|
111
113
|
};
|
|
@@ -114,7 +116,7 @@ describe("isMultiRepo", () => {
|
|
|
114
116
|
|
|
115
117
|
it("returns false for 1 repo", () => {
|
|
116
118
|
const resolution: RepoResolution = {
|
|
117
|
-
repos: [{ name: "default", path: "/
|
|
119
|
+
repos: [{ name: "default", path: "/tmp/test/ai-workspace" }],
|
|
118
120
|
source: "config_default",
|
|
119
121
|
};
|
|
120
122
|
expect(isMultiRepo(resolution)).toBe(false);
|
|
@@ -143,21 +145,21 @@ describe("validateRepoPath", () => {
|
|
|
143
145
|
it("returns isGitRepo:true, isSubmodule:false for normal repo (.git is directory)", () => {
|
|
144
146
|
mockExistsSync.mockReturnValue(true);
|
|
145
147
|
mockStatSync.mockReturnValue({ isFile: () => false, isDirectory: () => true });
|
|
146
|
-
const result = validateRepoPath("/
|
|
148
|
+
const result = validateRepoPath("/tmp/test/repos/api");
|
|
147
149
|
expect(result).toEqual({ exists: true, isGitRepo: true, isSubmodule: false });
|
|
148
150
|
});
|
|
149
151
|
|
|
150
152
|
it("returns isGitRepo:true, isSubmodule:true for submodule (.git is file)", () => {
|
|
151
153
|
mockExistsSync.mockReturnValue(true);
|
|
152
154
|
mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false });
|
|
153
|
-
const result = validateRepoPath("/
|
|
155
|
+
const result = validateRepoPath("/tmp/test/workspace/submod");
|
|
154
156
|
expect(result).toEqual({ exists: true, isGitRepo: true, isSubmodule: true });
|
|
155
157
|
});
|
|
156
158
|
|
|
157
159
|
it("returns isGitRepo:false for directory without .git", () => {
|
|
158
160
|
// First call: path exists. Second call: .git does not exist
|
|
159
161
|
mockExistsSync.mockImplementation((p: string) => !String(p).endsWith(".git"));
|
|
160
|
-
const result = validateRepoPath("/
|
|
162
|
+
const result = validateRepoPath("/tmp/test/not-a-repo");
|
|
161
163
|
expect(result).toEqual({ exists: true, isGitRepo: false, isSubmodule: false });
|
|
162
164
|
});
|
|
163
165
|
});
|
package/src/infra/multi-repo.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync, statSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
|
|
13
14
|
export interface RepoConfig {
|
|
@@ -62,7 +63,7 @@ export function resolveRepos(
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
// 3. Config default: single repo
|
|
65
|
-
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "
|
|
66
|
+
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? path.join(homedir(), "ai-workspace");
|
|
66
67
|
return {
|
|
67
68
|
repos: [{ name: "default", path: baseRepo }],
|
|
68
69
|
source: "config_default",
|
|
@@ -76,7 +77,7 @@ function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, stri
|
|
|
76
77
|
|
|
77
78
|
function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {
|
|
78
79
|
// Convention: {parentDir}/{name}
|
|
79
|
-
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "
|
|
80
|
+
const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? path.join(homedir(), "ai-workspace");
|
|
80
81
|
const parentDir = path.dirname(baseRepo);
|
|
81
82
|
return path.join(parentDir, name);
|
|
82
83
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
// Types
|
|
@@ -25,7 +26,7 @@ export interface AgentProfile {
|
|
|
25
26
|
// Cached profile loader (5s TTL)
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
|
|
28
|
-
const PROFILES_PATH = join(
|
|
29
|
+
const PROFILES_PATH = join(homedir(), ".openclaw", "agent-profiles.json");
|
|
29
30
|
|
|
30
31
|
let profilesCache: { data: Record<string, AgentProfile>; loadedAt: number } | null = null;
|
|
31
32
|
const PROFILES_CACHE_TTL_MS = 5_000;
|
|
@@ -62,9 +62,9 @@ describe("renderTemplate", () => {
|
|
|
62
62
|
const result = renderTemplate(template, {
|
|
63
63
|
identifier: "CT-42",
|
|
64
64
|
title: "Implement auth",
|
|
65
|
-
worktreePath: "/
|
|
65
|
+
worktreePath: "/tmp/test/worktrees/ct-42",
|
|
66
66
|
});
|
|
67
|
-
expect(result).toBe("Issue: CT-42\nTitle: Implement auth\nWorktree: /
|
|
67
|
+
expect(result).toBe("Issue: CT-42\nTitle: Implement auth\nWorktree: /tmp/test/worktrees/ct-42");
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
it("handles special regex characters in values", () => {
|
|
@@ -10,6 +10,11 @@ import {
|
|
|
10
10
|
getCurrentSession,
|
|
11
11
|
getSessionCount,
|
|
12
12
|
hydrateFromDispatchState,
|
|
13
|
+
recordIssueAffinity,
|
|
14
|
+
getIssueAffinity,
|
|
15
|
+
_configureAffinityTtl,
|
|
16
|
+
_getAffinityTtlMs,
|
|
17
|
+
_resetAffinityForTesting,
|
|
13
18
|
type ActiveSession,
|
|
14
19
|
} from "./active-session.js";
|
|
15
20
|
|
|
@@ -25,10 +30,11 @@ function makeSession(overrides?: Partial<ActiveSession>): ActiveSession {
|
|
|
25
30
|
|
|
26
31
|
// Clean up after each test to avoid cross-contamination
|
|
27
32
|
afterEach(() => {
|
|
28
|
-
// Clear all known sessions
|
|
33
|
+
// Clear all known sessions (use sessions.delete directly to avoid triggering affinity)
|
|
29
34
|
clearActiveSession("uuid-1");
|
|
30
35
|
clearActiveSession("uuid-2");
|
|
31
36
|
clearActiveSession("uuid-3");
|
|
37
|
+
_resetAffinityForTesting();
|
|
32
38
|
});
|
|
33
39
|
|
|
34
40
|
describe("set + get", () => {
|
|
@@ -152,3 +158,92 @@ describe("hydrateFromDispatchState", () => {
|
|
|
152
158
|
expect(restored).toBe(0);
|
|
153
159
|
});
|
|
154
160
|
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Issue-agent affinity
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
describe("issue agent affinity", () => {
|
|
167
|
+
it("recordIssueAffinity + getIssueAffinity round-trip", () => {
|
|
168
|
+
recordIssueAffinity("uuid-1", "mal");
|
|
169
|
+
expect(getIssueAffinity("uuid-1")).toBe("mal");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns null for unknown issue", () => {
|
|
173
|
+
expect(getIssueAffinity("no-such-id")).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("returns null after TTL expires", () => {
|
|
177
|
+
_configureAffinityTtl(100); // 100ms TTL
|
|
178
|
+
recordIssueAffinity("uuid-1", "mal");
|
|
179
|
+
vi.useFakeTimers();
|
|
180
|
+
vi.advanceTimersByTime(150);
|
|
181
|
+
expect(getIssueAffinity("uuid-1")).toBeNull();
|
|
182
|
+
vi.useRealTimers();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns agent within TTL", () => {
|
|
186
|
+
_configureAffinityTtl(60_000);
|
|
187
|
+
recordIssueAffinity("uuid-1", "kaylee");
|
|
188
|
+
expect(getIssueAffinity("uuid-1")).toBe("kaylee");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("overwrites previous affinity for same issue", () => {
|
|
192
|
+
recordIssueAffinity("uuid-1", "mal");
|
|
193
|
+
recordIssueAffinity("uuid-1", "kaylee");
|
|
194
|
+
expect(getIssueAffinity("uuid-1")).toBe("kaylee");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("tracks separate issues independently", () => {
|
|
198
|
+
recordIssueAffinity("uuid-1", "mal");
|
|
199
|
+
recordIssueAffinity("uuid-2", "kaylee");
|
|
200
|
+
expect(getIssueAffinity("uuid-1")).toBe("mal");
|
|
201
|
+
expect(getIssueAffinity("uuid-2")).toBe("kaylee");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("clearActiveSession records affinity when agentId present", () => {
|
|
205
|
+
setActiveSession({
|
|
206
|
+
agentSessionId: "sess-1",
|
|
207
|
+
issueIdentifier: "API-100",
|
|
208
|
+
issueId: "uuid-1",
|
|
209
|
+
agentId: "mal",
|
|
210
|
+
startedAt: Date.now(),
|
|
211
|
+
});
|
|
212
|
+
clearActiveSession("uuid-1");
|
|
213
|
+
expect(getActiveSession("uuid-1")).toBeNull(); // session cleared
|
|
214
|
+
expect(getIssueAffinity("uuid-1")).toBe("mal"); // affinity preserved
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("clearActiveSession does NOT record affinity when agentId missing", () => {
|
|
218
|
+
setActiveSession({
|
|
219
|
+
agentSessionId: "sess-1",
|
|
220
|
+
issueIdentifier: "API-100",
|
|
221
|
+
issueId: "uuid-1",
|
|
222
|
+
startedAt: Date.now(),
|
|
223
|
+
// no agentId
|
|
224
|
+
});
|
|
225
|
+
clearActiveSession("uuid-1");
|
|
226
|
+
expect(getIssueAffinity("uuid-1")).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("_resetAffinityForTesting clears all entries and resets TTL", () => {
|
|
230
|
+
_configureAffinityTtl(5000);
|
|
231
|
+
recordIssueAffinity("uuid-1", "mal");
|
|
232
|
+
recordIssueAffinity("uuid-2", "kaylee");
|
|
233
|
+
_resetAffinityForTesting();
|
|
234
|
+
expect(getIssueAffinity("uuid-1")).toBeNull();
|
|
235
|
+
expect(getIssueAffinity("uuid-2")).toBeNull();
|
|
236
|
+
expect(_getAffinityTtlMs()).toBe(30 * 60_000);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("_configureAffinityTtl sets custom TTL", () => {
|
|
240
|
+
_configureAffinityTtl(5000);
|
|
241
|
+
expect(_getAffinityTtlMs()).toBe(5000);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("_configureAffinityTtl resets to default when called with undefined", () => {
|
|
245
|
+
_configureAffinityTtl(5000);
|
|
246
|
+
_configureAffinityTtl(undefined);
|
|
247
|
+
expect(_getAffinityTtlMs()).toBe(30 * 60_000);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -26,6 +26,19 @@ export interface ActiveSession {
|
|
|
26
26
|
// Keyed by issue ID — one active session per issue at a time.
|
|
27
27
|
const sessions = new Map<string, ActiveSession>();
|
|
28
28
|
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Issue-agent affinity: tracks which agent last handled each issue.
|
|
31
|
+
// Entries expire after a configurable TTL (default 30 min).
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
interface AffinityEntry {
|
|
35
|
+
agentId: string;
|
|
36
|
+
recordedAt: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const issueAgentAffinity = new Map<string, AffinityEntry>();
|
|
40
|
+
let _affinityTtlMs = 30 * 60_000; // 30 minutes default
|
|
41
|
+
|
|
29
42
|
/**
|
|
30
43
|
* Register the active session for an issue. Idempotent — calling again
|
|
31
44
|
* for the same issue just updates the session.
|
|
@@ -36,8 +49,13 @@ export function setActiveSession(session: ActiveSession): void {
|
|
|
36
49
|
|
|
37
50
|
/**
|
|
38
51
|
* Clear the active session for an issue.
|
|
52
|
+
* If the session had an agentId, records it as affinity for future routing.
|
|
39
53
|
*/
|
|
40
54
|
export function clearActiveSession(issueId: string): void {
|
|
55
|
+
const session = sessions.get(issueId);
|
|
56
|
+
if (session?.agentId) {
|
|
57
|
+
recordIssueAffinity(issueId, session.agentId);
|
|
58
|
+
}
|
|
41
59
|
sessions.delete(issueId);
|
|
42
60
|
}
|
|
43
61
|
|
|
@@ -104,3 +122,45 @@ export async function hydrateFromDispatchState(configPath?: string): Promise<num
|
|
|
104
122
|
export function getSessionCount(): number {
|
|
105
123
|
return sessions.size;
|
|
106
124
|
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Issue-agent affinity — public API
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Record which agent last handled an issue.
|
|
132
|
+
* Called automatically from clearActiveSession when an agentId is present.
|
|
133
|
+
*/
|
|
134
|
+
export function recordIssueAffinity(issueId: string, agentId: string): void {
|
|
135
|
+
issueAgentAffinity.set(issueId, { agentId, recordedAt: Date.now() });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Look up which agent last handled an issue.
|
|
140
|
+
* Returns null if no affinity recorded or if the entry has expired.
|
|
141
|
+
*/
|
|
142
|
+
export function getIssueAffinity(issueId: string): string | null {
|
|
143
|
+
const entry = issueAgentAffinity.get(issueId);
|
|
144
|
+
if (!entry) return null;
|
|
145
|
+
if (Date.now() - entry.recordedAt > _affinityTtlMs) {
|
|
146
|
+
issueAgentAffinity.delete(issueId);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return entry.agentId;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @internal — configure affinity TTL from pluginConfig. */
|
|
153
|
+
export function _configureAffinityTtl(ttlMs?: number): void {
|
|
154
|
+
_affinityTtlMs = ttlMs ?? 30 * 60_000;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @internal — read current affinity TTL (for testing). */
|
|
158
|
+
export function _getAffinityTtlMs(): number {
|
|
159
|
+
return _affinityTtlMs;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @internal — test-only; clears all affinity state and resets TTL. */
|
|
163
|
+
export function _resetAffinityForTesting(): void {
|
|
164
|
+
issueAgentAffinity.clear();
|
|
165
|
+
_affinityTtlMs = 30 * 60_000;
|
|
166
|
+
}
|
|
@@ -293,7 +293,7 @@ export function resolveOrchestratorWorkspace(
|
|
|
293
293
|
api: any,
|
|
294
294
|
pluginConfig?: Record<string, unknown>,
|
|
295
295
|
): string {
|
|
296
|
-
const home =
|
|
296
|
+
const home = homedir();
|
|
297
297
|
const agentId = (pluginConfig?.defaultAgentId as string) ?? "default";
|
|
298
298
|
|
|
299
299
|
try {
|
|
@@ -14,6 +14,9 @@ vi.mock("./dispatch-state.js", () => ({
|
|
|
14
14
|
vi.mock("./active-session.js", () => ({
|
|
15
15
|
setActiveSession: vi.fn(),
|
|
16
16
|
clearActiveSession: vi.fn(),
|
|
17
|
+
getIssueAffinity: vi.fn().mockReturnValue(null),
|
|
18
|
+
_configureAffinityTtl: vi.fn(),
|
|
19
|
+
_resetAffinityForTesting: vi.fn(),
|
|
17
20
|
}));
|
|
18
21
|
vi.mock("../infra/notify.js", () => ({}));
|
|
19
22
|
vi.mock("./artifacts.js", () => ({
|
|
@@ -39,6 +39,9 @@ vi.mock("../api/linear-api.js", () => ({
|
|
|
39
39
|
vi.mock("./active-session.js", () => ({
|
|
40
40
|
setActiveSession: vi.fn(),
|
|
41
41
|
clearActiveSession: vi.fn(),
|
|
42
|
+
getIssueAffinity: vi.fn().mockReturnValue(null),
|
|
43
|
+
_configureAffinityTtl: vi.fn(),
|
|
44
|
+
_resetAffinityForTesting: vi.fn(),
|
|
42
45
|
}));
|
|
43
46
|
|
|
44
47
|
vi.mock("../infra/observability.js", () => ({
|