feat: 实现 Queue Q1 API 和 CLI 骨架

This commit is contained in:
Codex
2026-06-01 22:20:09 +08:00
parent f4a26e5961
commit 237b10c4da
11 changed files with 566 additions and 14 deletions
+117 -1
View File
@@ -1,4 +1,4 @@
import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInput, CreateRunInput, FailureKind, JsonRecord, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, SessionRecord, SessionRef, TerminalStatus } from "../common/types.js";
import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, FailureKind, JsonRecord, QueueCommanderSnapshot, QueueReadCursorRecord, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, SessionRecord, SessionRef, TerminalStatus } from "../common/types.js";
import { AgentRunError } from "../common/errors.js";
import { newId, nowIso, stableHash } from "../common/validation.js";
import { redactJson } from "../common/redaction.js";
@@ -39,10 +39,25 @@ export interface AgentRunStore {
cancelRun(runId: string, reason?: string): MaybePromise<RunRecord>;
cancelCommand(commandId: string, reason?: string): MaybePromise<CommandRecord>;
getSession(sessionId: string): MaybePromise<SessionRecord | null>;
createQueueTask(input: CreateQueueTaskInput): MaybePromise<QueueTaskRecord>;
listQueueTasks(input: ListQueueTasksInput): MaybePromise<QueueTaskListResult>;
getQueueTask(taskId: string): MaybePromise<QueueTaskRecord>;
cancelQueueTask(taskId: string, reason?: string): MaybePromise<QueueTaskRecord>;
markQueueTaskRead(taskId: string, readerId: string): MaybePromise<QueueReadCursorRecord>;
queueStats(queue?: string): MaybePromise<QueueStats>;
queueCommander(queue?: string): MaybePromise<QueueCommanderSnapshot>;
backends(): MaybePromise<JsonRecord[]>;
close?(): MaybePromise<void>;
}
export interface ListQueueTasksInput {
queue?: string;
state?: QueueTaskState;
cursor?: string;
limit: number;
updatedAfter?: number;
}
export interface SaveRunnerJobInput {
runId: string;
commandId: string;
@@ -77,6 +92,9 @@ export class MemoryAgentRunStore implements AgentRunStore {
private readonly runners = new Map<string, RunnerRecord>();
private readonly sessions = new Map<string, SessionRecord>();
private readonly runnerJobs = new Map<string, RunnerJobRecord>();
private readonly queueTasks = new Map<string, QueueTaskRecord>();
private readonly queueReadCursors = new Map<string, QueueReadCursorRecord>();
private queueVersion = 0;
health(): StoreHealth {
return { adapter: "memory-self-test", ready: true, reachable: true, migrationReady: true, migrationId: "memory-self-test", failureKind: null, message: null, credentialValuesPrinted: false };
@@ -247,6 +265,64 @@ export class MemoryAgentRunStore implements AgentRunStore {
return this.sessions.get(sessionId) ?? null;
}
createQueueTask(input: CreateQueueTaskInput): QueueTaskRecord {
const payloadHash = stableHash(input.payload);
if (input.idempotencyKey) {
const existing = Array.from(this.queueTasks.values()).find((task) => task.tenantId === input.tenantId && task.projectId === input.projectId && task.idempotencyKey === input.idempotencyKey);
if (existing) {
if (existing.payloadHash !== payloadHash) throw new AgentRunError("schema-invalid", "queue task idempotency key reused with different payload", { httpStatus: 409 });
return existing;
}
}
const at = nowIso();
const task: QueueTaskRecord = { ...input, id: newId("qt"), state: "pending", version: this.nextQueueVersion(), payloadHash, latestAttempt: null, sessionPath: null, createdAt: at, updatedAt: at, cancelledAt: null, cancelReason: null };
this.queueTasks.set(task.id, task);
return task;
}
listQueueTasks(input: ListQueueTasksInput): QueueTaskListResult {
const startVersion = parseQueueCursor(input.cursor) ?? input.updatedAfter ?? 0;
const items = Array.from(this.queueTasks.values())
.filter((task) => task.version > startVersion)
.filter((task) => !input.queue || task.queue === input.queue)
.filter((task) => !input.state || task.state === input.state)
.sort(queueTaskSort)
.slice(0, clampQueueLimit(input.limit));
return { items, count: items.length, cursor: items.length > 0 ? String(items[items.length - 1]?.version ?? startVersion) : null };
}
getQueueTask(taskId: string): QueueTaskRecord {
const task = this.queueTasks.get(taskId);
if (!task) throw new AgentRunError("schema-invalid", `queue task ${taskId} was not found`, { httpStatus: 404 });
return task;
}
cancelQueueTask(taskId: string, reason = "cancel requested"): QueueTaskRecord {
const task = this.getQueueTask(taskId);
if (isTerminalQueueTaskState(task.state)) return task;
const at = nowIso();
const next: QueueTaskRecord = { ...task, state: "cancelled", version: this.nextQueueVersion(), updatedAt: at, cancelledAt: at, cancelReason: reason };
this.queueTasks.set(taskId, next);
return next;
}
markQueueTaskRead(taskId: string, readerId: string): QueueReadCursorRecord {
const task = this.getQueueTask(taskId);
const record: QueueReadCursorRecord = { taskId, readerId, taskVersion: task.version, readAt: nowIso() };
this.queueReadCursors.set(queueReadKey(taskId, readerId), record);
return record;
}
queueStats(queue?: string): QueueStats {
return buildQueueStats(Array.from(this.queueTasks.values()).filter((task) => !queue || task.queue === queue), queue ?? null);
}
queueCommander(queue?: string): QueueCommanderSnapshot {
const items = Array.from(this.queueTasks.values()).filter((task) => !queue || task.queue === queue).sort(queueTaskSort).slice(0, 20);
const generatedAt = nowIso();
return { queue: queue ?? null, stats: buildQueueStats(Array.from(this.queueTasks.values()).filter((task) => !queue || task.queue === queue), queue ?? null, generatedAt), items, generatedAt };
}
backends(): JsonRecord[] {
return backendCapabilities();
}
@@ -258,6 +334,11 @@ export class MemoryAgentRunStore implements AgentRunStore {
return next;
}
private nextQueueVersion(): number {
this.queueVersion += 1;
return this.queueVersion;
}
private resolveSessionForRun(input: CreateRunInput, at: string): SessionRef | null {
if (!input.sessionRef) return null;
const existing = this.sessions.get(input.sessionRef.sessionId);
@@ -334,6 +415,10 @@ export function isTerminalCommandState(state: CommandRecord["state"]): boolean {
return state === "completed" || state === "failed" || state === "cancelled";
}
export function isTerminalQueueTaskState(state: QueueTaskState): boolean {
return state === "completed" || state === "failed" || state === "blocked" || state === "cancelled";
}
export function sessionRefFromRecord(record: SessionRecord, fallback: SessionRef): SessionRef {
return {
sessionId: record.sessionId,
@@ -369,3 +454,34 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null,
};
}
export function queueTaskSort(a: QueueTaskRecord, b: QueueTaskRecord): number {
if (a.priority !== b.priority) return b.priority - a.priority;
return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
}
export function buildQueueStats(tasks: QueueTaskRecord[], queue: string | null, generatedAt = nowIso()): QueueStats {
const byState: Record<string, number> = {};
const byLane: Record<string, number> = {};
let maxVersion = 0;
for (const task of tasks) {
byState[task.state] = (byState[task.state] ?? 0) + 1;
byLane[task.lane] = (byLane[task.lane] ?? 0) + 1;
maxVersion = Math.max(maxVersion, task.version);
}
return { queue, total: tasks.length, byState, byLane, maxVersion, generatedAt };
}
export function clampQueueLimit(limit: number): number {
return Math.max(1, Math.min(Number.isFinite(limit) ? Math.trunc(limit) : 50, 100));
}
export function parseQueueCursor(cursor: string | undefined): number | null {
if (!cursor) return null;
const value = Number(cursor);
return Number.isInteger(value) && value >= 0 ? value : null;
}
function queueReadKey(taskId: string, readerId: string): string {
return `${taskId}:${readerId}`;
}