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