fix: 统一 session send 续跑入口

This commit is contained in:
AgentRun Codex
2026-06-11 21:57:29 +08:00
parent 4a5b298a30
commit 64b824911f
6 changed files with 341 additions and 107 deletions
+55 -9
View File
@@ -56,8 +56,11 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
const failureMessage = resultFailureMessage(run, command, scopedEvents, terminal);
const failureDetails = resultFailureDetails(scopedEvents, terminal);
const reply = assistantReply(scopedEvents);
const responseAuthority = finalResponseAuthority(reply);
const needsContinuation = terminal === "completed" && responseAuthority !== "authoritative";
const completionEvidence = completionEvidenceSummary({ terminal, terminalSource, reply, responseAuthority, needsContinuation, sessionId: run.sessionRef?.sessionId ?? null });
const blocker = terminal === "blocked" || terminal === "failed" ? { failureKind, message: failureMessage, details: failureDetails } : null;
const liveness = livenessSnapshot(run, command, events, scopedEvents, terminal, failureKind, failureMessage);
const liveness = livenessSnapshot(run, command, events, scopedEvents, terminal, failureKind, failureMessage, { responseAuthority, needsContinuation });
const terminalClassification = terminalClassificationSummary({ terminal, terminalSource, failureKind, failureMessage, liveness });
const diagnosis = runDiagnosis({ run, command, latestJob, events, terminalClassification, liveness, terminalStatus: terminal, failureKind, failureMessage });
const steerDelivery = command?.type === "steer" ? steerDeliverySummary(events, command.id) : null;
@@ -74,6 +77,10 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
terminalStatus: terminal,
terminalSource,
completed: terminal === "completed",
finalResponseAuthority: responseAuthority,
finalResponseFallback: responseAuthority === "fallback",
needsContinuation,
completionEvidence,
reply: reply.text,
finalResponse: {
text: reply.text,
@@ -81,6 +88,9 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
source: reply.source,
final: reply.final,
replyAuthority: reply.replyAuthority,
authority: responseAuthority,
fallback: responseAuthority === "fallback",
needsContinuation,
textTruncated: reply.textTruncated,
outputTruncated: reply.outputTruncated,
},
@@ -110,7 +120,7 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
};
}
function livenessSnapshot(run: RunRecord, command: CommandRecord | null, events: RunEvent[], scopedEvents: RunEvent[], terminal: TerminalStatus | null, failureKind: FailureKind | null, failureMessage: string | null): JsonRecord {
function livenessSnapshot(run: RunRecord, command: CommandRecord | null, events: RunEvent[], scopedEvents: RunEvent[], terminal: TerminalStatus | null, failureKind: FailureKind | null, failureMessage: string | null, completion: { responseAuthority: string; needsContinuation: boolean }): JsonRecord {
const nowMs = Date.now();
const active = terminal === null && !runIsTerminal(run) && !commandIsTerminal(command);
const lastEvent = events.at(-1) ?? null;
@@ -143,15 +153,16 @@ function livenessSnapshot(run: RunRecord, command: CommandRecord | null, events:
lease,
transportDisconnect: transportDisconnect ? livenessActivitySummary(transportDisconnect, nowMs) : null,
retryInterruption: retryInterruption ? livenessActivitySummary(retryInterruption, nowMs) : null,
recoveryActions: recoveryActions({ run, command, afterSeq, active, terminal, failureKind, failureMessage }),
recoveryActions: recoveryActions({ run, command, afterSeq, active, terminal, failureKind, failureMessage, needsContinuation: completion.needsContinuation, finalResponseAuthority: completion.responseAuthority }),
valuesPrinted: false,
};
}
function livenessPhase(input: { active: boolean; command: CommandRecord | null; lastVisibleActivity: RunEvent | null; leaseExpired: boolean | null; transportDisconnect: RunEvent | null; timeoutBudget: JsonRecord; lastActivity: JsonRecord | null }): string {
if (!input.active) return "terminal";
if (input.command?.state === "pending") return "waiting-runner";
if (input.command?.state === "pending" && input.leaseExpired === true) return "waiting-runner-stale-lease";
if (input.leaseExpired === true) return "runner-heartbeat-stale";
if (input.command?.state === "pending") return "waiting-runner";
if (input.transportDisconnect) return "transport-disconnected";
if (input.lastVisibleActivity?.type === "tool_call") {
const status = input.lastVisibleActivity.payload.status;
@@ -428,8 +439,8 @@ function ageMs(value: string, nowMs: number): number | null {
return Number.isFinite(parsed) ? Math.max(0, nowMs - parsed) : null;
}
function recoveryActions(input: { run: RunRecord; command: CommandRecord | null; afterSeq: number; active: boolean; terminal: TerminalStatus | null; failureKind: FailureKind | null; failureMessage: string | null }): JsonRecord[] {
const { run, command, afterSeq, active, terminal, failureKind, failureMessage } = input;
function recoveryActions(input: { run: RunRecord; command: CommandRecord | null; afterSeq: number; active: boolean; terminal: TerminalStatus | null; failureKind: FailureKind | null; failureMessage: string | null; needsContinuation: boolean; finalResponseAuthority: string }): JsonRecord[] {
const { run, command, afterSeq, active, terminal, failureKind, failureMessage, needsContinuation, finalResponseAuthority } = input;
const sessionId = run.sessionRef?.sessionId ?? null;
const traceCommand = sessionId ? `./scripts/agentrun sessions trace ${sessionId} --after-seq ${afterSeq} --limit 100 --run-id ${run.id}` : `./scripts/agentrun runs events ${run.id} --after-seq ${afterSeq} --limit 100 --summary`;
const outputCommand = sessionId ? `./scripts/agentrun sessions output ${sessionId} --after-seq ${afterSeq} --limit 100 --run-id ${run.id}` : null;
@@ -438,20 +449,55 @@ function recoveryActions(input: { run: RunRecord; command: CommandRecord | null;
];
if (outputCommand) actions.push({ action: "poll-output", runId: run.id, commandId: command?.id ?? null, afterSeq, command: outputCommand, valuesPrinted: false });
if (active) {
if (sessionId) actions.push({ action: "steer-session", sessionId, runId: run.id, commandId: command?.id ?? null, command: `./scripts/agentrun sessions steer ${sessionId} --prompt-stdin`, valuesPrinted: false });
if (sessionId) actions.push({ action: "send-session", sessionId, runId: run.id, commandId: command?.id ?? null, command: `./scripts/agentrun sessions send ${sessionId} --prompt-stdin`, hint: "manager 会按当前 session 状态自动决定内部 steer 或新 turn", valuesPrinted: false });
if (command) actions.push({ action: "cancel-command", runId: run.id, commandId: command.id, command: `./scripts/agentrun commands cancel ${command.id} --reason <reason>`, valuesPrinted: false });
else actions.push({ action: "cancel-run", runId: run.id, command: `./scripts/agentrun runs cancel ${run.id} --reason <reason>`, valuesPrinted: false });
return actions;
}
if (needsContinuation && sessionId) {
if (command) actions.push({ action: "inspect-result", runId: run.id, commandId: command.id, command: `./scripts/agentrun commands result ${command.id} --run-id ${run.id}`, valuesPrinted: false });
actions.push({ action: "continue-session", reason: `final-response-${finalResponseAuthority}`, sessionId, command: `./scripts/agentrun sessions send ${sessionId} --prompt-stdin`, hint: "命令已 terminal completed,但没有 authoritative final response;管理者应先读 trace/output,再用同一 session 发送后续 prompt。", valuesPrinted: false });
return actions;
}
if (terminal === "failed" || terminal === "blocked" || terminal === "cancelled") {
if (command) actions.push({ action: "inspect-result", runId: run.id, commandId: command.id, command: `./scripts/agentrun commands result ${command.id} --run-id ${run.id}`, valuesPrinted: false });
if (sessionId) actions.push({ action: "resume-session", sessionId, command: `./scripts/agentrun sessions turn ${sessionId} --prompt-stdin`, valuesPrinted: false });
if (sessionId) actions.push({ action: "continue-session", sessionId, command: `./scripts/agentrun sessions send ${sessionId} --prompt-stdin`, valuesPrinted: false });
if (failureKind === "backend-timeout") actions.push({ action: "split-task", reason: "backend-timeout", hint: "先由管理者读取 trace/result,总结下一步,再把后续 prompt 发到同一 session;必要时把大 patch / 长工具链拆成更短 turn。", failureMessage: failureMessage ? boundedTextSummary(failureMessage, { limitChars: 200 }).text as string : null, valuesPrinted: false });
else actions.push({ action: "retry-or-split", reason: failureKind ?? "terminal", hint: "先读 trace/output 的 detail id,再决定 steer、重跑或拆分", valuesPrinted: false });
else actions.push({ action: "retry-or-split", reason: failureKind ?? "terminal", hint: "先读 trace/output 的 detail id,再决定继续同 session、重跑或拆分", valuesPrinted: false });
}
return actions;
}
function finalResponseAuthority(reply: AssistantReplySummary): "authoritative" | "fallback" | "missing" {
if (reply.replyAuthority || reply.final) return "authoritative";
return reply.text.length > 0 ? "fallback" : "missing";
}
function completionEvidenceSummary(input: { terminal: TerminalStatus | null; terminalSource: string; reply: AssistantReplySummary; responseAuthority: string; needsContinuation: boolean; sessionId: string | null }): JsonRecord {
const recommendedAction = input.needsContinuation && input.sessionId ? `./scripts/agentrun sessions send ${input.sessionId} --prompt-stdin` : null;
return {
terminalStatus: input.terminal,
terminalSource: input.terminalSource,
finalResponseAuthority: input.responseAuthority,
finalResponseSeq: input.reply.seq,
finalResponseSource: input.reply.source,
finalResponseFinal: input.reply.final,
finalResponseReplyAuthority: input.reply.replyAuthority,
finalResponseFallback: input.responseAuthority === "fallback",
needsContinuation: input.needsContinuation,
reason: completionEvidenceReason(input.responseAuthority, input.terminal),
recommendedAction,
valuesPrinted: false,
};
}
function completionEvidenceReason(responseAuthority: string, terminal: TerminalStatus | null): string {
if (terminal !== "completed") return "command is not completed";
if (responseAuthority === "authoritative") return "terminal completed with authoritative assistant final response";
if (responseAuthority === "fallback") return "terminal completed but only a non-authoritative assistant progress/output fallback was available";
return "terminal completed but no assistant response text was available";
}
function numberJsonValue(value: JsonValue | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
+159 -2
View File
@@ -6,8 +6,8 @@ import { openAgentRunStoreFromEnv } from "./store.js";
import { AgentRunError, errorToJson } from "../common/errors.js";
import { asRecord, validateBackendProfile, validateCreateCommand, validateCreateQueueTask, validateCreateRun, validateQueueTaskState, validateSessionListState } from "../common/validation.js";
import { isBackendProfile } from "../common/backend-profiles.js";
import type { ApiErrorBody, ApiOkBody, JsonRecord, JsonValue, QueueTaskRecord, RunEvent } from "../common/types.js";
import { createKubernetesRunnerJob } from "./kubernetes-runner-job.js";
import type { ApiErrorBody, ApiOkBody, CommandRecord, JsonRecord, JsonValue, QueueTaskRecord, RunEvent, RunRecord, SessionRecord } from "../common/types.js";
import { createKubernetesRunnerJob, type RunnerJobDefaults } from "./kubernetes-runner-job.js";
import { dispatchQueueTask, refreshQueueTaskFromCore } from "./queue-dispatch.js";
import { buildRunResult } from "./result.js";
import { runnerJobStatusSummary } from "./runner-job-status.js";
@@ -35,6 +35,21 @@ function sessionPvcOptionsForRequest(serverDefaults: { kubectlHandler?: import("
return pvcOptions(runnerJobDefaults);
}
function runnerJobDefaultsForRequest(defaults: ManagerServerOptions["runnerJobDefaults"], sourceCommit: string): RunnerJobDefaults {
const namespace = defaults?.namespace ?? process.env.AGENTRUN_RUNTIME_NAMESPACE ?? "agentrun-v01";
return {
namespace,
managerUrl: defaults?.managerUrl ?? process.env.AGENTRUN_INTERNAL_MGR_URL ?? `http://agentrun-mgr.${namespace}.svc.cluster.local:8080`,
image: defaults?.image ?? process.env.AGENTRUN_RUNNER_IMAGE ?? "",
sourceCommit,
...optionalStringRecord("envIdentity", defaults?.envIdentity ?? process.env.AGENTRUN_ENV_IDENTITY),
...optionalStringRecord("artifactCatalogFile", defaults?.artifactCatalogFile ?? process.env.AGENTRUN_ARTIFACT_CATALOG_FILE),
serviceAccountName: defaults?.serviceAccountName ?? process.env.AGENTRUN_RUNNER_SERVICE_ACCOUNT ?? "agentrun-v01-runner",
...(defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {}),
...(defaults?.unideskSshEndpointEnv ? { unideskSshEndpointEnv: defaults.unideskSshEndpointEnv } : {}),
};
}
export interface ManagerServerOptions {
store?: AgentRunStore;
port?: number;
@@ -341,6 +356,10 @@ async function route({ method, url, body, store, sourceCommit, authSummary, runn
if (runId) input.runId = runId;
return await store.listSessionOutput(sessionOutputMatch[1] ?? "", input) as unknown as JsonValue;
}
const sessionSendMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)\/send$/u);
if (method === "POST" && sessionSendMatch) {
return await sendToSession({ store, sessionId: sessionSendMatch[1] ?? "", body, sourceCommit, runnerJobDefaults }) as JsonValue;
}
const sessionReadMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)\/read$/u);
if (method === "POST" && sessionReadMatch) {
const record = body === null ? {} : asRecord(body, "read");
@@ -606,6 +625,144 @@ async function applyNamedAipodSpec(name: string, body: unknown, dir?: string): P
return await applyAipodSpec(spec, dir) as JsonValue;
}
async function sendToSession(input: { store: AgentRunStore; sessionId: string; body: unknown; sourceCommit: string; runnerJobDefaults?: ManagerServerOptions["runnerJobDefaults"] }): Promise<JsonRecord> {
const record = asRecord(input.body ?? {}, "sessionSend");
const dryRun = record.dryRun === true;
const existing = await input.store.getSession(input.sessionId);
const active = existing ? await activeReceivableCommand(input.store, existing) : null;
const payload = sessionSendPayload(record);
const commandIdempotencyKey = optionalString(record.commandIdempotencyKey) ?? optionalString(record.idempotencyKey);
if (active) {
const commandBody: JsonRecord = { type: "steer", payload };
if (commandIdempotencyKey) commandBody.idempotencyKey = commandIdempotencyKey;
const request = { method: "POST", path: `/api/v1/runs/${active.run.id}/commands`, commandType: "steer", payloadBytes: jsonByteLength(payload), valuesPrinted: false };
if (dryRun) return sessionSendPlan(input.sessionId, "steer", active, request, null);
const command = await input.store.createCommand(active.run.id, validateCreateCommand(commandBody));
return sessionSendResponse({ sessionId: input.sessionId, decision: "steer", run: active.run, command, runnerJob: null, activeBefore: active, dryRun: false });
}
const runRecord = asRecord(record.run ?? record.runBase ?? null, "sessionSend.run");
const runBody = sessionSendRunBody(input.sessionId, runRecord);
const commandBody: JsonRecord = { type: "turn", payload };
if (commandIdempotencyKey) commandBody.idempotencyKey = commandIdempotencyKey;
const runnerJobBody = record.runnerJob === undefined || record.runnerJob === null ? {} : asRecord(record.runnerJob, "sessionSend.runnerJob");
const createRunnerJob = record.createRunnerJob !== false;
const request = {
method: "POST",
path: `/api/v1/sessions/${input.sessionId}/send`,
commandType: "turn",
runBytes: jsonByteLength(runBody),
payloadBytes: jsonByteLength(payload),
createRunnerJob,
runnerJobBytes: createRunnerJob ? jsonByteLength(runnerJobBody) : 0,
valuesPrinted: false,
};
if (dryRun) return sessionSendPlan(input.sessionId, "turn", active, request, runBody);
const run = await input.store.createRun(validateCreateRun(runBody));
const command = await input.store.createCommand(run.id, validateCreateCommand(commandBody));
let runnerJob: JsonValue = null;
if (createRunnerJob) {
runnerJob = await createKubernetesRunnerJob({
store: input.store,
runId: run.id,
input: { ...runnerJobBody, commandId: command.id } as never,
defaults: runnerJobDefaultsForRequest(input.runnerJobDefaults, input.sourceCommit),
}) as unknown as JsonValue;
}
return sessionSendResponse({ sessionId: input.sessionId, decision: "turn", run, command, runnerJob, activeBefore: active, dryRun: false });
}
async function activeReceivableCommand(store: AgentRunStore, session: SessionRecord): Promise<{ run: RunRecord; command: CommandRecord; reason: string; leaseExpired: boolean } | null> {
if (!session.activeRunId || !session.activeCommandId) return null;
const [run, command] = await Promise.all([store.getRun(session.activeRunId), store.getCommand(session.activeCommandId)]);
if (run.sessionRef?.sessionId !== session.sessionId || command.runId !== run.id) return null;
if (runIsTerminal(run) || commandIsTerminal(command)) return null;
if (command.type !== "turn") return null;
const leaseExpired = run.leaseExpiresAt ? Date.parse(run.leaseExpiresAt) <= Date.now() : false;
if (leaseExpired) return null;
if (command.state !== "acknowledged") return null;
if (run.status !== "claimed" && run.status !== "running") return null;
return { run, command, reason: "active-turn-running", leaseExpired };
}
function sessionSendPayload(record: JsonRecord): JsonRecord {
const payload = asJsonRecord(record.payload);
if (payload) return payload;
const prompt = optionalString(record.prompt) ?? optionalString(record.message) ?? optionalString(record.text);
if (!prompt) throw new AgentRunError("schema-invalid", "sessions send requires payload or non-empty prompt/message/text", { httpStatus: 400 });
return { prompt };
}
function sessionSendRunBody(sessionId: string, runRecord: JsonRecord): JsonRecord {
const sessionRef = asJsonRecord(runRecord.sessionRef) ?? {};
const metadata = asJsonRecord(sessionRef.metadata) ?? {};
return { ...runRecord, sessionRef: { ...sessionRef, sessionId, metadata } };
}
function sessionSendPlan(sessionId: string, decision: "steer" | "turn", active: Awaited<ReturnType<typeof activeReceivableCommand>>, request: JsonRecord, runBody: JsonRecord | null): JsonRecord {
return {
action: "session-send-plan",
dryRun: true,
mutation: false,
sessionId,
decision,
internalCommandType: decision,
activeBefore: active ? activeBeforeSummary(active) : null,
request,
...(runBody ? { run: { bodyBytes: jsonByteLength(runBody), sessionRef: summarizeSendSessionRef(runBody), valuesPrinted: false } } : {}),
next: { confirm: `./scripts/agentrun sessions send ${sessionId} --prompt-stdin`, note: "Remove --dry-run to perform the mutation. Manager will decide internal steer vs turn from durable session state." },
valuesPrinted: false,
};
}
function sessionSendResponse(input: { sessionId: string; decision: "steer" | "turn"; run: RunRecord; command: CommandRecord; runnerJob: JsonValue; activeBefore: Awaited<ReturnType<typeof activeReceivableCommand>>; dryRun: false }): JsonRecord {
return {
action: "session-send",
dryRun: input.dryRun,
mutation: true,
sessionId: input.sessionId,
decision: input.decision,
internalCommandType: input.command.type,
run: input.run as unknown as JsonRecord,
command: input.command as unknown as JsonRecord,
runnerJob: input.runnerJob,
activeBefore: input.activeBefore ? activeBeforeSummary(input.activeBefore) : null,
pollCommands: {
show: `./scripts/agentrun sessions show ${input.sessionId} --reader-id cli`,
trace: `./scripts/agentrun sessions trace ${input.sessionId} --after-seq 0 --limit 100`,
output: `./scripts/agentrun sessions output ${input.sessionId} --after-seq 0 --limit 100`,
read: `./scripts/agentrun sessions read ${input.sessionId} --reader-id cli`,
cancel: `./scripts/agentrun sessions cancel ${input.sessionId}`,
},
valuesPrinted: false,
};
}
function activeBeforeSummary(active: NonNullable<Awaited<ReturnType<typeof activeReceivableCommand>>>): JsonRecord {
return { runId: active.run.id, commandId: active.command.id, commandState: active.command.state, runStatus: active.run.status, leaseExpiresAt: active.run.leaseExpiresAt, leaseExpired: active.leaseExpired, reason: active.reason, valuesPrinted: false };
}
function summarizeSendSessionRef(runBody: JsonRecord): JsonRecord {
const ref = asJsonRecord(runBody.sessionRef) ?? {};
return { sessionId: optionalString(ref.sessionId), conversationId: optionalString(ref.conversationId), threadId: optionalString(ref.threadId), metadataKeys: Object.keys(asJsonRecord(ref.metadata) ?? {}).sort(), valuesPrinted: false };
}
function runIsTerminal(run: RunRecord): boolean {
return run.status === "completed" || run.status === "failed" || run.status === "blocked" || run.status === "cancelled";
}
function commandIsTerminal(command: CommandRecord): boolean {
return command.state === "completed" || command.state === "failed" || command.state === "cancelled";
}
function optionalString(value: JsonValue | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function jsonByteLength(value: unknown): number {
return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
}
function integerQuery(url: URL, key: string, fallback: number): number {
const value = Number(url.searchParams.get(key));
return Number.isInteger(value) && value >= 0 ? value : fallback;