diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index fdfd19aa..584a4a23 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -11,18 +11,19 @@ description: UniDesk AgentRun-backed Code Queue CLI — Skill(cli-spec)。legacy ```bash bun scripts/cli.ts agentrun get tasks --limit 20 -bun scripts/cli.ts agentrun describe task/ +bun scripts/cli.ts agentrun describe "修复 HWLAB 的 cicd" bun scripts/cli.ts agentrun describe task/ --input -o json bun scripts/cli.ts agentrun describe aipodspec/ -bun scripts/cli.ts agentrun events run/ --after-seq 0 --expect 10 --timeout 120s --limit 20 +bun scripts/cli.ts agentrun events "修复 HWLAB 的 cicd" --after-seq 0 --expect 10 --timeout 120s --limit 20 bun scripts/cli.ts agentrun events run/ --detail-seq -o json -bun scripts/cli.ts agentrun logs session/ --tail 120 -bun scripts/cli.ts agentrun result run/ --command +bun scripts/cli.ts agentrun logs "修复 HWLAB 的 cicd" --tail 120 +bun scripts/cli.ts agentrun result "修复 HWLAB 的 cicd" bun scripts/cli.ts agentrun get attempts --task bun scripts/cli.ts agentrun retry task/ --idempotency-key --reason --dry-run bun scripts/cli.ts agentrun send --prompt-stdin bun scripts/cli.ts agentrun create task \ --aipod Artificer \ + --title "修复 HWLAB 的 cicd" \ --target \ --target-workspace \ --repo \ @@ -43,6 +44,11 @@ AipodSpec/Artificer、task manifest 和 queue 渐进披露见 [references/resour ## P0 边界 +- Artificer `create task` 必须显式提供唯一中文短标题: + - 标题为单行 2 到 80 字符,并至少包含一个中文汉字; + - `HWLAB`、`CI/CD` 等专有名和空格可以保留; + - `describe/events/logs/result` 优先使用标题的完全匹配查询; + - 标题重复时 CLI 必须报歧义,不得猜测;内部 ID 仅用于深度下钻和机器身份。 - AgentRun 与调用方之间的契约版本、源码/镜像版本和 `commitId` 对齐只能产生非阻塞 warning;可安全归一化的请求继续 admission/dispatch,不得因版本或契约漂移阻止用户任务、session、run、command 或 runner。 - 新写入口不要用 `codex submit/steer/resume`;这些 legacy mutation 已冻结。 - Artificer 的 runner primary workspace 与任务 Target 必须分开判定: diff --git a/.agents/skills/unidesk-code-queue/references/resources.md b/.agents/skills/unidesk-code-queue/references/resources.md index 3b6c0f99..de4edf67 100644 --- a/.agents/skills/unidesk-code-queue/references/resources.md +++ b/.agents/skills/unidesk-code-queue/references/resources.md @@ -18,6 +18,7 @@ ```bash bun scripts/cli.ts agentrun create task \ --aipod Artificer \ + --title "修复 HWLAB 的 cicd" \ --target \ --target-workspace \ --repo \ @@ -26,6 +27,10 @@ bun scripts/cli.ts agentrun create task \ --prompt-stdin ``` +- `--title` 为必填中文短标题,允许英文专有名和空格; +- 后续 `describe/events/logs/result` 直接传入标题字符串; +- 标题按规范化后的完全匹配解析,重复标题必须改用内部 ID 处理歧义。 + - 默认直接执行 live 单命令;不要把 `--dry-run` 作为派单前置步骤; - live 必须先经 owning YAML 选中的 Target `trans` 重入并完成只读 preflight; - preflight 必须验证: diff --git a/.agents/skills/unidesk-subagent/SKILL.md b/.agents/skills/unidesk-subagent/SKILL.md index 3a3e9644..6f7e822b 100644 --- a/.agents/skills/unidesk-subagent/SKILL.md +++ b/.agents/skills/unidesk-subagent/SKILL.md @@ -38,6 +38,8 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 审查证据只保留 issue/PR/commit、验证摘要和必要下钻链接,不复制无界日志或构造额外实证体系; - 只有直接保护真实业务资源、不可逆操作或明确损害风险时才允许最小 guard,且只覆盖实际风险窗口并在风险解除后立即释放; - Artificer 的 `create task` 外层入口: + - 必须使用与子 issue / MDTODO 语义一致的唯一中文短标题; + - 标题至少包含一个中文汉字,专有名和空格可以保留; - 必须按 owning YAML 选中 Target; - 必须通过 `trans :` 重入目标节点; - 预检、创建、自动派发和观察都在重入后完成; diff --git a/docs/reference/agentrun.md b/docs/reference/agentrun.md index 746fe5af..a72192b7 100644 --- a/docs/reference/agentrun.md +++ b/docs/reference/agentrun.md @@ -254,6 +254,7 @@ UniDesk 指挥官新任务入口固定使用 `bun scripts/cli.ts agentrun get|de ```bash bun scripts/cli.ts agentrun create task \ --aipod Artificer \ + --title "修复 HWLAB 的 cicd" \ --target \ --target-workspace \ --repo \ @@ -262,6 +263,14 @@ bun scripts/cli.ts agentrun create task \ --prompt-stdin ``` +Artificer 标题契约: + +- `create task` 必须显式提供单行中文短标题; +- 标题长度为 2 到 80 字符,至少包含一个中文汉字; +- `HWLAB`、`CI/CD` 等专有名和空格可以保留; +- `describe/events/logs/result` 使用标题完全匹配并解析最新执行资源; +- 标题重复时返回歧义错误,内部 ID 继续作为不可变机器身份。 + - 默认直接执行 live 单命令,不把 `--dry-run` 作为派单前置步骤; - live 先经 Target `trans` 重入并执行 source/worktree/Aipod/SecretRef 只读预检; - 预检通过后在同一命令内 submit 并自动 dispatch; diff --git a/scripts/src/agentrun-session-send-render.test.ts b/scripts/src/agentrun-session-send-render.test.ts index bba2e39f..d7e3b712 100644 --- a/scripts/src/agentrun-session-send-render.test.ts +++ b/scripts/src/agentrun-session-send-render.test.ts @@ -259,6 +259,7 @@ function options(output: AgentRunOutputMode): AgentRunResourceOptions { dryRun: false, file: null, aipod: null, + title: null, idempotencyKey: null, promptStdin: false, node: null, diff --git a/scripts/src/agentrun.test.ts b/scripts/src/agentrun.test.ts index 0ac6752f..caa6859c 100644 --- a/scripts/src/agentrun.test.ts +++ b/scripts/src/agentrun.test.ts @@ -221,6 +221,7 @@ describe("AgentRun render-only REST client config", () => { describe("Artificer Target one-command dispatch", () => { test("requires trans re-entry before Target filesystem preflight and derives a stable idempotency key", () => { const request = targetTaskRequestFromArgs([ + "--title", "修复 SelfMedia 的 OCR 规划器", "--target", "NC01", "--target-workspace", "/root/.worktrees/selfmedia/r6-ocr-visual-planner", "--repo", "pikainc/selfmedia", @@ -269,6 +270,7 @@ describe("Artificer Target one-command dispatch", () => { test("scopes post-task create replay to one complete logical operation", () => { const base = { + title: "修复 UniDesk 的 Artificer 派单", target: "NC01", targetRoute: "NC01:/root/unidesk/.worktree/base", projectId: "pikasTech/unidesk", @@ -1642,6 +1644,7 @@ describe("AgentRun YAML tool credential binding", () => { test("preserves explicit credential subsets across Aipod admission and rejects unknown identities or conflicts", async () => { const renderBodies: Record[] = []; const submittedBodies: Record[] = []; + const taskTitle = "验证 Artificer 工具凭据"; let credentialMode: "all" | "subset" | "conflict" | "unknown" = "all"; const renderedToolCredentials = (): Record[] => { const credentials = [ @@ -1679,6 +1682,9 @@ describe("AgentRun YAML tool credential binding", () => { return credentials; }; const renderedTask = () => ({ + title: taskTitle, + metadata: { title: taskTitle }, + sessionRef: { metadata: { title: taskTitle } }, tenantId: "unidesk", projectId: "pikasTech/unidesk", queue: "commander", @@ -1726,7 +1732,7 @@ describe("AgentRun YAML tool credential binding", () => { process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath; await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, ""))); - const accepted = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "--model", "gpt-explicit", "--reasoning-effort", "high", "-o", "json"]); + const accepted = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--title", taskTitle, "--prompt", "fixture", "--model", "gpt-explicit", "--reasoning-effort", "high", "-o", "json"]); expect(accepted.ok).toBe(true); expect(renderBodies).toHaveLength(1); expect(submittedBodies).toHaveLength(1); @@ -1737,20 +1743,20 @@ describe("AgentRun YAML tool credential binding", () => { expect(submittedBodies[0]).toEqual(renderedTask()); credentialMode = "subset"; - const subsetAccepted = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]); + const subsetAccepted = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--title", taskTitle, "--prompt", "fixture", "-o", "json"]); expect(subsetAccepted.ok).toBe(true); expect(renderBodies).toHaveLength(2); expect(submittedBodies).toHaveLength(2); expect(submittedBodies[1]).toEqual(renderedTask()); credentialMode = "conflict"; - const rejected = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]); + const rejected = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--title", taskTitle, "--prompt", "fixture", "-o", "json"]); expect(rejected.ok).toBe(false); expect(submittedBodies).toHaveLength(2); expect(renderedTextOf(rejected)).toContain("github/github-ssh keys conflict"); credentialMode = "unknown"; - const unknownRejected = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]); + const unknownRejected = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--title", taskTitle, "--prompt", "fixture", "-o", "json"]); expect(unknownRejected.ok).toBe(false); expect(submittedBodies).toHaveLength(2); expect(renderedTextOf(unknownRejected)).toContain("artifact-store/publish is not declared by YAML lane"); diff --git a/scripts/src/agentrun/entry.ts b/scripts/src/agentrun/entry.ts index 71f2f599..758488b5 100644 --- a/scripts/src/agentrun/entry.ts +++ b/scripts/src/agentrun/entry.ts @@ -57,19 +57,19 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun describe run/ --node NC01 --lane nc01-v02", "bun scripts/cli.ts agentrun get tasks -o wide", "bun scripts/cli.ts agentrun get tasks -o json", - "bun scripts/cli.ts agentrun describe task/", + "bun scripts/cli.ts agentrun describe \"修复 HWLAB 的 cicd\"", "bun scripts/cli.ts agentrun describe task/ --input -o json", - "bun scripts/cli.ts agentrun events run/ --after-seq 0 --expect 10 --timeout 120s --limit 100", + "bun scripts/cli.ts agentrun events \"修复 HWLAB 的 cicd\" --after-seq 0 --expect 10 --timeout 120s --limit 100", "bun scripts/cli.ts agentrun events run/ --detail-seq ", - "bun scripts/cli.ts agentrun logs session/ --tail 100", - "bun scripts/cli.ts agentrun result run/ --command ", + "bun scripts/cli.ts agentrun logs \"修复 HWLAB 的 cicd\" --tail 100", + "bun scripts/cli.ts agentrun result \"修复 HWLAB 的 cicd\"", "bun scripts/cli.ts agentrun ack task/ --reader-id cli", "bun scripts/cli.ts agentrun cancel task/ --reason --dry-run", "bun scripts/cli.ts agentrun retry task/ --idempotency-key --reason --dry-run", "bun scripts/cli.ts agentrun get attempts --task --limit 20", "bun scripts/cli.ts agentrun dispatch task/", - "bun scripts/cli.ts agentrun create task --aipod Artificer --prompt-stdin --idempotency-key ", - "bun scripts/cli.ts agentrun create task --aipod Artificer --target --target-workspace --repo --ref --mdtodo-id --prompt-stdin [--dry-run]", + "bun scripts/cli.ts agentrun create task --aipod Artificer --title \"修复 HWLAB 的 cicd\" --prompt-stdin --idempotency-key ", + "bun scripts/cli.ts agentrun create task --aipod Artificer --title \"修复 HWLAB 的 cicd\" --target --target-workspace --repo --ref --mdtodo-id --prompt-stdin [--dry-run]", "bun scripts/cli.ts agentrun apply -f - --dry-run", "bun scripts/cli.ts agentrun send session/ --aipod Artificer --prompt-stdin", "bun scripts/cli.ts agentrun explain task", @@ -318,7 +318,7 @@ export function agentRunHelpText(args: string[]): string { return "Usage: bun scripts/cli.ts agentrun dispatch task/"; } if (verb === "create") { - return "Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --target --target-workspace --repo --ref --mdtodo-id --prompt-stdin [--idempotency-key ] [--dry-run]"; + return "Usage: bun scripts/cli.ts agentrun create task --aipod Artificer --title <中文短标题> --target --target-workspace --repo --ref --mdtodo-id --prompt-stdin [--idempotency-key ] [--dry-run]"; } if (verb === "apply") { return "Usage: bun scripts/cli.ts agentrun apply -f task.yaml|json|- [--dry-run]\nTask manifests use kind: Task and spec: ."; @@ -413,7 +413,7 @@ export function agentRunHelpText(args: string[]): string { " bun scripts/cli.ts agentrun logs session/ --tail 100", " bun scripts/cli.ts agentrun retry task/ --idempotency-key --reason --dry-run", " bun scripts/cli.ts agentrun get attempts --task --limit 20", - " bun scripts/cli.ts agentrun create task --aipod Artificer --target --target-workspace --repo --ref --mdtodo-id --prompt-stdin", + " bun scripts/cli.ts agentrun create task --aipod Artificer --title \"修复 HWLAB 的 cicd\" --target --target-workspace --repo --ref --mdtodo-id --prompt-stdin", "", "Machine/debug output:", " -o json|yaml emits a stable UniDesk resource object without the global JSON envelope.", diff --git a/scripts/src/agentrun/render.ts b/scripts/src/agentrun/render.ts index 98a174d9..5d1816fd 100644 --- a/scripts/src/agentrun/render.ts +++ b/scripts/src/agentrun/render.ts @@ -528,6 +528,7 @@ export function renderTargetTaskMutationLines(targetTask: Record"); } + if (verb === "create" && parseResourceKind(action) === "task" && options.aipod === "Artificer") { + validateChineseTaskTitle(options.title); + } } function resourceErrorOutputOptions(args: string[]): AgentRunResourceOptions { @@ -187,6 +191,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { dryRun: false, file: null, aipod: null, + title: null, idempotencyKey: null, promptStdin: false, node: null, @@ -198,7 +203,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions { mdtodoId: null, passthroughArgs: [], }; - const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--expect", "--timeout", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane", "--target", "--target-workspace", "--repo", "--ref", "--mdtodo-id"]); + const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--expect", "--timeout", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--title", "--idempotency-key", "--node", "--lane", "--target", "--target-workspace", "--repo", "--ref", "--mdtodo-id"]); const booleanFlags = new Set(["--full", "--raw", "--debug", "--input", "--unread", "--dry-run", "--full-text", "--prompt-stdin", "--stdin"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; @@ -341,6 +346,7 @@ export function applyResourceOption(options: AgentRunResourceOptions, flag: stri else if (flag === "--reason") options.reason = requiredValue(value, flag); else if (flag === "-f" || flag === "--file" || flag === "--filename") options.file = requiredValue(value, flag); else if (flag === "--aipod") options.aipod = requiredValue(value, flag); + else if (flag === "--title") options.title = requiredValue(value, flag); else if (flag === "--idempotency-key") options.idempotencyKey = requiredValue(value, flag); else if (flag === "--node") options.node = requiredValue(value, flag); else if (flag === "--lane") options.lane = requiredValue(value, flag); @@ -458,7 +464,8 @@ export async function resourceRetry(config: UniDeskConfig | null, command: strin } export async function resourceDescribe(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { - const ref = parseResourceRef(action, args); + const titleContext = await resolveTaskTitleContext(config, action); + const ref = titleContext === null ? parseResourceRef(action, args) : { kind: "task", name: titleContext.taskId } as const; if (options.taskInput && ref.kind !== "task") throw new AgentRunRestError("validation-failed", "describe --input supports task/ only"); if (ref.kind === "task") { const result = await runAgentRunRestCommand(config, "queue", ["show", ref.name, ...(options.full ? ["--full"] : [])]); @@ -619,8 +626,11 @@ function runnerJobObservationIdentity(managerResource: Record, } export async function resourceEvents(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { - const ref = parseResourceRef(action, args, "run"); - const runId = ref.kind === "run" ? ref.name : options.runId ?? requiredContext("events", "--run "); + const titleContext = await resolveTaskTitleContext(config, action); + const ref = titleContext === null ? parseResourceRef(action, args, "run") : null; + const runId = titleContext === null + ? ref?.kind === "run" ? ref.name : options.runId ?? requiredContext("events", "--run ") + : requiredTitleResource(titleContext, "runId", "events"); if (options.eventDetailSeq !== null) { const detailSeq = options.eventDetailSeq; const result = await runAgentRunRestCommand(config, "runs", ["events", runId, "--after-seq", String(detailSeq - 1), "--limit", "1"]); @@ -699,8 +709,11 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri } export async function resourceLogs(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { - const ref = parseResourceRef(action, args, "session"); - if (ref.kind !== "session") throw new Error("logs currently requires session/"); + const titleContext = await resolveTaskTitleContext(config, action); + const ref = titleContext === null + ? parseResourceRef(action, args, "session") + : { kind: "session", name: requiredTitleResource(titleContext, "sessionId", "logs") } as const; + if (ref.kind !== "session") throw new Error("logs currently requires session/ or an exact Chinese task title"); const effectiveLimit = options.tail ?? options.limit; const logArgs = ["output", ref.name, "--limit", String(effectiveLimit)]; if (options.afterSeq !== null) logArgs.push("--after-seq", String(options.afterSeq)); @@ -753,6 +766,13 @@ export async function resourceLogsTailResult(config: UniDeskConfig | null, sessi } export async function resourceResult(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { + const titleContext = await resolveTaskTitleContext(config, action); + if (titleContext !== null) { + const runId = requiredTitleResource(titleContext, "runId", "result"); + const commandId = requiredTitleResource(titleContext, "commandId", "result"); + const result = await runAgentRunRestCommand(config, "runs", ["result", runId, "--command-id", commandId]); + return renderResultSummary(command, result, options, { kind: "run", name: runId }); + } const ref = parseResourceRef(action, args); if (ref.kind === "run") { const commandId = options.commandId ?? requiredContext("run result", "--command "); @@ -767,6 +787,56 @@ export async function resourceResult(config: UniDeskConfig | null, command: stri throw new Error("result supports run/ or command/"); } +interface AgentRunTaskTitleContext { + title: string; + taskId: string; + runId: string | null; + commandId: string | null; + sessionId: string | null; +} + +async function resolveTaskTitleContext(config: UniDeskConfig | null, action: string | undefined): Promise { + if (!isChineseTaskTitle(action)) return null; + const title = validateChineseTaskTitle(action); + const result = await runAgentRunRestCommand(config, "queue", ["list", "--limit", "500"]); + const tasks = arrayRecords(record(innerData(result)).items ?? innerData(result)); + const matches = tasks.filter((task) => normalizeStoredTaskTitle(task) === title); + if (matches.length === 0) { + throw new AgentRunRestError("not-found", `未找到中文标题完全匹配“${title}”的 Artificer 任务。`); + } + if (matches.length > 1) { + throw new AgentRunRestError("validation-failed", `中文标题“${title}”匹配到多个任务,不能猜测。`, { + details: { + title, + candidates: matches.map((task) => ({ id: stringOrNull(task.id), state: stringOrNull(task.state ?? task.status) })), + valuesPrinted: false, + }, + }); + } + const task = matches[0] ?? {}; + const attempt = record(task.latestAttempt); + const taskId = stringOrNull(task.id); + if (taskId === null) throw new AgentRunRestError("schema-mismatch", `中文标题“${title}”匹配的任务缺少 id。`); + return { + title, + taskId, + runId: stringOrNull(attempt.runId ?? task.runId), + commandId: stringOrNull(attempt.commandId ?? task.commandId), + sessionId: stringOrNull(attempt.sessionId ?? record(task.sessionRef).sessionId ?? task.sessionId), + }; +} + +function normalizeStoredTaskTitle(task: Record): string | null { + const title = stringOrNull(task.title ?? record(task.metadata).title ?? record(record(task.sessionRef).metadata).title); + return title === null ? null : title.trim().replace(/\s+/gu, " "); +} + +function requiredTitleResource(context: AgentRunTaskTitleContext, key: "runId" | "commandId" | "sessionId", operation: string): string { + const value = context[key]; + if (value !== null) return value; + throw new AgentRunRestError("not-found", `中文标题“${context.title}”对应任务尚无可用于 ${operation} 的 ${key}。`); +} + export async function resourceAck(config: UniDeskConfig | null, command: string, action: string | undefined, args: string[], options: AgentRunResourceOptions): Promise { const ref = parseResourceRef(action, args, "task"); const result = ref.kind === "task" diff --git a/scripts/src/agentrun/rest-bridge.ts b/scripts/src/agentrun/rest-bridge.ts index b946fdef..6231a02f 100644 --- a/scripts/src/agentrun/rest-bridge.ts +++ b/scripts/src/agentrun/rest-bridge.ts @@ -50,6 +50,7 @@ import { targetTaskDisclosure, targetTaskPrompt, targetTaskRequestFromArgs, + validateChineseTaskTitle, type AgentRunTargetTaskPreflight, } from "./target-task"; import { capture, captureJsonPayload, compactCapture, nonNegativeIntegerOrNull, progressEvent, record, shQuote, sleep, stringOrNull } from "./utils"; @@ -599,8 +600,10 @@ export async function runAgentRunAipodSpecsRest(action: string | undefined, id: export async function submitQueueTaskRest(args: string[]): Promise> { const aipod = agentRunOption(args, "aipod") ?? agentRunOption(args, "aipod-spec"); if (aipod) { + const taskTitle = aipod === "Artificer" ? validateChineseTaskTitle(agentRunOption(args, "title")) : null; const targetTask = targetTaskPreflightFromArgs(args, aipod); const renderInput = await aipodRenderInputFromArgs(args, targetTask === null ? 2 : args.length); + if (taskTitle !== null) renderInput.title = taskTitle; let targetTaskOperation: Record | null = null; let canonicalIdempotencyKey: string | null = null; if (targetTask !== null) { @@ -625,6 +628,12 @@ export async function submitQueueTaskRest(args: string[]): Promise): Record { const workspace = asRecord(targetTask.workspace); return { + title: boundedText(targetTask.title, 80), mdtodoId: boundedText(targetTask.mdtodoId, 80), target: boundedText(targetTask.target, 120), targetWorkspace: boundedText(targetTask.targetWorkspace, 360), @@ -182,6 +183,8 @@ function targetTaskIdentityNext( const commandId = asString(identity.commandId); const runnerJobId = asString(identity.runnerJobId); const sessionId = asString(identity.sessionId); + const title = asString(targetTask.title); + const selector = title === null ? null : shellQuote(title); const target = asString(targetTask.target) ?? asString(aipodBinding.node); const lane = asString(aipodBinding.lane); const suffix = [ @@ -191,24 +194,30 @@ function targetTaskIdentityNext( const scoped = (command: string): string => suffix.length === 0 ? command : `${command} ${suffix}`; return { ...(taskId === null ? {} : { - task: scoped(`bun scripts/cli.ts agentrun describe task/${taskId}`), + task: scoped(`bun scripts/cli.ts agentrun describe ${selector ?? `task/${taskId}`}`), attempts: scoped(`bun scripts/cli.ts agentrun get attempts --task ${taskId}`), }), ...(runId === null ? {} : { - events: scoped(`bun scripts/cli.ts agentrun events run/${runId} --after-seq 0 --limit 20`), + events: scoped(`bun scripts/cli.ts agentrun events ${selector ?? `run/${runId}`} --after-seq 0 --limit 20`), }), ...(runId === null || commandId === null ? {} : { - result: scoped(`bun scripts/cli.ts agentrun result run/${runId} --command ${commandId}`), + result: scoped(selector === null + ? `bun scripts/cli.ts agentrun result run/${runId} --command ${commandId}` + : `bun scripts/cli.ts agentrun result ${selector}`), }), ...(runnerJobId === null || runId === null ? {} : { runner: scoped(`bun scripts/cli.ts agentrun describe runnerjob/${runnerJobId} --run ${runId}`), }), ...(sessionId === null ? {} : { - logs: scoped(`bun scripts/cli.ts agentrun logs session/${sessionId} --tail 120`), + logs: scoped(`bun scripts/cli.ts agentrun logs ${selector ?? `session/${sessionId}`} --tail 120`), }), }; } +function shellQuote(value: string): string { + return `'${value.replace(/'/gu, `'"'"'`)}'`; +} + function innerData(value: Record): unknown { return value.data ?? value; } diff --git a/scripts/src/agentrun/target-task.ts b/scripts/src/agentrun/target-task.ts index 64c2e1d1..5d0d345e 100644 --- a/scripts/src/agentrun/target-task.ts +++ b/scripts/src/agentrun/target-task.ts @@ -3,6 +3,7 @@ import { realpathSync, statSync } from "node:fs"; import { spawnSync } from "node:child_process"; export interface AgentRunTargetTaskRequest { + readonly title: string; readonly target: string; readonly targetWorkspace: string; readonly repository: string; @@ -43,6 +44,7 @@ export interface AgentRunTargetTaskOperationEvidence { readonly tenantId: string; readonly projectId: string; readonly aipod: string; + readonly title: string; readonly mdtodoId: string; readonly target: string; readonly repository: string; @@ -68,6 +70,7 @@ export class AgentRunTargetTaskPreflightError extends Error { export function targetTaskRequestFromArgs(args: string[]): AgentRunTargetTaskRequest | null { const raw = { + title: option(args, "title"), target: option(args, "target"), targetWorkspace: option(args, "target-workspace"), repository: option(args, "repo"), @@ -80,11 +83,12 @@ export function targetTaskRequestFromArgs(args: string[]): AgentRunTargetTaskReq if (missing.length > 0) { throw new AgentRunTargetTaskPreflightError( "target-context", - `Artificer Target 派单缺少 ${missing.join(", ")};请同时提供 --target、--target-workspace、--repo、--ref 和 --mdtodo-id。`, + `Artificer Target 派单缺少 ${missing.join(", ")};请同时提供 --title、--target、--target-workspace、--repo、--ref 和 --mdtodo-id。`, { missing }, ); } return { + title: validateChineseTaskTitle(raw.title), target: validateTarget(raw.target as string), targetWorkspace: validateTargetWorkspace(raw.targetWorkspace as string), repository: normalizeRepository(raw.repository as string), @@ -209,6 +213,7 @@ export function preflightTargetTask(request: AgentRunTargetTaskRequest, markerEn export function targetTaskPrompt(preflight: AgentRunTargetTaskPreflight, userPrompt: string | null): string { const context = [ "任务目标上下文(由 UniDesk YAML-first CLI 在创建 task 前经 Target trans 只读核验):", + `- 中文任务标题: ${preflight.title}`, `- MDTODO ID: ${preflight.mdtodoId}`, `- Target: ${preflight.target}`, `- targetWorkspace: ${preflight.targetWorkspace}`, @@ -229,6 +234,7 @@ export function targetTaskPrompt(preflight: AgentRunTargetTaskPreflight, userPro export function targetTaskDisclosure(preflight: AgentRunTargetTaskPreflight): Record { return { + title: preflight.title, mdtodoId: preflight.mdtodoId, target: preflight.target, targetWorkspace: preflight.targetWorkspace, @@ -260,6 +266,7 @@ export function targetTaskOperationEvidence( tenantId: operation.tenantId, projectId: operation.projectId, aipod: operation.aipod, + title: preflight.title, mdtodoId: preflight.mdtodoId, target: preflight.target, repository: preflight.repository, @@ -311,6 +318,41 @@ export function targetTaskIdempotencyKey( return resolveTargetTaskIdempotency(preflight, operation, null).idempotencyKey; } +export function validateChineseTaskTitle(raw: string | null): string { + if (raw === null || raw.trim().length === 0) { + throw new AgentRunTargetTaskPreflightError( + "task-title", + "Artificer 创建任务必须提供 --title <中文短标题>,例如 --title \"修复 HWLAB 的 cicd\"。", + { reason: "missing-chinese-task-title" }, + ); + } + if (/\r|\n|[\u0000-\u001f\u007f]/u.test(raw)) { + throw new AgentRunTargetTaskPreflightError("task-title", "Artificer 任务标题必须是单行短字符串。", { + reason: "invalid-task-title-control-character", + }); + } + const title = raw.trim().replace(/\s+/gu, " "); + const length = Array.from(title).length; + if (length < 2 || length > 80) { + throw new AgentRunTargetTaskPreflightError("task-title", "Artificer 任务标题长度必须为 2 到 80 个字符。", { + reason: "invalid-task-title-length", + length, + }); + } + if (!isChineseTaskTitle(title)) { + throw new AgentRunTargetTaskPreflightError( + "task-title", + "Artificer 任务标题必须至少包含一个中文汉字;HWLAB、CI/CD 等专有名和空格可以保留。", + { reason: "missing-han-character" }, + ); + } + return title; +} + +export function isChineseTaskTitle(value: string | undefined): value is string { + return typeof value === "string" && /\p{Script=Han}/u.test(value); +} + function git(request: AgentRunTargetTaskRequest, args: string[], stage: string): string { const result = spawnSync("git", args, { cwd: request.targetWorkspace, diff --git a/scripts/src/agentrun/utils.ts b/scripts/src/agentrun/utils.ts index 83d5a770..815d7ccf 100644 --- a/scripts/src/agentrun/utils.ts +++ b/scripts/src/agentrun/utils.ts @@ -85,6 +85,7 @@ export interface AgentRunResourceOptions { dryRun: boolean; file: string | null; aipod: string | null; + title: string | null; idempotencyKey: string | null; promptStdin: boolean; node: string | null;