@calltelemetry/openclaw-linear 0.5.2 → 0.6.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 +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} +5 -5
- package/src/{codex-worktree.ts → infra/codex-worktree.ts} +1 -1
- 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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* watchdog.ts — I/O inactivity watchdog for agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Resets a countdown timer on every tick(). If no tick arrives within
|
|
5
|
+
* the inactivity threshold, fires onKill(). Also provides a config
|
|
6
|
+
* resolver that reads per-agent timeouts from agent-profiles.json.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Defaults (seconds — matches config units)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_INACTIVITY_SEC = 120; // 2 min
|
|
16
|
+
export const DEFAULT_MAX_TOTAL_SEC = 7200; // 2 hrs
|
|
17
|
+
export const DEFAULT_TOOL_TIMEOUT_SEC = 600; // 10 min
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Watchdog
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export type WatchdogKillReason = "inactivity";
|
|
24
|
+
|
|
25
|
+
export interface WatchdogOptions {
|
|
26
|
+
/** Inactivity threshold in ms. */
|
|
27
|
+
inactivityMs: number;
|
|
28
|
+
/** Label for logging (e.g. "embedded:zoe:session-123"). */
|
|
29
|
+
label: string;
|
|
30
|
+
/** Logger interface. */
|
|
31
|
+
logger: { info(msg: string): void; warn(msg: string): void };
|
|
32
|
+
/** Called when inactivity timeout fires. Must kill the process/abort. */
|
|
33
|
+
onKill: (reason: WatchdogKillReason) => void | Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class InactivityWatchdog {
|
|
37
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
38
|
+
private readonly inactivityMs: number;
|
|
39
|
+
private readonly label: string;
|
|
40
|
+
private readonly logger: WatchdogOptions["logger"];
|
|
41
|
+
private readonly onKill: WatchdogOptions["onKill"];
|
|
42
|
+
private lastActivityAt: number = Date.now();
|
|
43
|
+
private killed = false;
|
|
44
|
+
private started = false;
|
|
45
|
+
|
|
46
|
+
constructor(opts: WatchdogOptions) {
|
|
47
|
+
this.inactivityMs = opts.inactivityMs;
|
|
48
|
+
this.label = opts.label;
|
|
49
|
+
this.logger = opts.logger;
|
|
50
|
+
this.onKill = opts.onKill;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Start the watchdog. Call after the process/run is launched. */
|
|
54
|
+
start(): void {
|
|
55
|
+
if (this.started) return;
|
|
56
|
+
this.started = true;
|
|
57
|
+
this.lastActivityAt = Date.now();
|
|
58
|
+
this.scheduleCheck();
|
|
59
|
+
this.logger.info(`Watchdog started: ${this.label} (inactivity=${this.inactivityMs}ms)`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Record an I/O activity tick. Resets the inactivity countdown. */
|
|
63
|
+
tick(): void {
|
|
64
|
+
this.lastActivityAt = Date.now();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Stop the watchdog (normal completion). */
|
|
68
|
+
stop(): void {
|
|
69
|
+
if (this.timer) {
|
|
70
|
+
clearTimeout(this.timer);
|
|
71
|
+
this.timer = null;
|
|
72
|
+
}
|
|
73
|
+
this.started = false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether the watchdog triggered a kill. */
|
|
77
|
+
get wasKilled(): boolean {
|
|
78
|
+
return this.killed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Milliseconds since last activity. */
|
|
82
|
+
get silenceMs(): number {
|
|
83
|
+
return Date.now() - this.lastActivityAt;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private scheduleCheck(): void {
|
|
87
|
+
const remaining = Math.max(1000, this.inactivityMs - (Date.now() - this.lastActivityAt));
|
|
88
|
+
this.timer = setTimeout(() => {
|
|
89
|
+
if (this.killed || !this.started) return;
|
|
90
|
+
|
|
91
|
+
const silence = Date.now() - this.lastActivityAt;
|
|
92
|
+
if (silence >= this.inactivityMs) {
|
|
93
|
+
this.killed = true;
|
|
94
|
+
this.logger.warn(
|
|
95
|
+
`Watchdog KILL: ${this.label} — no I/O for ${Math.round(silence / 1000)}s ` +
|
|
96
|
+
`(threshold: ${this.inactivityMs / 1000}s)`,
|
|
97
|
+
);
|
|
98
|
+
try {
|
|
99
|
+
const result = this.onKill("inactivity");
|
|
100
|
+
if (result && typeof (result as Promise<void>).catch === "function") {
|
|
101
|
+
(result as Promise<void>).catch((err) => {
|
|
102
|
+
this.logger.warn(`Watchdog onKill error: ${err}`);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
this.logger.warn(`Watchdog onKill error: ${err}`);
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// Activity happened during the wait — reschedule for remaining time
|
|
110
|
+
this.scheduleCheck();
|
|
111
|
+
}
|
|
112
|
+
}, remaining);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Config resolution
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export interface WatchdogConfig {
|
|
121
|
+
inactivityMs: number;
|
|
122
|
+
maxTotalMs: number;
|
|
123
|
+
toolTimeoutMs: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface AgentProfileWatchdog {
|
|
127
|
+
inactivitySec?: number;
|
|
128
|
+
maxTotalSec?: number;
|
|
129
|
+
toolTimeoutSec?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const PROFILES_PATH = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
|
|
133
|
+
|
|
134
|
+
function loadProfileWatchdog(agentId: string): AgentProfileWatchdog | null {
|
|
135
|
+
try {
|
|
136
|
+
const raw = readFileSync(PROFILES_PATH, "utf8");
|
|
137
|
+
const profiles = JSON.parse(raw).agents ?? {};
|
|
138
|
+
return profiles[agentId]?.watchdog ?? null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve watchdog config for an agent.
|
|
146
|
+
*
|
|
147
|
+
* Priority: agent-profiles.json → plugin config → hardcoded defaults.
|
|
148
|
+
* All config values are in seconds; output is in ms.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveWatchdogConfig(
|
|
151
|
+
agentId: string,
|
|
152
|
+
pluginConfig?: Record<string, unknown>,
|
|
153
|
+
): WatchdogConfig {
|
|
154
|
+
const profile = loadProfileWatchdog(agentId);
|
|
155
|
+
|
|
156
|
+
const inactivitySec =
|
|
157
|
+
profile?.inactivitySec ??
|
|
158
|
+
(pluginConfig?.inactivitySec as number | undefined) ??
|
|
159
|
+
DEFAULT_INACTIVITY_SEC;
|
|
160
|
+
|
|
161
|
+
const maxTotalSec =
|
|
162
|
+
profile?.maxTotalSec ??
|
|
163
|
+
(pluginConfig?.maxTotalSec as number | undefined) ??
|
|
164
|
+
DEFAULT_MAX_TOTAL_SEC;
|
|
165
|
+
|
|
166
|
+
const toolTimeoutSec =
|
|
167
|
+
profile?.toolTimeoutSec ??
|
|
168
|
+
(pluginConfig?.toolTimeoutSec as number | undefined) ??
|
|
169
|
+
DEFAULT_TOOL_TIMEOUT_SEC;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
inactivityMs: inactivitySec * 1000,
|
|
173
|
+
maxTotalMs: maxTotalSec * 1000,
|
|
174
|
+
toolTimeoutMs: toolTimeoutSec * 1000,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -9,10 +9,10 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
9
9
|
import { readFileSync as readFileSyncFs, existsSync } from "node:fs";
|
|
10
10
|
import { join, dirname } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "
|
|
13
|
-
import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "
|
|
12
|
+
import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
|
|
13
|
+
import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "../api/auth.js";
|
|
14
14
|
import { listWorktrees } from "./codex-worktree.js";
|
|
15
|
-
import { loadPrompts, clearPromptCache } from "
|
|
15
|
+
import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
|
|
16
16
|
|
|
17
17
|
function prompt(question: string): Promise<string> {
|
|
18
18
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -262,7 +262,7 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
262
262
|
? customPath.replace("~", process.env.HOME ?? "")
|
|
263
263
|
: customPath;
|
|
264
264
|
} else {
|
|
265
|
-
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "
|
|
265
|
+
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
266
266
|
resolvedPath = join(pluginRoot, "prompts.yaml");
|
|
267
267
|
}
|
|
268
268
|
|
|
@@ -293,7 +293,7 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
293
293
|
? customPath.replace("~", process.env.HOME ?? "")
|
|
294
294
|
: customPath;
|
|
295
295
|
} else {
|
|
296
|
-
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "
|
|
296
|
+
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
297
297
|
resolvedPath = join(pluginRoot, "prompts.yaml");
|
|
298
298
|
}
|
|
299
299
|
|
|
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { ensureGitignore } from "
|
|
5
|
+
import { ensureGitignore } from "../pipeline/artifacts.js";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
|
|
8
8
|
const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createNoopNotifier,
|
|
4
|
+
createDiscordNotifier,
|
|
5
|
+
type NotifyPayload,
|
|
6
|
+
} from "./notify.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Noop notifier
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
describe("createNoopNotifier", () => {
|
|
13
|
+
it("returns function that resolves without error", async () => {
|
|
14
|
+
const notify = createNoopNotifier();
|
|
15
|
+
await expect(notify("dispatch", {
|
|
16
|
+
identifier: "API-1",
|
|
17
|
+
title: "test",
|
|
18
|
+
status: "dispatched",
|
|
19
|
+
})).resolves.toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Discord notifier
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
describe("createDiscordNotifier", () => {
|
|
28
|
+
const botToken = "test-bot-token";
|
|
29
|
+
const channelId = "123456";
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function stubFetch(): { getCalls: () => { url: string; body: any }[] } {
|
|
36
|
+
const calls: { url: string; body: any }[] = [];
|
|
37
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string, opts: any) => {
|
|
38
|
+
calls.push({ url, body: JSON.parse(opts.body) });
|
|
39
|
+
return { ok: true, status: 200 } as Response;
|
|
40
|
+
}));
|
|
41
|
+
return { getCalls: () => calls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const basePayload: NotifyPayload = {
|
|
45
|
+
identifier: "API-42",
|
|
46
|
+
title: "Fix auth",
|
|
47
|
+
status: "dispatched",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
it("formats dispatch message", async () => {
|
|
51
|
+
const { getCalls } = stubFetch();
|
|
52
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
53
|
+
await notify("dispatch", basePayload);
|
|
54
|
+
expect(getCalls()).toHaveLength(1);
|
|
55
|
+
const msg = getCalls()[0].body.content;
|
|
56
|
+
expect(msg).toContain("**API-42**");
|
|
57
|
+
expect(msg).toContain("dispatched");
|
|
58
|
+
expect(msg).toContain("Fix auth");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("formats working message with attempt", async () => {
|
|
62
|
+
const { getCalls } = stubFetch();
|
|
63
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
64
|
+
await notify("working", { ...basePayload, status: "working", attempt: 1 });
|
|
65
|
+
const msg = getCalls()[0].body.content;
|
|
66
|
+
expect(msg).toContain("worker started");
|
|
67
|
+
expect(msg).toContain("attempt 1");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("formats audit_pass message", async () => {
|
|
71
|
+
const { getCalls } = stubFetch();
|
|
72
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
73
|
+
await notify("audit_pass", { ...basePayload, status: "done", verdict: { pass: true } });
|
|
74
|
+
const msg = getCalls()[0].body.content;
|
|
75
|
+
expect(msg).toContain("passed audit");
|
|
76
|
+
expect(msg).toContain("PR ready");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("formats audit_fail message with gaps", async () => {
|
|
80
|
+
const { getCalls } = stubFetch();
|
|
81
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
82
|
+
await notify("audit_fail", {
|
|
83
|
+
...basePayload,
|
|
84
|
+
status: "working",
|
|
85
|
+
attempt: 1,
|
|
86
|
+
verdict: { pass: false, gaps: ["no tests", "missing validation"] },
|
|
87
|
+
});
|
|
88
|
+
const msg = getCalls()[0].body.content;
|
|
89
|
+
expect(msg).toContain("failed audit");
|
|
90
|
+
expect(msg).toContain("no tests");
|
|
91
|
+
expect(msg).toContain("missing validation");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("formats escalation message", async () => {
|
|
95
|
+
const { getCalls } = stubFetch();
|
|
96
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
97
|
+
await notify("escalation", {
|
|
98
|
+
...basePayload,
|
|
99
|
+
status: "stuck",
|
|
100
|
+
reason: "audit failed 3x",
|
|
101
|
+
});
|
|
102
|
+
const msg = getCalls()[0].body.content;
|
|
103
|
+
expect(msg).toContain("needs human review");
|
|
104
|
+
expect(msg).toContain("audit failed 3x");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("formats watchdog_kill message", async () => {
|
|
108
|
+
const { getCalls } = stubFetch();
|
|
109
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
110
|
+
await notify("watchdog_kill", {
|
|
111
|
+
...basePayload,
|
|
112
|
+
status: "stuck",
|
|
113
|
+
attempt: 0,
|
|
114
|
+
reason: "no I/O for 120s",
|
|
115
|
+
});
|
|
116
|
+
const msg = getCalls()[0].body.content;
|
|
117
|
+
expect(msg).toContain("killed by watchdog");
|
|
118
|
+
expect(msg).toContain("no I/O for 120s");
|
|
119
|
+
expect(msg).toContain("attempt 0");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("handles fetch failure gracefully", async () => {
|
|
123
|
+
vi.stubGlobal("fetch", vi.fn(async () => {
|
|
124
|
+
throw new Error("network error");
|
|
125
|
+
}));
|
|
126
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
127
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
128
|
+
// Should not throw
|
|
129
|
+
await expect(notify("dispatch", basePayload)).resolves.toBeUndefined();
|
|
130
|
+
consoleSpy.mockRestore();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("sends to correct Discord API URL", async () => {
|
|
134
|
+
const { getCalls } = stubFetch();
|
|
135
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
136
|
+
await notify("dispatch", basePayload);
|
|
137
|
+
expect(getCalls()[0].url).toContain(`/channels/${channelId}/messages`);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("formats auditing message", async () => {
|
|
141
|
+
const { getCalls } = stubFetch();
|
|
142
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
143
|
+
await notify("auditing", { ...basePayload, status: "auditing" });
|
|
144
|
+
const msg = getCalls()[0].body.content;
|
|
145
|
+
expect(msg).toContain("audit in progress");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("formats stuck message", async () => {
|
|
149
|
+
const { getCalls } = stubFetch();
|
|
150
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
151
|
+
await notify("stuck", { ...basePayload, status: "stuck", reason: "stale 2h" });
|
|
152
|
+
const msg = getCalls()[0].body.content;
|
|
153
|
+
expect(msg).toContain("stuck");
|
|
154
|
+
expect(msg).toContain("stale 2h");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("handles non-ok response gracefully", async () => {
|
|
158
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({
|
|
159
|
+
ok: false,
|
|
160
|
+
status: 429,
|
|
161
|
+
text: async () => "rate limited",
|
|
162
|
+
})));
|
|
163
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
164
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
165
|
+
await expect(notify("dispatch", basePayload)).resolves.toBeUndefined();
|
|
166
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
167
|
+
consoleSpy.mockRestore();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -17,7 +17,8 @@ export type NotifyKind =
|
|
|
17
17
|
| "audit_pass" // audit passed → done
|
|
18
18
|
| "audit_fail" // audit failed → rework
|
|
19
19
|
| "escalation" // 2x fail or stale → stuck
|
|
20
|
-
| "stuck"
|
|
20
|
+
| "stuck" // stale detection
|
|
21
|
+
| "watchdog_kill"; // agent killed by inactivity watchdog
|
|
21
22
|
|
|
22
23
|
export interface NotifyPayload {
|
|
23
24
|
identifier: string;
|
|
@@ -55,6 +56,10 @@ function formatDiscordMessage(kind: NotifyKind, payload: NotifyPayload): string
|
|
|
55
56
|
return `🚨 ${prefix} needs human review — ${payload.reason ?? "audit failed 2x"}`;
|
|
56
57
|
case "stuck":
|
|
57
58
|
return `⏰ ${prefix} stuck — ${payload.reason ?? "stale 2h"}`;
|
|
59
|
+
case "watchdog_kill":
|
|
60
|
+
return `⚡ ${prefix} killed by watchdog (${payload.reason ?? "no I/O for 120s"}). ${
|
|
61
|
+
payload.attempt != null ? `Retrying (attempt ${payload.attempt}).` : "Will retry."
|
|
62
|
+
}`;
|
|
58
63
|
default:
|
|
59
64
|
return `${prefix} — ${kind}: ${payload.status}`;
|
|
60
65
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
setActiveSession,
|
|
7
|
+
clearActiveSession,
|
|
8
|
+
getActiveSession,
|
|
9
|
+
getActiveSessionByIdentifier,
|
|
10
|
+
getCurrentSession,
|
|
11
|
+
getSessionCount,
|
|
12
|
+
hydrateFromDispatchState,
|
|
13
|
+
type ActiveSession,
|
|
14
|
+
} from "./active-session.js";
|
|
15
|
+
|
|
16
|
+
function makeSession(overrides?: Partial<ActiveSession>): ActiveSession {
|
|
17
|
+
return {
|
|
18
|
+
agentSessionId: "sess-1",
|
|
19
|
+
issueIdentifier: "API-100",
|
|
20
|
+
issueId: "uuid-1",
|
|
21
|
+
startedAt: Date.now(),
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Clean up after each test to avoid cross-contamination
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
// Clear all known sessions
|
|
29
|
+
clearActiveSession("uuid-1");
|
|
30
|
+
clearActiveSession("uuid-2");
|
|
31
|
+
clearActiveSession("uuid-3");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("set + get", () => {
|
|
35
|
+
it("round-trip by issueId", () => {
|
|
36
|
+
const session = makeSession();
|
|
37
|
+
setActiveSession(session);
|
|
38
|
+
const found = getActiveSession("uuid-1");
|
|
39
|
+
expect(found).not.toBeNull();
|
|
40
|
+
expect(found!.issueIdentifier).toBe("API-100");
|
|
41
|
+
expect(found!.agentSessionId).toBe("sess-1");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns null for unknown issueId", () => {
|
|
45
|
+
expect(getActiveSession("no-such-id")).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("clearActiveSession", () => {
|
|
50
|
+
it("removes session", () => {
|
|
51
|
+
setActiveSession(makeSession());
|
|
52
|
+
clearActiveSession("uuid-1");
|
|
53
|
+
expect(getActiveSession("uuid-1")).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("getActiveSessionByIdentifier", () => {
|
|
58
|
+
it("finds by identifier string", () => {
|
|
59
|
+
setActiveSession(makeSession({ issueIdentifier: "API-200", issueId: "uuid-2" }));
|
|
60
|
+
const found = getActiveSessionByIdentifier("API-200");
|
|
61
|
+
expect(found).not.toBeNull();
|
|
62
|
+
expect(found!.issueId).toBe("uuid-2");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns null for unknown identifier", () => {
|
|
66
|
+
expect(getActiveSessionByIdentifier("NOPE-999")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("getCurrentSession", () => {
|
|
71
|
+
it("returns session when exactly 1", () => {
|
|
72
|
+
setActiveSession(makeSession());
|
|
73
|
+
const current = getCurrentSession();
|
|
74
|
+
expect(current).not.toBeNull();
|
|
75
|
+
expect(current!.issueId).toBe("uuid-1");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns null when 0 sessions", () => {
|
|
79
|
+
expect(getCurrentSession()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns null when >1 sessions", () => {
|
|
83
|
+
setActiveSession(makeSession({ issueId: "uuid-1" }));
|
|
84
|
+
setActiveSession(makeSession({ issueId: "uuid-2", issueIdentifier: "API-200" }));
|
|
85
|
+
expect(getCurrentSession()).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("getSessionCount", () => {
|
|
90
|
+
it("reflects current count", () => {
|
|
91
|
+
expect(getSessionCount()).toBe(0);
|
|
92
|
+
setActiveSession(makeSession());
|
|
93
|
+
expect(getSessionCount()).toBe(1);
|
|
94
|
+
setActiveSession(makeSession({ issueId: "uuid-2", issueIdentifier: "API-200" }));
|
|
95
|
+
expect(getSessionCount()).toBe(2);
|
|
96
|
+
clearActiveSession("uuid-1");
|
|
97
|
+
expect(getSessionCount()).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("hydrateFromDispatchState", () => {
|
|
102
|
+
it("restores working dispatches from state file", async () => {
|
|
103
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-hydrate-"));
|
|
104
|
+
const statePath = join(dir, "state.json");
|
|
105
|
+
writeFileSync(statePath, JSON.stringify({
|
|
106
|
+
dispatches: {
|
|
107
|
+
active: {
|
|
108
|
+
"API-300": {
|
|
109
|
+
issueId: "uuid-300",
|
|
110
|
+
issueIdentifier: "API-300",
|
|
111
|
+
worktreePath: "/tmp/wt/API-300",
|
|
112
|
+
branch: "codex/API-300",
|
|
113
|
+
tier: "junior",
|
|
114
|
+
model: "test",
|
|
115
|
+
status: "working",
|
|
116
|
+
dispatchedAt: "2026-01-01T00:00:00Z",
|
|
117
|
+
attempt: 0,
|
|
118
|
+
},
|
|
119
|
+
"API-301": {
|
|
120
|
+
issueId: "uuid-301",
|
|
121
|
+
issueIdentifier: "API-301",
|
|
122
|
+
worktreePath: "/tmp/wt/API-301",
|
|
123
|
+
branch: "codex/API-301",
|
|
124
|
+
tier: "junior",
|
|
125
|
+
model: "test",
|
|
126
|
+
status: "done",
|
|
127
|
+
dispatchedAt: "2026-01-01T00:00:00Z",
|
|
128
|
+
attempt: 1,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
completed: {},
|
|
132
|
+
},
|
|
133
|
+
sessionMap: {},
|
|
134
|
+
processedEvents: [],
|
|
135
|
+
}), "utf-8");
|
|
136
|
+
|
|
137
|
+
const restored = await hydrateFromDispatchState(statePath);
|
|
138
|
+
// Only "working" and "dispatched" are restored, not "done"
|
|
139
|
+
expect(restored).toBe(1);
|
|
140
|
+
expect(getActiveSession("uuid-300")).not.toBeNull();
|
|
141
|
+
expect(getActiveSession("uuid-300")!.issueIdentifier).toBe("API-300");
|
|
142
|
+
expect(getActiveSession("uuid-301")).toBeNull();
|
|
143
|
+
|
|
144
|
+
// Cleanup
|
|
145
|
+
clearActiveSession("uuid-300");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns 0 when no active dispatches", async () => {
|
|
149
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-hydrate-"));
|
|
150
|
+
const statePath = join(dir, "state.json");
|
|
151
|
+
const restored = await hydrateFromDispatchState(statePath);
|
|
152
|
+
expect(restored).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
});
|