diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index 8dfbdc32..ba6f12cb 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -49,6 +49,10 @@ AipodSpec/Artificer、task manifest 和 queue 渐进披露见 [references/resour - credential 只显示 SecretRef 并固定 `valuesPrinted=false`; - 完整服务端对象只通过显式 `--full -o json` 或 `--raw` 下钻。 - Session follow-up 使用 `agentrun send`,由服务端决定内部 steer vs turn。 +- Session follow-up 的默认 text、显式 JSON 和 YAML 使用同一有界投影: + - dry-run 直接展示 `decision`、`reuseRun`、`createRunner`、session/run/command/runner identity、`mutation=false` 和语义化 `Next`; + - live 失败保留 manager 返回的 typed failure、资源 identity、`mutation` / `partialWrite` 与只读下钻命令; + - CLI 不补做 retry、cleanup、runner 调度或 session 状态机,partial write 时禁止盲目重发。 - 终态失败重试只使用 `agentrun retry task/`:保持同一 task,新增不可变 attempt;先用 `--dry-run` 完成服务端全校验,再以同一幂等键执行真实重试。 - retry 只接受 Queue 已处于 `failed` 或 `blocked`;`completed`、`cancelled` 和其他状态必须 fail closed。不得复制 task、重置 task、引入本地 scheduler/backoff/judge 或直接创建 runner Job 来模拟重试。 - HWLAB Code Agent/CaseRun follow-up 也优先落到 AgentRun resource primitive,不绕过队列状态。 diff --git a/scripts/src/agentrun.test.ts b/scripts/src/agentrun.test.ts index 25024ba1..3b01c188 100644 --- a/scripts/src/agentrun.test.ts +++ b/scripts/src/agentrun.test.ts @@ -808,6 +808,174 @@ describe("AgentRun default transport contract", () => { rmSync(tempConfigPath, { force: true }); } }); + + test("session send plan and typed partial-write errors stay homologous and bounded", async () => { + const sessionId = "sess_artificer_fixture"; + const runId = "run_partial_fixture"; + const commandId = "cmd_partial_fixture"; + const hiddenKubernetesPayload = "HIDDEN_KUBERNETES_PAYLOAD_MUST_NOT_BE_PRINTED"; + let postRequests = 0; + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + expect(request.headers.get("authorization")).toBe("Bearer secret-value"); + if (request.method === "GET" && url.pathname === `/api/v1/sessions/${sessionId}`) { + return Response.json({ + ok: true, + data: { + sessionId, + tenantId: "unidesk", + projectId: "test", + providerId: "NC01", + backendProfile: "codex", + conversationId: sessionId, + metadata: { title: "Artificer fixture" }, + }, + }); + } + if (request.method === "POST" && url.pathname === `/api/v1/sessions/${sessionId}/send`) { + postRequests += 1; + const body = await request.json() as Record; + if (body.payload?.prompt === "plan fixture") { + expect(body.dryRun).toBe(true); + return Response.json({ + ok: true, + data: { + action: "session-send-plan", + dryRun: true, + mutation: false, + sessionId, + decision: "turn", + internalCommandType: "turn", + activeBefore: null, + reusedIdleRun: null, + request: { method: "POST", path: `/api/v1/sessions/${sessionId}/send`, commandType: "turn", createRunnerJob: true, valuesPrinted: false }, + next: { + confirm: { action: "send-session", operation: "send", resourceKind: "session", resourceName: sessionId, sessionId, inputKind: "prompt", valuesPrinted: false }, + note: "Remove --dry-run to perform the mutation.", + }, + valuesPrinted: false, + }, + }); + } + return Response.json({ + ok: false, + failureKind: "infra-failed", + message: "kubectl get jobs failed with code 1", + traceId: "trc_session_send_fixture", + error: { + name: "AgentRunError", + failureKind: "infra-failed", + message: "kubectl get jobs failed with code 1", + details: { + code: "runner-admission-failed", + reason: "runner-retention-observation-failed", + phase: "runner-admission", + sessionId, + runId, + commandId, + mutation: true, + partialWrite: true, + durable: true, + resources: { + session: { id: sessionId, state: "running" }, + run: { id: runId, status: "queued" }, + command: { id: commandId, state: "pending", type: "turn" }, + }, + recoveryActions: [ + { operation: "describe", resourceKind: "session", resourceName: sessionId }, + { operation: "events", resourceKind: "run", resourceName: runId, runId, afterSeq: 0, limit: 100 }, + ], + stderr: hiddenKubernetesPayload, + rawKubernetesObject: { secret: hiddenKubernetesPayload }, + valuesPrinted: false, + }, + }, + }, { status: 502 }); + } + return new Response("not found", { status: 404 }); + }, + }); + const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG; + const previousKey = process.env.HWLAB_API_KEY; + process.env.HWLAB_API_KEY = "secret-value"; + const tempConfigPath = `/tmp/unidesk-agentrun-session-send-test-${process.pid}-${Date.now()}.yaml`; + try { + process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath; + await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, ""))); + + const planArgs = ["send", `session/${sessionId}`, "--prompt", "plan fixture", "--dry-run"]; + const planHuman = renderedTextOf(await runAgentRunCommand(null, planArgs)); + expect(planHuman).toContain("Session send plan"); + expect(planHuman).toContain("Decision: turn"); + expect(planHuman).toContain("ReuseRun: false"); + expect(planHuman).toContain("CreateRunner: true"); + expect(planHuman).toContain("Mutation: false"); + expect(planHuman).not.toContain("[object Object]"); + const planJsonText = renderedTextOf(await runAgentRunCommand(null, [...planArgs, "-o", "json"])); + const planYamlText = renderedTextOf(await runAgentRunCommand(null, [...planArgs, "-o", "yaml"])); + const planJson = JSON.parse(planJsonText) as Record; + const planYaml = Bun.YAML.parse(planYamlText) as Record; + expect(planYaml).toEqual(planJson); + expect(planJson).toMatchObject({ kind: "SessionSendPlan", decision: "turn", dryRun: true, mutation: false, partialWrite: false, reuseRun: false, createRunner: true }); + expect(planJson.resources.session).toEqual({ kind: "session", id: sessionId }); + expect(planJson.next.actions[0]).toMatchObject({ mutation: true }); + expect(planJson.next.actions[0].command).not.toContain("--dry-run"); + + const failureArgs = ["send", `session/${sessionId}`, "--prompt", "failure fixture"]; + const failureHumanResult = await runAgentRunCommand(null, failureArgs); + const failureHuman = renderedTextOf(failureHumanResult); + expect(failureHumanResult.ok).toBe(false); + expect(failureHuman).toContain("Session send failed"); + expect(failureHuman).toContain("Error: validation-failed"); + expect(failureHuman).toContain("TypedFailure: infra-failed"); + expect(failureHuman).toContain("Code: runner-admission-failed"); + expect(failureHuman).toContain("Reason: runner-retention-observation-failed"); + expect(failureHuman).toContain("Mutation: true"); + expect(failureHuman).toContain("PartialWrite: true"); + expect(failureHuman).toContain(`session/${sessionId} state=running`); + expect(failureHuman).toContain(`run/${runId} status=queued`); + expect(failureHuman).toContain(`command/${commandId} state=pending type=turn`); + expect(failureHuman).not.toContain(hiddenKubernetesPayload); + expect(failureHuman).not.toContain("agentrun send"); + + const failureJsonResult = await runAgentRunCommand(null, [...failureArgs, "-o", "json"]); + const failureYamlResult = await runAgentRunCommand(null, [...failureArgs, "-o", "yaml"]); + const failureJsonText = renderedTextOf(failureJsonResult); + const failureYamlText = renderedTextOf(failureYamlResult); + const failureJson = JSON.parse(failureJsonText) as Record; + const failureYaml = Bun.YAML.parse(failureYamlText) as Record; + expect(failureJsonResult.ok).toBe(false); + expect(failureYamlResult.ok).toBe(false); + expect(failureYaml).toEqual(failureJson); + expect(failureJson).toMatchObject({ + kind: "SessionSendError", + mutation: true, + partialWrite: true, + error: { + failureKind: "validation-failed", + typedFailureKind: "infra-failed", + code: "runner-admission-failed", + reason: "runner-retention-observation-failed", + traceId: "trc_session_send_fixture", + }, + }); + for (const bounded of [planHuman, planJsonText, planYamlText, failureHuman, failureJsonText, failureYamlText]) { + expect(Buffer.byteLength(bounded, "utf8")).toBeLessThan(7000); + expect(bounded).not.toContain(hiddenKubernetesPayload); + expect(bounded).not.toContain("rawKubernetesObject"); + } + expect(postRequests).toBe(6); + } finally { + server.stop(true); + if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG; + else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig; + if (previousKey === undefined) delete process.env.HWLAB_API_KEY; + else process.env.HWLAB_API_KEY = previousKey; + rmSync(tempConfigPath, { force: true }); + } + }); }); describe("AgentRun YAML tool credential binding", () => { diff --git a/scripts/src/agentrun/config.ts b/scripts/src/agentrun/config.ts index 3ca35f63..02ba35d1 100644 --- a/scripts/src/agentrun/config.ts +++ b/scripts/src/agentrun/config.ts @@ -721,7 +721,10 @@ export function renderAgentRunSessionPolicyExplanation(args: string[] = [], opti } export function safeAgentRunEnvelope(envelope: Record): Record { - return pickCompact(envelope, ["ok", "failureKind", "message", "code", "details", "valuesPrinted"]); + const safe = pickCompact(envelope, ["ok", "failureKind", "message", "code", "details", "traceId", "action", "decision", "internalCommandType", "dryRun", "mutation", "partialWrite", "sessionId", "runId", "commandId", "runnerJobId", "runnerAdmission", "recoveryActions", "valuesPrinted"]); + const nestedError = record(envelope.error); + if (Object.keys(nestedError).length > 0) safe.error = pickCompact(nestedError, ["name", "failureKind", "message", "code", "details"]); + return safe; } export function normalizeAgentRunFailureKind(raw: string | null, httpStatus: number): AgentRunFailureKind { diff --git a/scripts/src/agentrun/index.ts b/scripts/src/agentrun/index.ts index e19ddc5d..614e7e94 100644 --- a/scripts/src/agentrun/index.ts +++ b/scripts/src/agentrun/index.ts @@ -11,5 +11,6 @@ export * from "./yaml-lane"; export * from "./secrets"; export * from "./git-mirror"; export * from "./rest-bridge"; +export * from "./session-send-render"; export * from "./config"; export * from "./utils"; diff --git a/scripts/src/agentrun/render.ts b/scripts/src/agentrun/render.ts index 3760cee6..10a34ed4 100644 --- a/scripts/src/agentrun/render.ts +++ b/scripts/src/agentrun/render.ts @@ -38,6 +38,7 @@ import { agentRunDryRunPlan, readAgentRunClientConfig } from "./config"; import { status } from "./control-plane"; import { arrayRecords, displayValue, isRecord, nextPagedResourceCommand, parseTaskManifest, pickCompact, relativeAge, renderFailureLines, renderResourceNextLines, renderTable, resourceName, shortId, stringOrDash, truncateMultiline, truncateOneLine } from "./options"; import { activeAgentRunRestTarget, agentRunRestRequest, runAgentRunRestCommand } from "./rest-bridge"; +import { renderSessionSendResult } from "./session-send-render"; import { record, stringOrNull } from "./utils"; export function resolveAgentRunCancelPolicyTarget(config: UniDeskConfig | null, options: AgentRunResourceOptions): { configPath: string; spec: AgentRunLaneSpec; source: "selected-lane" | "default-lane" } | null { @@ -130,7 +131,7 @@ export async function resourceSessionPromptCommand(config: UniDeskConfig | null, if (ref.kind !== "session") throw new Error("send requires session/"); const sessionArgs = ["send", ref.name, ...stripLeadingResource(args, ref.name)]; const result = await runAgentRunRestCommand(config, "sessions", sessionArgs); - return renderMutationSummary(command, result, options, options.dryRun ? "Session send plan" : "Session send submitted"); + return renderSessionSendResult(command, result, options); } export function renderedCliResult(ok: boolean, command: string, renderedText: string, contentType: RenderedCliResult["contentType"] = "text/plain"): RenderedCliResult { diff --git a/scripts/src/agentrun/resource-actions.ts b/scripts/src/agentrun/resource-actions.ts index 4fa30e40..efd95c9c 100644 --- a/scripts/src/agentrun/resource-actions.ts +++ b/scripts/src/agentrun/resource-actions.ts @@ -40,6 +40,7 @@ import { isHelpArg, renderAgentRunHelp } from "./entry"; import { agentRunExplain, arrayRecords, shortId } from "./options"; import { agentRunCancelAuthorityDisclosure, agentRunCancelCascadeScope, agentRunCancelFencingDisclosure, agentRunCancelRunnerAbortDisclosure, compactAipodSpecDescriptionPayload, compactTaskDescriptionPayload, innerData, normalizeAipodSpecItems, normalizeAttemptResources, normalizeEventItems, normalizeLogItems, normalizeQueueAttemptItems, normalizeRunnerJobItems, normalizeSessionItems, normalizeSingleCommand, normalizeTaskItems, parseResourceKind, parseResourceRef, renderAipodSpecDescription, renderDescribe, renderEventLike, renderGenericDescription, renderMachine, renderMutationSummary, renderQueueAttemptList, renderQueueRetrySummary, renderResourceResult, renderResultSummary, renderTaskDescription, renderedCliResult, requiredContext, rerunWithoutDryRun, resolveAgentRunCancelPolicyTarget, resourceApply, resourceCreate, resourceDispatch, resourceSessionPromptCommand, taskInputDescriptionPayload, taskListState, unwrapTaskDetail } from "./render"; import { AgentRunRestError, renderAgentRunRestError, resolveAgentRunRestTarget, runAgentRunRestCommand, withAgentRunRestTarget } from "./rest-bridge"; +import { renderSessionSendError } from "./session-send-render"; import { runnerJobObservationScript, type RunnerJobObservationIdentity } from "./runner-observation"; import { capture, captureJsonPayload, compactCapture, nonNegativeIntegerOrNull, record, stringOrNull } from "./utils"; @@ -88,7 +89,10 @@ export async function runAgentRunResourceCommand(config: UniDeskConfig | null, v return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`); }); } catch (error) { - if (error instanceof AgentRunRestError) return renderAgentRunRestError(command, error, options); + if (error instanceof AgentRunRestError) { + if (verb === "send") return renderSessionSendError(command, error.toPayload(command), options); + return renderAgentRunRestError(command, error, options); + } return renderedCliResult(false, command, `Error: ${error instanceof Error ? error.message : String(error)}`); } return renderedCliResult(false, command, `Unsupported AgentRun resource command. Try: bun scripts/cli.ts agentrun --help`); diff --git a/scripts/src/agentrun/session-send-render.ts b/scripts/src/agentrun/session-send-render.ts new file mode 100644 index 00000000..fb324ad4 --- /dev/null +++ b/scripts/src/agentrun/session-send-render.ts @@ -0,0 +1,390 @@ +import type { RenderedCliResult } from "../output"; +import type { AgentRunResourceOptions } from "./utils"; +import { record, stringOrNull } from "./utils"; + +type SessionSendProjection = { + kind: "SessionSendPlan" | "SessionSendResult" | "SessionSendError"; + ok: boolean; + action: string; + decision: string | null; + internalCommandType: string | null; + dryRun: boolean; + mutation: boolean | null; + partialWrite: boolean | null; + reuseRun: boolean | null; + createRunner: boolean | null; + resources: Record; + runnerAdmission: Record | null; + request: Record | null; + error: Record | null; + next: { + actions: Array<{ command: string; mutation: boolean; reason: string }>; + note: string | null; + }; + valuesPrinted: false; +}; + +const resourceStateKeys = ["state", "status", "phase", "terminalStatus", "type"] as const; + +export function renderSessionSendResult(command: string, raw: Record, options: AgentRunResourceOptions): RenderedCliResult { + if (options.raw) return renderSessionSendMachine(command, raw, "json", raw.ok !== false); + const projection = sessionSendSuccessProjection(command, raw); + if (options.output === "json" || options.output === "yaml") return renderSessionSendMachine(command, projection, options.output, true); + return renderSessionSendHuman(command, projection); +} + +export function renderSessionSendError(command: string, raw: Record, options: AgentRunResourceOptions): RenderedCliResult { + if (options.raw) return renderSessionSendMachine(command, raw, "json", false); + const projection = sessionSendErrorProjection(raw); + if (options.output === "json" || options.output === "yaml") return renderSessionSendMachine(command, projection, options.output, false); + return renderSessionSendHuman(command, projection); +} + +export function sessionSendSuccessProjection(command: string, raw: Record): SessionSendProjection { + const data = record(innerData(raw)); + const activeBefore = record(data.activeBefore); + const reusedIdleRun = record(data.reusedIdleRun); + const run = record(data.run); + const commandResource = record(data.command); + const runnerJob = record(data.runnerJob); + const dispatchIntent = record(commandResource.dispatchIntent); + const runnerAdmission = compactRunnerAdmission(record(data.runnerAdmission)); + const request = compactRequest(record(data.request)); + const decision = stringOrNull(data.decision); + const dryRun = data.dryRun === true; + const reuseRun = decision === null + ? null + : decision === "steer" || Object.keys(reusedIdleRun).length > 0; + const createRunner = resolveCreateRunner({ decision, reuseRun, request: record(data.request), runnerJob, runnerAdmission: record(data.runnerAdmission) }); + const sessionId = firstString(data.sessionId, record(run.sessionRef).sessionId, activeBefore.sessionId, reusedIdleRun.sessionId); + const runId = firstString(run.id, data.runId, activeBefore.runId, reusedIdleRun.runId, record(data.runnerAdmission).runId); + const commandId = firstString(commandResource.id, data.commandId, activeBefore.commandId, reusedIdleRun.commandId, record(data.runnerAdmission).commandId); + const runnerJobId = firstString(runnerJob.id, runnerJob.runnerJobId, data.runnerJobId, record(data.runnerAdmission).plannedRunnerJobId, dispatchIntent.runnerJobId); + const dispatchIntentId = firstString(dispatchIntent.id, record(data.runnerAdmission).dispatchIntentId); + const mutation = booleanOrNull(data.mutation) ?? booleanOrNull(raw.mutation); + const partialWrite = booleanOrNull(record(data.runnerAdmission).partialWrite) ?? false; + const nextActions = dryRun + ? [{ + command: sessionSendConfirmCommand(command, sessionId), + mutation: true, + reason: "复用同一 prompt stdin,仅在确认计划后提交一次。", + }] + : readOnlyNextActions({ sessionId, runId, commandId, runnerJobId }, data.pollActions); + return { + kind: dryRun ? "SessionSendPlan" : "SessionSendResult", + ok: raw.ok !== false, + action: firstString(data.action, raw.action) ?? (dryRun ? "session-send-plan" : "session-send"), + decision, + internalCommandType: stringOrNull(data.internalCommandType), + dryRun, + mutation, + partialWrite, + reuseRun, + createRunner, + resources: compactResources({ + session: resourceIdentity("session", sessionId, record(data.session)), + run: resourceIdentity("run", runId, run), + command: resourceIdentity("command", commandId, commandResource), + runnerJob: resourceIdentity("runnerjob", runnerJobId, runnerJob), + dispatchIntent: resourceIdentity("dispatch-intent", dispatchIntentId, dispatchIntent), + }), + runnerAdmission, + request, + error: null, + next: { + actions: nextActions, + note: dryRun + ? "dry-run 未写入 durable facts;移除 --dry-run 会由 manager 再次按 session durable state 决定 steer 或 turn。" + : "只使用 manager 返回的资源 identity 做只读下钻;不要在客户端补做 runner 调度或状态修复。", + }, + valuesPrinted: false, + }; +} + +export function sessionSendErrorProjection(raw: Record): SessionSendProjection { + const agentrun = record(raw.agentrun); + const nestedError = record(agentrun.error); + const rootDetails = record(agentrun.details); + const nestedDetails = record(nestedError.details); + const details = { ...rootDetails, ...nestedDetails }; + const mutationSummary = record(details.mutationSummary); + const partialWriteSummary = record(details.partialWrite); + const resources = record(details.resources); + const resourceIdentities = record(details.resourceIdentities); + const session = firstRecord(record(resources.session), record(resourceIdentities.session), record(details.session)); + const run = firstRecord(record(resources.run), record(resourceIdentities.run), record(details.run)); + const commandResource = firstRecord(record(resources.command), record(resourceIdentities.command), record(details.command)); + const runnerJob = firstRecord(record(resources.runnerJob), record(resourceIdentities.runnerJob), record(details.runnerJob)); + const dispatchIntent = firstRecord(record(resources.dispatchIntent), record(resourceIdentities.dispatchIntent), record(commandResource.dispatchIntent), record(details.dispatchIntent)); + const runnerAdmission = compactRunnerAdmission(firstRecord(record(details.runnerAdmission), record(agentrun.runnerAdmission))); + const sessionId = firstString(details.sessionId, session.id, session.sessionId, record(runnerAdmission).sessionId); + const runId = firstString(details.runId, run.id, run.runId, record(runnerAdmission).runId); + const commandId = firstString(details.commandId, commandResource.id, commandResource.commandId, record(runnerAdmission).commandId); + const runnerJobId = firstString(details.runnerJobId, runnerJob.id, runnerJob.runnerJobId, record(runnerAdmission).plannedRunnerJobId, dispatchIntent.runnerJobId); + const dispatchIntentId = firstString(details.dispatchIntentId, dispatchIntent.id, record(runnerAdmission).dispatchIntentId); + const typedFailureKind = firstString(nestedError.failureKind, details.failureKind, agentrun.failureKind); + const code = firstString(details.code, nestedError.code, agentrun.code); + const reason = firstString(details.reason, details.degradedReason, details.changeReason, record(runnerAdmission).reason); + const mutation = firstBoolean(details.mutation, mutationSummary.mutation, mutationSummary.occurred, record(runnerAdmission).mutation); + const partialWrite = firstBoolean( + typeof details.partialWrite === "boolean" ? details.partialWrite : null, + partialWriteSummary.occurred, + partialWriteSummary.value, + mutationSummary.partialWrite, + record(runnerAdmission).partialWrite, + ); + const recoveryActions = Array.isArray(details.recoveryActions) + ? details.recoveryActions + : Array.isArray(agentrun.recoveryActions) + ? agentrun.recoveryActions + : []; + const nextActions = readOnlyNextActions({ sessionId, runId, commandId, runnerJobId }, recoveryActions); + const message = truncateOneLine(firstString(raw.message, nestedError.message, agentrun.message) ?? "AgentRun session send failed", 400); + return { + kind: "SessionSendError", + ok: false, + action: firstString(details.action, agentrun.action) ?? "session-send", + decision: firstString(details.decision, agentrun.decision), + internalCommandType: firstString(details.internalCommandType, agentrun.internalCommandType), + dryRun: firstBoolean(details.dryRun, agentrun.dryRun) ?? false, + mutation, + partialWrite, + reuseRun: firstBoolean(details.reuseRun, details.reusedRun), + createRunner: firstBoolean(details.createRunner, details.createRunnerJob, record(runnerAdmission).state === "not-requested" ? false : null), + resources: compactResources({ + session: resourceIdentity("session", sessionId, session), + run: resourceIdentity("run", runId, run), + command: resourceIdentity("command", commandId, commandResource), + runnerJob: resourceIdentity("runnerjob", runnerJobId, runnerJob), + dispatchIntent: resourceIdentity("dispatch-intent", dispatchIntentId, dispatchIntent), + }), + runnerAdmission, + request: null, + error: { + failureKind: stringOrNull(raw.failureKind), + typedFailureKind, + code, + reason, + message, + httpStatus: numberOrNull(raw.httpStatus), + traceId: firstString(details.traceId, agentrun.traceId), + phase: firstString(details.phase, details.stage), + durable: firstBoolean(details.durable, record(runnerAdmission).durable), + valuesPrinted: false, + }, + next: { + actions: nextActions, + note: partialWrite === true + ? "manager 已报告 partial write;先按资源 identity 只读核对 durable state,禁止盲目重发、客户端清理或另建 run。" + : "先按资源 identity 核对 manager durable state;只有 manager 明确 fail-closed 或给出可重试合同后才能重发。", + }, + valuesPrinted: false, + }; +} + +function renderSessionSendHuman(command: string, projection: SessionSendProjection): RenderedCliResult { + const lines: string[] = []; + if (projection.kind === "SessionSendError") { + const error = record(projection.error); + lines.push("Session send failed"); + lines.push(`Error: ${display(error.failureKind)}`); + if (error.typedFailureKind !== null) lines.push(`TypedFailure: ${display(error.typedFailureKind)}`); + if (error.code !== null) lines.push(`Code: ${display(error.code)}`); + if (error.reason !== null) lines.push(`Reason: ${display(error.reason)}`); + lines.push(`Message: ${display(error.message)}`); + if (error.httpStatus !== null) lines.push(`HTTP: ${display(error.httpStatus)}`); + } else { + lines.push(projection.kind === "SessionSendPlan" ? "Session send plan" : "Session send submitted"); + lines.push(`OK: ${String(projection.ok)}`); + } + lines.push(`DryRun: ${String(projection.dryRun)}`); + lines.push(`Decision: ${display(projection.decision)}`); + lines.push(`CommandType: ${display(projection.internalCommandType)}`); + lines.push(`ReuseRun: ${display(projection.reuseRun)}`); + lines.push(`CreateRunner: ${display(projection.createRunner)}`); + lines.push(`Mutation: ${display(projection.mutation)}`); + lines.push(`PartialWrite: ${display(projection.partialWrite)}`); + const resources = Object.values(projection.resources).filter((value) => value !== null).map(record); + if (resources.length > 0) { + lines.push("", "Resources:"); + for (const resource of resources) { + const states = resourceStateKeys + .map((key) => resource[key] === null || resource[key] === undefined ? null : `${key}=${display(resource[key])}`) + .filter((value): value is string => value !== null); + lines.push(` ${display(resource.kind)}/${display(resource.id)}${states.length === 0 ? "" : ` ${states.join(" ")}`}`); + } + } + if (projection.runnerAdmission !== null) { + const admission = projection.runnerAdmission; + lines.push("", "RunnerAdmission:"); + lines.push(` mode=${display(admission.mode)} state=${display(admission.state)} reason=${display(admission.reason)} durable=${display(admission.durable)}`); + } + if (projection.next.actions.length > 0 || projection.next.note !== null) { + lines.push("", "Next:"); + for (const action of projection.next.actions) lines.push(` ${action.command}`); + if (projection.next.note !== null) lines.push(` Note: ${projection.next.note}`); + } + return { ok: projection.ok, command, renderedText: lines.join("\n"), contentType: "text/plain" }; +} + +function compactResources(resources: Record | null>): Record { + return { + session: resources.session, + run: resources.run, + command: resources.command, + runnerJob: resources.runnerJob, + dispatchIntent: resources.dispatchIntent, + }; +} + +function resourceIdentity(kind: string, id: string | null, value: Record): Record | null { + if (id === null) return null; + const identity: Record = { kind, id }; + for (const key of resourceStateKeys) { + const field = value[key]; + if (typeof field === "string" || typeof field === "number" || typeof field === "boolean") identity[key] = field; + } + if (kind === "dispatch-intent") { + for (const key of ["runnerJobId", "dispatchOutcome", "actualRunnerJobId", "activeRunnerId", "attemptCount", "durable"] as const) { + const field = value[key]; + if (typeof field === "string" || typeof field === "number" || typeof field === "boolean" || field === null) identity[key] = field; + } + } + return identity; +} + +function compactRunnerAdmission(value: Record): Record | null { + if (Object.keys(value).length === 0) return null; + const result: Record = {}; + for (const key of ["mode", "state", "reason", "failureKind", "sessionId", "runId", "commandId", "dispatchIntentId", "plannedRunnerJobId", "mutation", "partialWrite", "durable"] as const) { + const field = value[key]; + if (typeof field === "string" || typeof field === "number" || typeof field === "boolean" || field === null) result[key] = typeof field === "string" ? truncateOneLine(field, 240) : field; + } + result.valuesPrinted = false; + return result; +} + +function compactRequest(value: Record): Record | null { + if (Object.keys(value).length === 0) return null; + const result: Record = {}; + for (const key of ["method", "path", "commandType", "createRunnerJob", "reusedIdleRun"] as const) { + const field = value[key]; + if (typeof field === "string" || typeof field === "boolean" || field === null) result[key] = typeof field === "string" ? truncateOneLine(field, 240) : field; + } + result.valuesPrinted = false; + return result; +} + +function resolveCreateRunner(input: { decision: string | null; reuseRun: boolean | null; request: Record; runnerJob: Record; runnerAdmission: Record }): boolean | null { + if (input.decision === "steer" || input.reuseRun === true) return false; + const requested = booleanOrNull(input.request.createRunnerJob); + if (requested !== null) return requested; + const admissionState = stringOrNull(input.runnerAdmission.state); + if (admissionState === "not-requested") return false; + if (admissionState !== null) return true; + if (Object.keys(input.runnerJob).length > 0) return true; + return input.decision === null ? null : false; +} + +function readOnlyNextActions(ids: { sessionId: string | null; runId: string | null; commandId: string | null; runnerJobId: string | null }, rawActions: unknown): Array<{ command: string; mutation: boolean; reason: string }> { + const commands: string[] = []; + const add = (command: string | null): void => { + if (command !== null && !commands.includes(command)) commands.push(command); + }; + for (const rawAction of Array.isArray(rawActions) ? rawActions.slice(0, 12) : []) { + if (typeof rawAction === "string") { + if (isSafeReadCommand(rawAction)) add(rawAction); + continue; + } + const action = record(rawAction); + const operation = stringOrNull(action.operation); + const resourceKind = stringOrNull(action.resourceKind); + const resourceName = stringOrNull(action.resourceName); + if (operation === "describe" && resourceKind !== null && resourceName !== null) add(`bun scripts/cli.ts agentrun describe ${resourceKind}/${resourceName}`); + if (operation === "events") { + const runId = firstString(action.runId, resourceKind === "run" ? resourceName : null); + if (runId !== null) add(`bun scripts/cli.ts agentrun events run/${runId} --after-seq ${nonNegativeInteger(action.afterSeq) ?? 0} --limit ${positiveInteger(action.limit) ?? 100}`); + } + if (operation === "logs") { + const sessionId = firstString(action.sessionId, resourceKind === "session" ? resourceName : null); + if (sessionId !== null) add(`bun scripts/cli.ts agentrun logs session/${sessionId} --tail ${positiveInteger(action.limit) ?? 100}`); + } + } + add(ids.sessionId === null ? null : `bun scripts/cli.ts agentrun describe session/${ids.sessionId}`); + add(ids.runId === null ? null : `bun scripts/cli.ts agentrun events run/${ids.runId} --after-seq 0 --limit 100`); + add(ids.runId === null || ids.commandId === null ? null : `bun scripts/cli.ts agentrun describe command/${ids.commandId} --run ${ids.runId}`); + add(ids.runId === null || ids.runnerJobId === null ? null : `bun scripts/cli.ts agentrun describe runnerjob/${ids.runnerJobId} --run ${ids.runId}`); + return commands.slice(0, 5).map((value) => ({ command: value, mutation: false, reason: "只读核对 manager durable facts。" })); +} + +function isSafeReadCommand(value: string): boolean { + return /^bun scripts\/cli\.ts agentrun (?:get|describe|events|logs|result)\b/u.test(value.trim()); +} + +function sessionSendConfirmCommand(command: string, sessionId: string | null): string { + const canonical = command + .replace(/\s+--dry-run\b/gu, "") + .replace(/\s+(?:-o|--output)\s+(?:json|yaml|name|wide)\b/gu, "") + .replace(/\s+(?:-o|--output)=(?:json|yaml|name|wide)\b/gu, "") + .replace(/\s+--(?:raw|full)\b/gu, "") + .trim(); + if (/(?:^|\s)--prompt-stdin(?:\s|$)/u.test(canonical)) return `bun scripts/cli.ts ${canonical}`; + return `bun scripts/cli.ts agentrun send session/${sessionId ?? ""} --prompt-stdin`; +} + +function innerData(value: Record): unknown { + const data = value.data; + if (typeof data === "object" && data !== null && !Array.isArray(data)) return data; + return value; +} + +function firstRecord(...values: Record[]): Record { + return values.find((value) => Object.keys(value).length > 0) ?? {}; +} + +function firstString(...values: unknown[]): string | null { + for (const value of values) { + const text = stringOrNull(value); + if (text !== null) return text; + } + return null; +} + +function firstBoolean(...values: unknown[]): boolean | null { + for (const value of values) { + const boolean = booleanOrNull(value); + if (boolean !== null) return boolean; + } + return null; +} + +function booleanOrNull(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function nonNegativeInteger(value: unknown): number | null { + return Number.isInteger(value) && Number(value) >= 0 ? Number(value) : null; +} + +function positiveInteger(value: unknown): number | null { + return Number.isInteger(value) && Number(value) > 0 ? Number(value) : null; +} + +function truncateOneLine(value: string, max: number): string { + const normalized = value.replace(/\s+/gu, " ").trim(); + return normalized.length <= max ? normalized : `${normalized.slice(0, Math.max(0, max - 1))}…`; +} + +function display(value: unknown): string { + if (value === null || value === undefined || value === "") return "-"; + return truncateOneLine(String(value), 400); +} + +function renderSessionSendMachine(command: string, value: unknown, mode: "json" | "yaml", ok: boolean): RenderedCliResult { + const renderedText = mode === "json" ? `${JSON.stringify(value, null, 2)}\n` : `${Bun.YAML.stringify(value)}\n`; + return { ok, command, renderedText, contentType: mode === "json" ? "application/json" : "application/yaml" }; +}