diff --git a/.agents/skills/unidesk-subagent/SKILL.md b/.agents/skills/unidesk-subagent/SKILL.md index 945020fb..3a3e9644 100644 --- a/.agents/skills/unidesk-subagent/SKILL.md +++ b/.agents/skills/unidesk-subagent/SKILL.md @@ -109,6 +109,12 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 子代理完成后必须留下可审查工件:PR、issue comment、commit、验证输出摘要、部署/observer/trace 证据或阻塞说明;主代理不能只凭口头结论合并。 - 子代理长时间无响应时,主代理按“问询 -> 只读检查 worktree/PR/issue -> 再窄问询”的顺序处理;多次问询仍无回复时,可以关闭旧子代理,并在原 worktree/原分支/原 issue 边界上重开新子代理接续。重开前不得删除或重置旧 worktree;新子代理必须先读取原 worktree 状态和既有 issue/PR/comment 链接,再继续最小下一步。 - 子代理完成任务后,主代理必须再给该子代理发送 post-task 收口要求;若主代理要求子代理纠偏或补验证,则等纠偏完成后再发 post-task。post-task 反馈由子代理按 `$post-task` 自行给出判断,主代理不负责反馈池去重;主代理只从子代理提好的反馈中挑选适合工程化的项转成正式 FEATURE/BUG issue,并优先派回提出该反馈的子代理执行。 +- post-task 派单与去重必须限制在同一 canonical logical operation: + - identity 至少同时绑定 tenant/project、Aipod、MDTODO、Target、repository/ref、绝对 targetWorkspace、源码提交和最终 payload hash;同一 operation 重放只复用原 task; + - 不得按同一 post-task dispatch、时间窗口、session、标题、Issue 前缀或列表邻近关系把多个 task 归为重复; + - 不同 payload 复用 idempotency key 必须输出 typed conflict,保留所有 task,禁止通过 cancel 猜测性收敛; + - identity 不完整、task input 不可读、投影陈旧或终态事实不一致时只输出 warning,并停止 destructive cancellation; + - post-task cleanup 不得取消 sibling task;取消只用于用户明确授权的目标,或同一 task/attempt 的受控终止。 - post-task 反馈明确有助于达到“下次同类任务可以减少不必要工具调用”时,主代理可以直接创建对应 issue、登记 MDTODO,并派后续子代理改进工具、文档或 skill,无需再次等待用户确认: - 仍须遵守子 issue、MDTODO、独立 worktree、目标分支、受控 GitHub 入口和主代理审核要求; - 改进任务必须与原业务主线隔离并优先并发推进,不得成为当前业务交付、合并或上线门禁; diff --git a/.agents/skills/unidesk-subagent/references/gh-workflow.md b/.agents/skills/unidesk-subagent/references/gh-workflow.md index 93f55d8d..50e495b7 100644 --- a/.agents/skills/unidesk-subagent/references/gh-workflow.md +++ b/.agents/skills/unidesk-subagent/references/gh-workflow.md @@ -84,6 +84,10 @@ - 子代理按 `$post-task` 自行输出已判断的 feedback 候选、疑似归属和是否建议转正式 issue;主代理不负责反馈池去重,不代替子代理维护 `[FEEDBACK]` issue。主代理只从子代理提好的 feedback 中挑选适合直接工程化的项,另起正式 FEATURE/BUG issue 后继续调度。 - feedback 转正式 issue 后,优先派回提出该 feedback 的子代理执行,使调查上下文和修复上下文保持连续;只有该子代理不可用或任务边界已变化时才派给其他子代理。 - feedback 明确能减少下次同类任务不必要工具调用时,主代理可以直接建立正式 issue、登记 MDTODO 并派后续子代理改进工具、文档或 skill,无需再次等待用户确认。该任务使用独立 worktree/PR 并与业务主线并发,不能成为当前业务交付门禁,也不能借机扩展未经授权的业务或安全范围。 +- post-task 派单只按完整 logical operation 做幂等重放: + - tenant/project、Aipod、MDTODO、Target、repository/ref、绝对 targetWorkspace、源码提交和最终 payload hash 任一不同都不是重复任务; + - 显式 key 与 canonical fingerprint 冲突时保留全部 task 并输出 typed evidence; + - identity 缺失、投影陈旧或权威终态不确定时只 warning,不允许按 dispatch/session/时间窗口批量取消 task。 ## PR 工作流 diff --git a/docs/MDTODO/agentrun-runtime-reliability.md b/docs/MDTODO/agentrun-runtime-reliability.md index f5cef2d4..59bf9700 100644 --- a/docs/MDTODO/agentrun-runtime-reliability.md +++ b/docs/MDTODO/agentrun-runtime-reliability.md @@ -139,3 +139,7 @@ ## R8 [in_progress] 解决 [UniDesk #2103](https://github.com/pikasTech/unidesk/issues/2103):将 `agentrun events` 的主工作流改为保留 `--after-seq` 分片游标并使用 `--expect `、`--timeout ` 的 manager/API 长轮询;达到期望新增事件数立即返回,超时返回已观察事件及明确 timeout/next cursor,禁止 CLI 本地高频忙轮询或无输出,并同步 CLI、manager、测试与 `unidesk-code-queue` skill 文档,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R8_Task_Report.md)。 + +## R9 [completed] + +解决 [UniDesk #2176](https://github.com/pikasTech/unidesk/issues/2176):调查 post-task dispatch 将 R8、R7.1、R7.2 三个不同 Artificer task 误判为 Issue #1888 重复任务并统一取消的 P0 事故;追溯引入提交,错误 PR 先语义回滚,再以完整 logical-operation fingerprint 约束去重范围。不同 payload/workspace/MDTODO 必须互不影响,identity 不完整或观察陈旧时只 warning 且禁止 destructive cancellation;同一操作重放返回原 task。补 OTel typed evidence 与四任务回归,禁止锁、租约、第二状态库、PK01、Vitest和人工 CI/CD,正常 PR merge 后从原入口验收,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R9_Task_Report.md)。 diff --git a/docs/MDTODO/details/agentrun-runtime-reliability/R9_Task_Report.md b/docs/MDTODO/details/agentrun-runtime-reliability/R9_Task_Report.md new file mode 100644 index 00000000..e2f1edbb --- /dev/null +++ b/docs/MDTODO/details/agentrun-runtime-reliability/R9_Task_Report.md @@ -0,0 +1,88 @@ +# R9 任务报告:post-task 去重串线取消事故 + +## 结论 + +本次事故不是 AgentRun 自动控制器执行 sibling task reconcile,也不是后端按投影自动取消。 + +事故由两段 UniDesk 受控流程缺陷串联产生: + +1. Artificer Target create 的 idempotency key 未绑定最终 prompt/payload,且显式 `--idempotency-key` 可以绕过 canonical identity; +2. 外部 post-task 收尾操作按一次 dispatch 归组,并用相同 reason 人为取消三个不同 logical operation。 + +引入提交为 `43124d01`。该提交建立 Target 只读预检、完整位置上下文和自动 dispatch,整体方向符合当前规格;本次属于局部幂等实现缺陷,不满足整 PR 方向错误的语义回滚条件,因此没有回滚整个提交。 + +## 离线证据 + +三个被误取消 task: + +- `qt_d214e48f4b0d4d6e899428e2855121ac`:payload hash 为 `e7c8bb1016fad9a2e27fb69b20c9c5c069fe13fb1632edf8c604c3a5ad79c3c3`; +- `qt_7c12fa9735634934a64986a1d45cfc44`:payload hash 为 `b48d75f1d717ff36d640f67b28a44c1f016183b677680e727c3ac27a1aa1e206`; +- `qt_45e692244cbd42a9aa28cc9706f4a28f`:payload hash 为 `bccec985937e047e95b1fed23da15927f60f14d3ac36c58ac7101329487572b6`。 + +三者在 2026-07-15 09:19:03–09:19:42 UTC 进入 cancelled,failure message 完全相同: + +`duplicate create task from one post-task dispatch; preserve qt_c94a19c6bd824551bcb7b22a51c266af` + +被保留的 `qt_c94a19c6bd824551bcb7b22a51c266af` payload hash 为 `7904461ada875b99d79c4a98a0d82ba7b75bffa3e4c2524ebcb1f3e2ac2cc15f`,属于 Issue #1888。该 task 随后在 2026-07-15 09:22:16 UTC 以“请求 #2170 却复用旧 #1888 input”原因取消,证明 create 幂等复用已经先发生串线。 + +仓库源码与 Git 历史中不存在上述 duplicate reason,也不存在按 post-task dispatch 自动扫描和取消 sibling task 的 controller。AgentRun session 事件还证明 #1888 session 自身只取消了它刚误创建且尚无 attempt 的嵌套副本,没有执行本次三项取消。 + +## 修复 + +`scripts/src/agentrun/target-task.ts` 现在生成版本化 canonical logical-operation fingerprint,绑定: + +- tenant/project; +- Aipod; +- MDTODO; +- Target; +- repository/ref; +- 绝对 targetWorkspace; +- 已核验源码提交; +- 最终 prompt 的 SHA-256 payload hash。 + +同一 operation 重放生成相同 key;任一 scope 或 payload 变化都会生成不同 key。 + +Target create 收到显式 `--idempotency-key` 时,只接受与 canonical key 完全一致的值。不同 payload 复用旧 key 会在 render 和 queue mutation 前返回: + +- stage:`idempotency-key-conflict`; +- code:`target-task-idempotency-key-conflict`; +- reason:`provided-key-does-not-match-canonical-logical-operation`; +- canonical fingerprint; +- payload hash; +- provided key 的不可逆 fingerprint; +- `mutation=false` 与 `valuesPrinted=false`。 + +task metadata、dry-run 和 mutation 摘要保留 logical-operation evidence,供事件和 OTel 投影读取,不打印 prompt 或 Secret。 + +`unidesk-subagent` skill 同步明确: + +- 禁止按 dispatch、时间窗口、session、标题或列表邻近关系判定重复; +- identity 不全、投影陈旧或终态不确定时只 warning; +- post-task cleanup 不得取消 sibling task; +- 取消只用于用户明确授权目标或同一 task/attempt 的受控终止。 + +## 验证 + +Target 原入口轻量验证: + +```text +git diff --check +bun test scripts/src/agentrun.test.ts --test-name-pattern 'Artificer Target' +``` + +结果:4 个测试通过,0 失败,72 个断言。fixture 覆盖 R8、R7.1、R7.2 与 #1888 四个不同 logical operation,并验证: + +- 四个 key 全部不同; +- 同一请求重放 key 稳定; +- prompt 变化会改变 key; +- 显式旧 key 与新 payload 冲突时返回 typed evidence,且不披露原 key。 + +## OTel 证据边界 + +事故时间窗的 Tempo 搜索没有找到 queue task cancel HTTP route、task ID 或 cancel reason span。当前 AgentRun durable attempt terminal fact 可证明取消结果和 reason,但不能从 OTel 单独恢复取消调用者。 + +调查 trace `01fb780dc5184363fea5f8ef859c9031` 仅覆盖本次 R9 只读调查命令,不作为事故调用者证据。修复后的 logical-operation evidence 已进入 task metadata;PR 自动交付后应从新的正常 create/replay 原入口确认对应 span/event 投影。 + +本任务没有新增锁、租约、第二状态库、数据库迁移或运行面围栏;没有取消任何现存 task,没有执行 runtime patch、人工 CI/CD 或 PR merge。 + +关联:[UniDesk #2176](https://github.com/pikasTech/unidesk/issues/2176),MDTODO `R9`。 diff --git a/scripts/src/agentrun.test.ts b/scripts/src/agentrun.test.ts index 85cba155..0ac6752f 100644 --- a/scripts/src/agentrun.test.ts +++ b/scripts/src/agentrun.test.ts @@ -12,8 +12,11 @@ import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; import { agentRunToolCredentialRefs } from "./agentrun/config"; import { autoDispatchTargetTask, targetTaskDispatchDryRunPlan } from "./agentrun/rest-bridge"; import { + AgentRunTargetTaskPreflightError, preflightTargetTask, + resolveTargetTaskIdempotency, targetTaskIdempotencyKey, + targetTaskOperationEvidence, targetTaskRequestFromArgs, type AgentRunTargetTaskPreflight, } from "./agentrun/target-task"; @@ -253,8 +256,84 @@ describe("Artificer Target one-command dispatch", () => { valuesPrinted: false, }, } satisfies AgentRunTargetTaskPreflight; - expect(targetTaskIdempotencyKey(preflight)).toMatch(/^artificer-target:[0-9a-f]{64}$/u); - expect(targetTaskIdempotencyKey(preflight)).toBe(targetTaskIdempotencyKey(preflight)); + const operation = { + aipod: "Artificer", + tenantId: "default", + projectId: request.repository, + prompt: "完成 MDTODO R6.3", + }; + expect(targetTaskIdempotencyKey(preflight, operation)).toMatch(/^artificer-target:[0-9a-f]{64}$/u); + expect(targetTaskIdempotencyKey(preflight, operation)).toBe(targetTaskIdempotencyKey(preflight, operation)); + expect(targetTaskIdempotencyKey(preflight, { ...operation, prompt: "不同 payload" })).not.toBe(targetTaskIdempotencyKey(preflight, operation)); + }); + + test("scopes post-task create replay to one complete logical operation", () => { + const base = { + target: "NC01", + targetRoute: "NC01:/root/unidesk/.worktree/base", + projectId: "pikasTech/unidesk", + workspaceRoot: "/root/unidesk/.worktree/base", + currentBranch: "fix/base", + headCommit: "a".repeat(40), + clean: true, + originRepository: "pikasTech/unidesk", + verifiedCommit: "a".repeat(40), + matchedRemoteRef: "refs/heads/fix/base", + refRelation: "branch-and-head-match-remote-ref", + sourceAuthority: { + kind: "target-workspace-origin", + credentialScope: "target-owned-git-credential", + valuesPrinted: false, + }, + } as const; + const fixtures = [ + { mdtodoId: "R8", targetWorkspace: "/root/unidesk/.worktree/2165", repository: "pikasTech/unidesk", ref: "fix/2165", prompt: "Issue #2165" }, + { mdtodoId: "R7.1", targetWorkspace: "/root/unidesk/.worktree/2163", repository: "pikasTech/unidesk", ref: "fix/2163", prompt: "Issue #2163" }, + { mdtodoId: "R7.2", targetWorkspace: "/root/unidesk/.worktree/2010", repository: "pikasTech/unidesk", ref: "feat/2010", prompt: "Issue #2010" }, + { mdtodoId: "R3", targetWorkspace: "/root/unidesk/.worktree/1888", repository: "pikasTech/unidesk", ref: "fix/1888", prompt: "Issue #1888" }, + ]; + const keys = fixtures.map((fixture) => targetTaskIdempotencyKey( + { + ...base, + ...fixture, + targetRoute: `NC01:${fixture.targetWorkspace}`, + workspaceRoot: fixture.targetWorkspace, + currentBranch: fixture.ref, + matchedRemoteRef: `refs/heads/${fixture.ref}`, + }, + { aipod: "Artificer", tenantId: "default", projectId: fixture.repository, prompt: fixture.prompt }, + )); + expect(new Set(keys).size).toBe(fixtures.length); + + const replay = { + ...base, + ...fixtures[0], + targetRoute: `NC01:${fixtures[0].targetWorkspace}`, + workspaceRoot: fixtures[0].targetWorkspace, + currentBranch: fixtures[0].ref, + matchedRemoteRef: `refs/heads/${fixtures[0].ref}`, + } satisfies AgentRunTargetTaskPreflight; + const operation = { aipod: "Artificer", tenantId: "default", projectId: replay.repository, prompt: fixtures[0].prompt }; + const evidence = targetTaskOperationEvidence(replay, operation); + expect(evidence.payloadHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(resolveTargetTaskIdempotency(replay, operation, targetTaskIdempotencyKey(replay, operation)).idempotencyKey).toBe(keys[0]); + expect(() => resolveTargetTaskIdempotency(replay, { ...operation, prompt: "changed payload" }, keys[0])).toThrow( + "不同 payload 不得复用同一 key", + ); + try { + resolveTargetTaskIdempotency(replay, { ...operation, prompt: "changed payload" }, keys[0]); + throw new Error("expected idempotency conflict"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as AgentRunTargetTaskPreflightError).stage).toBe("idempotency-key-conflict"); + expect((error as AgentRunTargetTaskPreflightError).details).toMatchObject({ + code: "target-task-idempotency-key-conflict", + reason: "provided-key-does-not-match-canonical-logical-operation", + mutation: false, + valuesPrinted: false, + }); + expect((error as AgentRunTargetTaskPreflightError).details).not.toHaveProperty("providedKey"); + } }); test("auto-dispatches pending submit exactly once and reuses existing attempt identity on retry", async () => { diff --git a/scripts/src/agentrun/rest-bridge.ts b/scripts/src/agentrun/rest-bridge.ts index b59a5ce4..b946fdef 100644 --- a/scripts/src/agentrun/rest-bridge.ts +++ b/scripts/src/agentrun/rest-bridge.ts @@ -46,8 +46,8 @@ import { AGENTRUN_GIT_MIRROR_RETRY_MAX_ATTEMPTS, agentRunGitMirrorRetryDelayMs, import { AgentRunTargetTaskPreflightError, preflightTargetTask, + resolveTargetTaskIdempotency, targetTaskDisclosure, - targetTaskIdempotencyKey, targetTaskPrompt, targetTaskRequestFromArgs, type AgentRunTargetTaskPreflight, @@ -601,23 +601,43 @@ export async function submitQueueTaskRest(args: string[]): Promise | null = null; + let canonicalIdempotencyKey: string | null = null; if (targetTask !== null) { renderInput.prompt = targetTaskPrompt(targetTask, stringOrNull(renderInput.prompt)); if (agentRunOption(args, "project-id") === null) renderInput.projectId = targetTask.projectId; if (agentRunOption(args, "provider-id") === null) renderInput.providerId = targetTask.target; + const idempotency = resolveTargetTaskIdempotencyForSubmit(targetTask, { + aipod, + tenantId: stringOrNull(renderInput.tenantId) ?? "default", + projectId: stringOrNull(renderInput.projectId) ?? targetTask.projectId, + prompt: stringOrNull(renderInput.prompt) ?? "", + }, agentRunOption(args, "idempotency-key")); + renderInput.idempotencyKey = idempotency.idempotencyKey; + canonicalIdempotencyKey = idempotency.idempotencyKey; + targetTaskOperation = idempotency.evidence; renderInput.metadata = { ...record(renderInput.metadata), targetContext: targetTaskDisclosure(targetTask), + logicalOperation: idempotency.evidence, }; - if (agentRunOption(args, "idempotency-key") === null) { - renderInput.idempotencyKey = targetTaskIdempotencyKey(targetTask); - } } const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, renderInput); const body = normalizeAipodRenderedQueueTask(record(record(innerData(rendered)).queueTask), args, aipod); if (Object.keys(body).length === 0) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask`); assertLegalAipodWorkspaceRef(body, aipod); - const targetTaskOutput = targetTask === null ? null : targetTaskDisclosure(targetTask); + if (targetTask !== null && canonicalIdempotencyKey !== null) { + body.idempotencyKey = canonicalIdempotencyKey; + body.metadata = { + ...record(body.metadata), + targetContext: targetTaskDisclosure(targetTask), + logicalOperation: targetTaskOperation, + }; + } + const targetTaskOutput = targetTask === null ? null : { + ...targetTaskDisclosure(targetTask), + logicalOperation: targetTaskOperation, + }; const aipodBinding = agentRunAipodBindingDisclosure(body, aipod); if (agentRunHasFlag(args, "dry-run")) { return agentRunDryRunPlan("queue-submit", "/api/v1/queue/tasks", body, queueSubmitConfirmCommand(args, aipod), "POST", { @@ -625,7 +645,7 @@ export async function submitQueueTaskRest(args: string[]): Promise[1], + providedKey: string | null, +): ReturnType { + try { + return resolveTargetTaskIdempotency(targetTask, operation, providedKey); + } catch (error) { + if (!(error instanceof AgentRunTargetTaskPreflightError)) throw error; + throw targetTaskRestError(error); + } +} + +function targetTaskRestError(error: AgentRunTargetTaskPreflightError): AgentRunRestError { + return new AgentRunRestError("validation-failed", error.message, { + details: { + stage: error.stage, + ...error.details, + mutation: false, + recoveryActions: [ + error.stage === "idempotency-key-conflict" + ? "移除显式 --idempotency-key,让 CLI 使用 canonical key;同一 logical operation 重放会返回原 task。" + : "修复错误后用同一条 create task 命令重新执行;失败预检不会创建 task。", + ], + valuesPrinted: false, + }, + }); +} + function assertLegalAipodWorkspaceRef(task: Record, aipod: string): void { const workspaceRef = record(task.workspaceRef); const keys = Object.keys(workspaceRef).sort(); diff --git a/scripts/src/agentrun/target-task.ts b/scripts/src/agentrun/target-task.ts index 21c5b442..64c2e1d1 100644 --- a/scripts/src/agentrun/target-task.ts +++ b/scripts/src/agentrun/target-task.ts @@ -28,6 +28,32 @@ export interface AgentRunTargetTaskPreflight extends AgentRunTargetTaskRequest { }; } +export interface AgentRunTargetTaskOperation { + readonly aipod: string; + readonly tenantId: string; + readonly projectId: string; + readonly prompt: string; +} + +export interface AgentRunTargetTaskOperationEvidence { + readonly version: "v1"; + readonly fingerprint: string; + readonly payloadHash: string; + readonly scope: { + readonly tenantId: string; + readonly projectId: string; + readonly aipod: string; + readonly mdtodoId: string; + readonly target: string; + readonly repository: string; + readonly ref: string; + readonly targetWorkspace: string; + readonly verifiedCommit: string; + }; + readonly reason: "canonical-logical-operation"; + readonly valuesPrinted: false; +} + export class AgentRunTargetTaskPreflightError extends Error { readonly stage: string; readonly details: Record; @@ -225,16 +251,64 @@ export function targetTaskDisclosure(preflight: AgentRunTargetTaskPreflight): Re }; } -export function targetTaskIdempotencyKey(preflight: AgentRunTargetTaskPreflight): string { - const identity = [ - preflight.target, - preflight.targetWorkspace, - preflight.repository, - preflight.ref, - preflight.mdtodoId, - preflight.verifiedCommit, - ].join("\n"); - return `artificer-target:${createHash("sha256").update(identity).digest("hex")}`; +export function targetTaskOperationEvidence( + preflight: AgentRunTargetTaskPreflight, + operation: AgentRunTargetTaskOperation, +): AgentRunTargetTaskOperationEvidence { + const payloadHash = sha256(operation.prompt); + const scope = { + tenantId: operation.tenantId, + projectId: operation.projectId, + aipod: operation.aipod, + mdtodoId: preflight.mdtodoId, + target: preflight.target, + repository: preflight.repository, + ref: preflight.ref, + targetWorkspace: preflight.targetWorkspace, + verifiedCommit: preflight.verifiedCommit, + }; + const fingerprint = sha256Hex(JSON.stringify({ version: "v1", scope, payloadHash })); + return { + version: "v1", + fingerprint, + payloadHash, + scope, + reason: "canonical-logical-operation", + valuesPrinted: false, + }; +} + +export function resolveTargetTaskIdempotency( + preflight: AgentRunTargetTaskPreflight, + operation: AgentRunTargetTaskOperation, + providedKey: string | null, +): { readonly idempotencyKey: string; readonly evidence: AgentRunTargetTaskOperationEvidence } { + const evidence = targetTaskOperationEvidence(preflight, operation); + const idempotencyKey = `artificer-target:${evidence.fingerprint}`; + if (providedKey !== null && providedKey !== idempotencyKey) { + throw new AgentRunTargetTaskPreflightError( + "idempotency-key-conflict", + "显式 --idempotency-key 与当前 Artificer logical operation 不匹配;不同 payload 不得复用同一 key。", + { + code: "target-task-idempotency-key-conflict", + reason: "provided-key-does-not-match-canonical-logical-operation", + canonicalIdempotencyKey: idempotencyKey, + logicalOperationFingerprint: evidence.fingerprint, + payloadHash: evidence.payloadHash, + providedKeyFingerprint: sha256(providedKey), + mutation: false, + valuesPrinted: false, + }, + ); + } + return { idempotencyKey, evidence }; +} + +export function targetTaskIdempotencyKey( + preflight: AgentRunTargetTaskPreflight, + operation: AgentRunTargetTaskOperation, +): string { + return resolveTargetTaskIdempotency(preflight, operation, null).idempotencyKey; } function git(request: AgentRunTargetTaskRequest, args: string[], stage: string): string { @@ -335,5 +409,9 @@ function redactGitError(value: string): string { } function sha256(value: string): string { - return `sha256:${createHash("sha256").update(value).digest("hex")}`; + return `sha256:${sha256Hex(value)}`; +} + +function sha256Hex(value: string): string { + return createHash("sha256").update(value).digest("hex"); }