feat: add session subagent cli control
This commit is contained in:
+177
-1
@@ -1,4 +1,4 @@
|
||||
import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, FailureKind, JsonRecord, QueueAttemptRef, QueueCommanderSnapshot, QueueReadCursorRecord, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, SessionRecord, SessionRef, TerminalStatus } from "../common/types.js";
|
||||
import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, FailureKind, JsonRecord, QueueAttemptRef, QueueCommanderSnapshot, QueueReadCursorRecord, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, SessionEventPage, SessionListResult, SessionListState, SessionReadCursorRecord, SessionRecord, SessionRef, SessionSummary, 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,6 +39,11 @@ export interface AgentRunStore {
|
||||
cancelRun(runId: string, reason?: string): MaybePromise<RunRecord>;
|
||||
cancelCommand(commandId: string, reason?: string): MaybePromise<CommandRecord>;
|
||||
getSession(sessionId: string): MaybePromise<SessionRecord | null>;
|
||||
getSessionSummary(sessionId: string, readerId?: string | null): MaybePromise<SessionSummary>;
|
||||
listSessions(input: ListSessionsInput): MaybePromise<SessionListResult>;
|
||||
listSessionTrace(sessionId: string, input: SessionEventPageInput): MaybePromise<SessionEventPage>;
|
||||
listSessionOutput(sessionId: string, input: SessionEventPageInput): MaybePromise<SessionEventPage>;
|
||||
markSessionRead(sessionId: string, readerId: string): MaybePromise<SessionReadCursorRecord>;
|
||||
createQueueTask(input: CreateQueueTaskInput): MaybePromise<QueueTaskRecord>;
|
||||
listQueueTasks(input: ListQueueTasksInput): MaybePromise<QueueTaskListResult>;
|
||||
getQueueTask(taskId: string): MaybePromise<QueueTaskRecord>;
|
||||
@@ -59,6 +64,20 @@ export interface ListQueueTasksInput {
|
||||
updatedAfter?: number;
|
||||
}
|
||||
|
||||
export interface ListSessionsInput {
|
||||
state?: SessionListState;
|
||||
backendProfile?: BackendProfile;
|
||||
readerId?: string | null;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface SessionEventPageInput {
|
||||
runId?: string | null;
|
||||
afterSeq: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UpdateQueueTaskAttemptInput {
|
||||
state: QueueTaskState;
|
||||
latestAttempt: QueueAttemptRef;
|
||||
@@ -98,10 +117,12 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
private readonly eventsByRun = new Map<string, RunEvent[]>();
|
||||
private readonly runners = new Map<string, RunnerRecord>();
|
||||
private readonly sessions = new Map<string, SessionRecord>();
|
||||
private readonly sessionReadCursors = new Map<string, SessionReadCursorRecord>();
|
||||
private readonly runnerJobs = new Map<string, RunnerJobRecord>();
|
||||
private readonly queueTasks = new Map<string, QueueTaskRecord>();
|
||||
private readonly queueReadCursors = new Map<string, QueueReadCursorRecord>();
|
||||
private queueVersion = 0;
|
||||
private sessionVersion = 0;
|
||||
|
||||
health(): StoreHealth {
|
||||
return { adapter: "memory-self-test", ready: true, reachable: true, migrationReady: true, migrationId: "memory-self-test", failureKind: null, message: null, credentialValuesPrinted: false };
|
||||
@@ -113,6 +134,7 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
const run: RunRecord = { ...input, sessionRef, resourceBundleRef: input.resourceBundleRef ?? null, id: newId("run"), status: "pending", terminalStatus: null, failureKind: null, failureMessage: null, createdAt: at, updatedAt: at, claimedBy: null, leaseExpiresAt: null };
|
||||
this.runs.set(run.id, run);
|
||||
this.eventsByRun.set(run.id, []);
|
||||
this.touchSessionForRun(run, { lastRunId: run.id, lastActivityAt: at }, { bumpVersion: false, at });
|
||||
this.appendEvent(run.id, "backend_status", { phase: "run-created", backendProfile: run.backendProfile, sessionRef: summarizeSessionRef(run.sessionRef ?? null), resourceBundleRef: summarizeResourceBundleRef(run.resourceBundleRef ?? null) });
|
||||
return run;
|
||||
}
|
||||
@@ -143,6 +165,8 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
const seq = Array.from(this.commands.values()).filter((command) => command.runId === runId).length + 1;
|
||||
const command: CommandRecord = { ...input, id: newId("cmd"), runId, seq, state: "pending", payloadHash, createdAt: at, updatedAt: at, acknowledgedAt: null };
|
||||
this.commands.set(command.id, command);
|
||||
if (command.type === "turn") this.touchSessionForRun(run, { executionState: "running", lastRunId: run.id, lastCommandId: command.id, activeRunId: run.id, activeCommandId: command.id, terminalStatus: null, failureKind: null, title: sessionTitleFromCommand(command), lastActivityAt: at }, { bumpVersion: true, at });
|
||||
else if (command.type === "steer") this.touchSessionForRun(run, { executionState: "running", lastRunId: run.id, lastCommandId: command.id, activeRunId: run.id, lastActivityAt: at }, { bumpVersion: true, at });
|
||||
this.appendEvent(runId, "backend_status", { phase: "command-created", commandId: command.id, commandType: command.type });
|
||||
return command;
|
||||
}
|
||||
@@ -195,6 +219,7 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
if (isTerminalRunStatus(run.status)) throw new AgentRunError(run.failureKind ?? (run.status === "cancelled" ? "cancelled" : "schema-invalid"), `run ${runId} is already terminal: ${run.status}`, { httpStatus: 409 });
|
||||
if (run.claimedBy && run.claimedBy !== runnerId && !isLeaseExpired(run.leaseExpiresAt)) throw new AgentRunError("runner-lease-conflict", `run ${runId} is already claimed`, { httpStatus: 409 });
|
||||
const next = this.updateRun(runId, { status: "claimed", claimedBy: runnerId, leaseExpiresAt: new Date(Date.now() + leaseMs).toISOString() });
|
||||
this.touchSessionForRun(next, { executionState: "running", activeRunId: runId, lastRunId: runId, lastActivityAt: next.updatedAt }, { bumpVersion: false, at: next.updatedAt });
|
||||
this.appendEvent(runId, "backend_status", { phase: "run-claimed", runnerId });
|
||||
return next;
|
||||
}
|
||||
@@ -221,6 +246,7 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
this.commands.set(commandId, next);
|
||||
const run = this.getRun(command.runId);
|
||||
if (result.threadId && run.sessionRef?.sessionId) this.upsertSessionThread(run, result.threadId, result.turnId ?? null);
|
||||
if (command.type === "turn") this.touchSessionForRun(this.getRun(command.runId), { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: command.runId, lastCommandId: command.id, terminalStatus: result.terminalStatus, failureKind: result.failureKind, lastActivityAt: next.updatedAt }, { bumpVersion: true, at: next.updatedAt });
|
||||
this.appendEvent(command.runId, "backend_status", { phase: "command-terminal", commandId, state: next.state, terminalStatus: result.terminalStatus, failureKind: result.failureKind, message: result.failureMessage ?? null, threadId: result.threadId ?? null, turnId: result.turnId ?? null });
|
||||
return next;
|
||||
}
|
||||
@@ -233,6 +259,7 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
const event: RunEvent = { id: newId("evt"), runId, seq: events.length + 1, type: eventType, payload: redactJson(eventPayload), createdAt: nowIso() };
|
||||
events.push(event);
|
||||
this.eventsByRun.set(runId, events);
|
||||
this.touchSessionForRun(this.getRun(runId), { lastEventSeq: event.seq, lastActivityAt: event.createdAt }, { bumpVersion: false, at: event.createdAt });
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -243,6 +270,7 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
const next = this.updateRun(runId, { status, terminalStatus: result.terminalStatus, failureKind: result.failureKind, failureMessage: result.failureMessage });
|
||||
if (result.threadId && next.sessionRef?.sessionId) this.upsertSessionThread(next, result.threadId, result.turnId ?? null);
|
||||
this.appendEvent(runId, "terminal_status", { terminalStatus: result.terminalStatus, failureKind: result.failureKind, message: result.failureMessage });
|
||||
this.touchSessionForRun(this.getRun(runId), { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: runId, terminalStatus: result.terminalStatus, failureKind: result.failureKind, lastActivityAt: next.updatedAt }, { bumpVersion: true, at: next.updatedAt });
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -258,6 +286,7 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
this.appendEvent(runId, "backend_status", { phase: "cancel-requested", reason });
|
||||
const next = this.updateRun(runId, { status: "cancelled", terminalStatus: "cancelled", failureKind: "cancelled", failureMessage: reason });
|
||||
this.appendEvent(runId, "terminal_status", { terminalStatus: "cancelled", failureKind: "cancelled", message: reason });
|
||||
this.touchSessionForRun(next, { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: runId, terminalStatus: "cancelled", failureKind: "cancelled", lastActivityAt: next.updatedAt }, { bumpVersion: true, at: next.updatedAt });
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -267,6 +296,7 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
const next = { ...command, state: "cancelled" as const, updatedAt: nowIso() };
|
||||
this.commands.set(commandId, next);
|
||||
this.appendEvent(command.runId, "backend_status", { phase: "command-terminal", commandId, state: "cancelled", terminalStatus: "cancelled", failureKind: "cancelled", message: reason });
|
||||
if (command.type === "turn") this.touchSessionForRun(this.getRun(command.runId), { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: command.runId, lastCommandId: command.id, terminalStatus: "cancelled", failureKind: "cancelled", lastActivityAt: next.updatedAt }, { bumpVersion: true, at: next.updatedAt });
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -274,6 +304,46 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
return this.sessions.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
getSessionSummary(sessionId: string, readerId: string | null = null): SessionSummary {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) throw new AgentRunError("schema-invalid", `session ${sessionId} was not found`, { httpStatus: 404 });
|
||||
return buildSessionSummary(session, readerId, readerId ? this.sessionReadCursors.get(sessionReadKey(sessionId, readerId)) ?? null : null);
|
||||
}
|
||||
|
||||
listSessions(input: ListSessionsInput): SessionListResult {
|
||||
const startVersion = parseSessionCursor(input.cursor) ?? 0;
|
||||
const state = input.state ?? "default";
|
||||
const items = Array.from(this.sessions.values())
|
||||
.map((session) => buildSessionSummary(session, input.readerId ?? null, input.readerId ? this.sessionReadCursors.get(sessionReadKey(session.sessionId, input.readerId)) ?? null : null))
|
||||
.filter((session) => session.version > startVersion)
|
||||
.filter((session) => !input.backendProfile || session.backendProfile === input.backendProfile)
|
||||
.filter((session) => sessionMatchesListState(session, state))
|
||||
.sort(sessionSort)
|
||||
.slice(0, clampSessionLimit(input.limit));
|
||||
return { items, count: items.length, cursor: items.length > 0 ? String(items[items.length - 1]?.version ?? startVersion) : null, filters: sessionListFilters(input) };
|
||||
}
|
||||
|
||||
listSessionTrace(sessionId: string, input: SessionEventPageInput): SessionEventPage {
|
||||
const runId = this.resolveSessionRunId(sessionId, input.runId ?? null);
|
||||
if (!runId) return { sessionId, runId: null, items: [], count: 0, cursor: null };
|
||||
const items = this.listEvents(runId, input.afterSeq, input.limit);
|
||||
return { sessionId, runId, items, count: items.length, cursor: items.length > 0 ? String(items[items.length - 1]?.seq ?? input.afterSeq) : null };
|
||||
}
|
||||
|
||||
listSessionOutput(sessionId: string, input: SessionEventPageInput): SessionEventPage {
|
||||
const page = this.listSessionTrace(sessionId, input);
|
||||
const items = page.items.filter(isSessionOutputEvent);
|
||||
return { ...page, items, count: items.length, cursor: items.length > 0 ? String(items[items.length - 1]?.seq ?? input.afterSeq) : null };
|
||||
}
|
||||
|
||||
markSessionRead(sessionId: string, readerId: string): SessionReadCursorRecord {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) throw new AgentRunError("schema-invalid", `session ${sessionId} was not found`, { httpStatus: 404 });
|
||||
const record: SessionReadCursorRecord = { sessionId, readerId, sessionVersion: session.version, readAt: nowIso() };
|
||||
this.sessionReadCursors.set(sessionReadKey(sessionId, readerId), record);
|
||||
return record;
|
||||
}
|
||||
|
||||
createQueueTask(input: CreateQueueTaskInput): QueueTaskRecord {
|
||||
const payloadHash = stableHash(input.payload);
|
||||
if (input.idempotencyKey) {
|
||||
@@ -371,6 +441,18 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
conversationId: input.sessionRef.conversationId ?? null,
|
||||
threadId: input.sessionRef.threadId ?? null,
|
||||
metadata: input.sessionRef.metadata ?? {},
|
||||
version: this.nextSessionVersion(),
|
||||
executionState: "idle",
|
||||
lastRunId: null,
|
||||
lastCommandId: null,
|
||||
activeRunId: null,
|
||||
activeCommandId: null,
|
||||
lastEventSeq: 0,
|
||||
terminalStatus: null,
|
||||
failureKind: null,
|
||||
title: titleFromMetadata(input.sessionRef.metadata ?? {}),
|
||||
summary: {},
|
||||
lastActivityAt: at,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
expiresAt: input.sessionRef.expiresAt ?? null,
|
||||
@@ -391,6 +473,18 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
conversationId: run.sessionRef.conversationId ?? existing?.conversationId ?? null,
|
||||
threadId,
|
||||
metadata: { ...(existing?.metadata ?? {}), ...(run.sessionRef.metadata ?? {}), ...(turnId ? { lastTurnId: turnId } : {}) },
|
||||
version: this.nextSessionVersion(),
|
||||
executionState: existing?.executionState ?? "idle",
|
||||
lastRunId: existing?.lastRunId ?? run.id,
|
||||
lastCommandId: existing?.lastCommandId ?? null,
|
||||
activeRunId: existing?.activeRunId ?? null,
|
||||
activeCommandId: existing?.activeCommandId ?? null,
|
||||
lastEventSeq: existing?.lastEventSeq ?? 0,
|
||||
terminalStatus: existing?.terminalStatus ?? null,
|
||||
failureKind: existing?.failureKind ?? null,
|
||||
title: existing?.title ?? titleFromMetadata(run.sessionRef.metadata ?? {}),
|
||||
summary: existing?.summary ?? {},
|
||||
lastActivityAt: at,
|
||||
createdAt: existing?.createdAt ?? at,
|
||||
updatedAt: at,
|
||||
expiresAt: run.sessionRef.expiresAt ?? existing?.expiresAt ?? null,
|
||||
@@ -400,6 +494,32 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
this.updateRun(run.id, { sessionRef: nextSessionRef });
|
||||
this.appendEvent(run.id, "backend_status", { phase: "session-updated", sessionRef: summarizeSessionRef(nextSessionRef), turnId });
|
||||
}
|
||||
|
||||
private touchSessionForRun(run: RunRecord, patch: Partial<SessionRecord>, options: { bumpVersion: boolean; at?: string }): void {
|
||||
const sessionId = run.sessionRef?.sessionId;
|
||||
if (!sessionId) return;
|
||||
const existing = this.sessions.get(sessionId);
|
||||
if (!existing) return;
|
||||
const at = options.at ?? nowIso();
|
||||
const next: SessionRecord = { ...existing, ...patch, version: options.bumpVersion ? this.nextSessionVersion() : existing.version, updatedAt: at, lastActivityAt: patch.lastActivityAt ?? at };
|
||||
this.sessions.set(sessionId, next);
|
||||
}
|
||||
|
||||
private resolveSessionRunId(sessionId: string, requestedRunId: string | null): string | null {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) throw new AgentRunError("schema-invalid", `session ${sessionId} was not found`, { httpStatus: 404 });
|
||||
if (requestedRunId) {
|
||||
const run = this.getRun(requestedRunId);
|
||||
if (run.sessionRef?.sessionId !== sessionId) throw new AgentRunError("schema-invalid", `run ${requestedRunId} does not belong to session ${sessionId}`, { httpStatus: 404 });
|
||||
return requestedRunId;
|
||||
}
|
||||
return session.activeRunId ?? session.lastRunId;
|
||||
}
|
||||
|
||||
private nextSessionVersion(): number {
|
||||
this.sessionVersion += 1;
|
||||
return this.sessionVersion;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSessionBoundary(existing: SessionRecord, input: CreateRunInput): void {
|
||||
@@ -451,6 +571,43 @@ export function sessionRefFromRecord(record: SessionRecord, fallback: SessionRef
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionSummary(record: SessionRecord, readerId: string | null, readCursor: SessionReadCursorRecord | null): SessionSummary {
|
||||
const active = record.executionState === "running" || record.activeRunId !== null || record.activeCommandId !== null;
|
||||
const unread = !active && (!readCursor || readCursor.sessionVersion < record.version);
|
||||
const attentionState = active ? "active" : unread ? "unread" : "read";
|
||||
return { ...record, sessionPath: `${record.tenantId}/${record.projectId}/${record.sessionId}`, readerId, readCursor, attentionState, unread, active };
|
||||
}
|
||||
|
||||
export function sessionMatchesListState(session: SessionSummary, state: SessionListState): boolean {
|
||||
if (state === "all") return true;
|
||||
if (state === "default") return session.active || session.unread;
|
||||
if (state === "running") return session.active;
|
||||
if (state === "unread") return session.unread;
|
||||
if (state === "terminal") return session.executionState === "terminal";
|
||||
if (state === "idle") return session.executionState === "idle";
|
||||
return false;
|
||||
}
|
||||
|
||||
export function sessionSort(a: SessionSummary, b: SessionSummary): number {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||
if (a.unread !== b.unread) return a.unread ? -1 : 1;
|
||||
return (b.lastActivityAt ?? b.updatedAt).localeCompare(a.lastActivityAt ?? a.updatedAt) || b.updatedAt.localeCompare(a.updatedAt) || a.sessionId.localeCompare(b.sessionId);
|
||||
}
|
||||
|
||||
export function clampSessionLimit(limit: number): number {
|
||||
return Math.max(1, Math.min(Number.isFinite(limit) ? Math.trunc(limit) : 50, 100));
|
||||
}
|
||||
|
||||
export function parseSessionCursor(cursor: string | undefined): number | null {
|
||||
if (!cursor) return null;
|
||||
const value = Number(cursor);
|
||||
return Number.isInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
export function sessionListFilters(input: ListSessionsInput): JsonRecord {
|
||||
return { state: input.state ?? "default", backendProfile: input.backendProfile ?? null, readerId: input.readerId ?? null, cursor: input.cursor ?? null, limit: clampSessionLimit(input.limit) };
|
||||
}
|
||||
|
||||
export function summarizeSessionRef(sessionRef: SessionRef | null): JsonRecord | null {
|
||||
if (!sessionRef) return null;
|
||||
return {
|
||||
@@ -510,3 +667,22 @@ export function parseQueueCursor(cursor: string | undefined): number | null {
|
||||
function queueReadKey(taskId: string, readerId: string): string {
|
||||
return `${taskId}:${readerId}`;
|
||||
}
|
||||
|
||||
export function sessionReadKey(sessionId: string, readerId: string): string {
|
||||
return `${sessionId}:${readerId}`;
|
||||
}
|
||||
|
||||
export function titleFromMetadata(metadata: JsonRecord): string | null {
|
||||
const title = metadata.title;
|
||||
return typeof title === "string" && title.trim().length > 0 ? title.trim().slice(0, 200) : null;
|
||||
}
|
||||
|
||||
export function sessionTitleFromCommand(command: CommandRecord): string | null {
|
||||
const value = command.payload.prompt;
|
||||
if (typeof value !== "string") return null;
|
||||
return value.trim().replace(/\s+/gu, " ").slice(0, 120) || null;
|
||||
}
|
||||
|
||||
export function isSessionOutputEvent(event: RunEvent): boolean {
|
||||
return event.type === "assistant_message" || event.type === "command_output" || event.type === "diff" || event.type === "error" || event.type === "terminal_status";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user