mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(gateway): keep task suggestions recoverable (#120940)
Abandon unexpected acceptance failures instead of leaking accepting slots. Reject non-Git or blank suggestions before side effects, and protect unseen pending suggestions ahead of accepted replay state.
This commit is contained in:
committed by
GitHub
parent
5f14d4631a
commit
5297429b3e
@@ -4,9 +4,9 @@ import { Type } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
|
||||
const TaskIdSchema = Type.String({ minLength: 1, maxLength: 128 });
|
||||
const TaskTitleSchema = Type.String({ minLength: 1, maxLength: 60 });
|
||||
const TaskPromptSchema = Type.String({ minLength: 1, maxLength: 32_768 });
|
||||
const TaskTldrSchema = Type.String({ minLength: 1, maxLength: 1_024 });
|
||||
const TaskTitleSchema = Type.String({ minLength: 1, maxLength: 60, pattern: "\\S" });
|
||||
const TaskPromptSchema = Type.String({ minLength: 1, maxLength: 32_768, pattern: "\\S" });
|
||||
const TaskTldrSchema = Type.String({ minLength: 1, maxLength: 1_024, pattern: "\\S" });
|
||||
const TaskCwdSchema = Type.String({ minLength: 1, maxLength: 4_096 });
|
||||
const TaskSessionKeySchema = Type.String({ minLength: 1, maxLength: 512 });
|
||||
const TaskAgentIdSchema = Type.String({ minLength: 1, maxLength: 128 });
|
||||
|
||||
@@ -43,6 +43,14 @@ describe("task suggestion tools", () => {
|
||||
expect(result?.content).toEqual([
|
||||
{ type: "text", text: JSON.stringify({ task_id: "task_123" }, null, 2) },
|
||||
]);
|
||||
expect(spawnTask?.description).toContain("absolute path inside a git checkout");
|
||||
expect(spawnTask?.parameters).toMatchObject({
|
||||
properties: {
|
||||
cwd: {
|
||||
description: "Absolute path inside a git checkout; defaults to the current project.",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(spawnTask?.outputSchema).toBeDefined();
|
||||
expect(Value.Check(spawnTask!.outputSchema!, result?.details)).toBe(true);
|
||||
expect(compactToolOutputHint(spawnTask?.outputSchema)).toBe("{ task_id: string }");
|
||||
|
||||
@@ -33,7 +33,7 @@ const SpawnTaskToolSchema = Type.Object(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 4_096,
|
||||
description: "Absolute project directory; defaults to the current project.",
|
||||
description: "Absolute path inside a git checkout; defaults to the current project.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -75,7 +75,7 @@ export function createTaskSuggestionTools(params: {
|
||||
displaySummary: SPAWN_TASK_TOOL_DISPLAY_SUMMARY,
|
||||
description: [
|
||||
"Suggest confirmed valuable out-of-scope follow-up: dead code, stale docs, missing coverage, verified TODO, security issue.",
|
||||
"Operator suggestion only; does not start work.",
|
||||
"Operator suggestion only; does not start work. cwd must be an absolute path inside a git checkout.",
|
||||
].join(" "),
|
||||
parameters: SpawnTaskToolSchema,
|
||||
outputSchema: SpawnTaskOutputSchema,
|
||||
|
||||
@@ -16,9 +16,10 @@ type Method =
|
||||
| "taskSuggestions.accept"
|
||||
| "taskSuggestions.dismiss";
|
||||
|
||||
async function call(method: Method, params: Record<string, unknown>) {
|
||||
const GIT_CWD = process.cwd();
|
||||
|
||||
async function call(method: Method, params: Record<string, unknown>, broadcast = vi.fn()) {
|
||||
const calls: Parameters<RespondFn>[] = [];
|
||||
const broadcast = vi.fn();
|
||||
const respond: RespondFn = (...args) => {
|
||||
calls.push(args);
|
||||
};
|
||||
@@ -55,10 +56,10 @@ afterEach(async () => {
|
||||
describe("task suggestion gateway methods", () => {
|
||||
it("creates, lists, and resolves an ephemeral suggestion", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete src/example.ts and update its tests.",
|
||||
tldr: "The adapter is unreachable and adds maintenance cost.",
|
||||
cwd: "/repo",
|
||||
title: " Remove stale adapter ",
|
||||
prompt: " Delete src/example.ts and update its tests. ",
|
||||
tldr: " The adapter is unreachable and adds maintenance cost. ",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const payload = requirePayload(created) as { taskId: string };
|
||||
@@ -67,7 +68,12 @@ describe("task suggestion gateway methods", () => {
|
||||
"task.suggestion",
|
||||
expect.objectContaining({
|
||||
action: "created",
|
||||
suggestion: expect.objectContaining({ agentId: "main" }),
|
||||
suggestion: expect.objectContaining({
|
||||
agentId: "main",
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete src/example.ts and update its tests.",
|
||||
tldr: "The adapter is unreachable and adds maintenance cost.",
|
||||
}),
|
||||
}),
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
@@ -77,7 +83,15 @@ describe("task suggestion gateway methods", () => {
|
||||
agentId: "main",
|
||||
});
|
||||
expect(listed.response?.[1]).toMatchObject({
|
||||
suggestions: [{ id: payload.taskId, cwd: "/repo" }],
|
||||
suggestions: [
|
||||
{
|
||||
id: payload.taskId,
|
||||
cwd: GIT_CWD,
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete src/example.ts and update its tests.",
|
||||
tldr: "The adapter is unreachable and adds maintenance cost.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const resolved = await call("taskSuggestions.dismiss", {
|
||||
@@ -94,12 +108,12 @@ describe("task suggestion gateway methods", () => {
|
||||
expect(empty.response?.[1]).toEqual({ suggestions: [] });
|
||||
});
|
||||
|
||||
it("preserves accepted-session replay when successful admission expires a pending suggestion", async () => {
|
||||
it("evicts accepted-session replay before an unseen pending suggestion", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete src/example.ts and update its tests.",
|
||||
tldr: "The adapter is unreachable and adds maintenance cost.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
@@ -114,7 +128,7 @@ describe("task suggestion gateway methods", () => {
|
||||
label: "Remove stale adapter",
|
||||
task: "Delete src/example.ts and update its tests.",
|
||||
worktree: true,
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
});
|
||||
sessionKey = (params as { key: string }).key;
|
||||
expect(sessionKey).toMatch(/^agent:main:dashboard:/);
|
||||
@@ -122,13 +136,13 @@ describe("task suggestion gateway methods", () => {
|
||||
});
|
||||
|
||||
const first = await call("taskSuggestions.accept", { taskId });
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
for (let index = 0; index < 99; index += 1) {
|
||||
requirePayload(
|
||||
await call("taskSuggestions.create", {
|
||||
title: `Pending follow up ${index}`,
|
||||
prompt: `Complete pending follow-up task ${index}.`,
|
||||
tldr: "The operator has not accepted this follow-up.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
@@ -141,23 +155,28 @@ describe("task suggestion gateway methods", () => {
|
||||
expect(oldestPending).toBeDefined();
|
||||
const admitted = await call("taskSuggestions.create", {
|
||||
title: "Latest follow up",
|
||||
prompt: "Preserve the accepted task while admitting new work.",
|
||||
tldr: "Evict only the oldest pending follow-up.",
|
||||
cwd: "/repo",
|
||||
prompt: "Prefer unseen pending work over accepted replay state.",
|
||||
tldr: "Evict only the completed task replay.",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
expect(admitted.response?.[0]).toBe(true);
|
||||
expect(admitted.broadcast).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect(admitted.broadcast).toHaveBeenCalledTimes(1);
|
||||
expect(admitted.broadcast).toHaveBeenCalledWith(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId: oldestPending?.id, resolution: "expired" },
|
||||
expect.objectContaining({ action: "created" }),
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
|
||||
const retry = await call("taskSuggestions.accept", { taskId });
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(first.response?.[1]).toEqual({ taskId, key: sessionKey });
|
||||
expect(retry.response?.[1]).toEqual({ taskId, key: sessionKey });
|
||||
expect(retry.response?.[0]).toBe(false);
|
||||
expect(retry.response?.[2]).toMatchObject({ code: "INVALID_REQUEST" });
|
||||
expect(
|
||||
(requirePayload(listed) as { suggestions: Array<{ id: string }> }).suggestions,
|
||||
).toContainEqual(expect.objectContaining({ id: oldestPending?.id }));
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(first.broadcast).toHaveBeenCalledWith(
|
||||
"task.suggestion",
|
||||
@@ -179,7 +198,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: `Accepted follow up ${index}`,
|
||||
prompt: `Complete accepted follow-up task ${index}.`,
|
||||
tldr: "This follow-up already created its managed task session.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
@@ -192,7 +211,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "Latest follow up",
|
||||
prompt: "Keep accepting new suggestions after earlier tasks completed.",
|
||||
tldr: "Accepted-session replay is bounded best-effort state.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
@@ -214,7 +233,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
@@ -244,7 +263,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
@@ -298,7 +317,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
@@ -329,7 +348,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
@@ -356,6 +375,47 @@ describe("task suggestion gateway methods", () => {
|
||||
expect(listed.response?.[1]).toEqual({ suggestions: [] });
|
||||
});
|
||||
|
||||
it("abandons an acceptance when rollback inspection throws", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
vi.spyOn(sessionCreateHandlers, "sessions.create").mockRejectedValue(
|
||||
new Error("initial dispatch failed"),
|
||||
);
|
||||
vi.spyOn(sessionDeleteHandlers, "sessions.delete").mockImplementation(async ({ respond }) => {
|
||||
respond(true, { ok: true, deleted: true }, undefined);
|
||||
});
|
||||
vi.spyOn(managedWorktrees, "findLiveByOwner").mockImplementation(() => {
|
||||
throw new Error("worktree registry unavailable");
|
||||
});
|
||||
const broadcast = vi.fn();
|
||||
|
||||
await expect(call("taskSuggestions.accept", { taskId }, broadcast)).rejects.toThrow(
|
||||
"worktree registry unavailable",
|
||||
);
|
||||
|
||||
expect(broadcast).toHaveBeenCalledTimes(1);
|
||||
expect(broadcast).toHaveBeenCalledWith(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId, resolution: "expired" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
expect(listed.response?.[1]).toEqual({ suggestions: [] });
|
||||
const retry = await call("taskSuggestions.accept", { taskId });
|
||||
expect(retry.response?.[0]).toBe(false);
|
||||
expect(retry.response?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "task suggestion cannot be accepted: dismissed",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a relative cwd before recording or broadcasting", async () => {
|
||||
const result = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
@@ -370,12 +430,50 @@ describe("task suggestion gateway methods", () => {
|
||||
expect(result.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a cwd outside a git checkout before recording or broadcasting", async () => {
|
||||
const result = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/not-a-git-checkout",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
expect(result.response?.[0]).toBe(false);
|
||||
expect(result.response?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "task suggestion cwd must be inside a git checkout",
|
||||
});
|
||||
expect(result.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["title", "prompt", "tldr"] as const)(
|
||||
"rejects whitespace-only %s before recording or broadcasting",
|
||||
async (field) => {
|
||||
const params = {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
};
|
||||
params[field] = " \n\t ";
|
||||
|
||||
const result = await call("taskSuggestions.create", params);
|
||||
|
||||
expect(result.response?.[0]).toBe(false);
|
||||
expect(result.response?.[2]).toMatchObject({ code: "INVALID_REQUEST" });
|
||||
expect(result.response?.[2]?.message).toContain(field);
|
||||
expect(result.broadcast).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an agent that conflicts with the source session", async () => {
|
||||
const result = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
prompt: "Add the missing regression test.",
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "work",
|
||||
});
|
||||
@@ -393,7 +491,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "Add coverage",
|
||||
prompt: "x".repeat(32_769),
|
||||
tldr: "The edge case is untested.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
@@ -409,7 +507,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: `Follow up ${index}`,
|
||||
prompt: `${index}: ${"x".repeat(32_760)}`,
|
||||
tldr: "The follow-up remains useful.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
taskIds.push((requirePayload(created) as { taskId: string }).taskId);
|
||||
@@ -430,7 +528,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: `Follow up ${index}`,
|
||||
prompt: `Complete follow-up task ${index}.`,
|
||||
tldr: `Follow-up task ${index} remains useful.`,
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
requirePayload(created);
|
||||
@@ -445,7 +543,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "Latest follow up",
|
||||
prompt: "Complete the latest follow-up task.",
|
||||
tldr: "The latest follow-up remains useful.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
@@ -472,7 +570,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: `Running follow up ${index}`,
|
||||
prompt: largePrompt,
|
||||
tldr: "This accepted task is still starting.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
@@ -484,7 +582,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "Preserve accepted task",
|
||||
prompt: "Keep its accepted result available for retries.",
|
||||
tldr: "A rejected admission must not discard completed results.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const acceptedTaskId = (requirePayload(accepted) as { taskId: string }).taskId;
|
||||
@@ -495,7 +593,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "Keep this follow up",
|
||||
prompt: "Do not discard this pending task.",
|
||||
tldr: "The operator has not accepted it yet.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const pendingTaskId = (requirePayload(pending) as { taskId: string }).taskId;
|
||||
@@ -503,7 +601,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "One oversized follow up",
|
||||
prompt: largePrompt,
|
||||
tldr: "This valid task cannot fit beside protected tasks.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
@@ -536,7 +634,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: `Follow up ${index}`,
|
||||
prompt: `Complete follow-up task ${index}.`,
|
||||
tldr: `Follow-up task ${index} remains useful.`,
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
const taskId = (requirePayload(created) as { taskId: string }).taskId;
|
||||
@@ -548,7 +646,7 @@ describe("task suggestion gateway methods", () => {
|
||||
title: "One too many",
|
||||
prompt: "Complete one more follow-up task.",
|
||||
tldr: "This follow-up can wait until capacity returns.",
|
||||
cwd: "/repo",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
validateTaskSuggestionsListParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { insideGitCheckout } from "../../agents/worktrees/git.js";
|
||||
import { managedWorktrees } from "../../agents/worktrees/service.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
@@ -43,6 +44,19 @@ type TaskSuggestionAcceptanceResult =
|
||||
|
||||
const activeAcceptances = new Map<string, Promise<TaskSuggestionAcceptanceResult>>();
|
||||
|
||||
function abandonSuggestedTaskAcceptance(
|
||||
taskId: string,
|
||||
options: GatewayRequestHandlerOptions,
|
||||
): void {
|
||||
if (abandonTaskSuggestionAcceptance(taskId)) {
|
||||
options.context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId, resolution: "expired" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackSuggestedTaskSession(params: {
|
||||
key: string;
|
||||
agentId?: string;
|
||||
@@ -119,13 +133,7 @@ async function failSuggestedTaskSession(params: {
|
||||
}
|
||||
return { ok: false, error: params.error };
|
||||
}
|
||||
if (abandonTaskSuggestionAcceptance(params.taskId)) {
|
||||
params.options.context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId: params.taskId, resolution: "expired" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
}
|
||||
abandonSuggestedTaskAcceptance(params.taskId, params.options);
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
@@ -258,6 +266,14 @@ export const taskSuggestionsHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!insideGitCheckout(params.cwd)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "task suggestion cwd must be inside a git checkout"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sessionAgentId = parseAgentSessionKey(params.sessionKey)?.agentId;
|
||||
const requestedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
if (
|
||||
@@ -342,6 +358,9 @@ export const taskSuggestionsHandlers: GatewayRequestHandlers = {
|
||||
taskId: params.taskId,
|
||||
suggestion: acceptance.suggestion,
|
||||
options,
|
||||
}).catch((error: unknown) => {
|
||||
abandonSuggestedTaskAcceptance(params.taskId, options);
|
||||
throw error;
|
||||
});
|
||||
activeAcceptances.set(params.taskId, pending);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const BASE_SUGGESTION = {
|
||||
title: "Follow up",
|
||||
prompt: "Complete the follow-up task.",
|
||||
tldr: "The follow-up remains useful.",
|
||||
cwd: process.cwd(),
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
};
|
||||
|
||||
describe("task suggestion registry", () => {
|
||||
it("evicts accepted replay before pending work", async () => {
|
||||
const {
|
||||
beginTaskSuggestionAcceptance,
|
||||
completeTaskSuggestionAcceptance,
|
||||
createTaskSuggestion,
|
||||
listTaskSuggestions,
|
||||
} = await importFreshModule<typeof import("./task-suggestion-registry.js")>(
|
||||
import.meta.url,
|
||||
"./task-suggestion-registry.js?scope=eviction-priority",
|
||||
);
|
||||
const accepted = createTaskSuggestion(BASE_SUGGESTION);
|
||||
expect(accepted.status).toBe("created");
|
||||
if (accepted.status !== "created") {
|
||||
throw new Error("expected accepted suggestion admission");
|
||||
}
|
||||
expect(beginTaskSuggestionAcceptance(accepted.suggestion.id).status).toBe("claimed");
|
||||
completeTaskSuggestionAcceptance(accepted.suggestion.id, "agent:main:dashboard:accepted");
|
||||
|
||||
let oldestPendingTaskId = "";
|
||||
for (let index = 0; index < 99; index += 1) {
|
||||
const pending = createTaskSuggestion({
|
||||
...BASE_SUGGESTION,
|
||||
title: `Pending follow up ${index}`,
|
||||
});
|
||||
expect(pending.status).toBe("created");
|
||||
if (pending.status === "created" && index === 0) {
|
||||
oldestPendingTaskId = pending.suggestion.id;
|
||||
}
|
||||
}
|
||||
|
||||
const replacement = createTaskSuggestion({
|
||||
...BASE_SUGGESTION,
|
||||
title: "Latest follow up",
|
||||
});
|
||||
|
||||
expect(replacement).toMatchObject({ status: "created", evictedPendingTaskIds: [] });
|
||||
expect(beginTaskSuggestionAcceptance(accepted.suggestion.id)).toEqual({ status: "missing" });
|
||||
expect(listTaskSuggestions({}).map((suggestion) => suggestion.id)).toContain(
|
||||
oldestPendingTaskId,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -31,9 +31,9 @@ function planTaskSuggestionEvictions(
|
||||
let projectedCount = suggestions.size + 1;
|
||||
let projectedBytes = retainedSuggestionBytes + suggestionBytes + 1;
|
||||
const planned: Array<[string, TaskSuggestionRecord]> = [];
|
||||
// Accepted replay is best effort: protect it behind pending work, but never
|
||||
// let completed entries permanently prevent new suggestions from starting.
|
||||
for (const status of ["dismissed", "pending", "accepted"] as const) {
|
||||
// Accepted replay is best-effort and evicted before unseen pending work;
|
||||
// completed entries must not displace suggestions awaiting operator action.
|
||||
for (const status of ["dismissed", "accepted", "pending"] as const) {
|
||||
for (const [taskId, record] of suggestions) {
|
||||
if (
|
||||
projectedCount <= MAX_TASK_SUGGESTIONS &&
|
||||
@@ -61,9 +61,9 @@ export function createTaskSuggestion(
|
||||
): CreateTaskSuggestionResult {
|
||||
const suggestion: TaskSuggestion = {
|
||||
id: `task_${randomUUID()}`,
|
||||
title: params.title,
|
||||
prompt: params.prompt,
|
||||
tldr: params.tldr,
|
||||
title: params.title.trim(),
|
||||
prompt: params.prompt.trim(),
|
||||
tldr: params.tldr.trim(),
|
||||
cwd: params.cwd,
|
||||
sessionKey: params.sessionKey,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
|
||||
Reference in New Issue
Block a user