fix(agentrun): 限定 post-task 幂等作用域
This commit is contained in:
@@ -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 入口和主代理审核要求;
|
||||
- 改进任务必须与原业务主线隔离并优先并发推进,不得成为当前业务交付、合并或上线门禁;
|
||||
|
||||
@@ -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 工作流
|
||||
|
||||
|
||||
@@ -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`。
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Record<string
|
||||
if (aipod) {
|
||||
const targetTask = targetTaskPreflightFromArgs(args, aipod);
|
||||
const renderInput = await aipodRenderInputFromArgs(args, targetTask === null ? 2 : args.length);
|
||||
let targetTaskOperation: Record<string, unknown> | 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<Record<string
|
||||
aipodBinding,
|
||||
...(targetTaskOutput === null ? {} : { targetTask: targetTaskOutput }),
|
||||
...(targetTask === null ? {} : {
|
||||
dispatchPlan: targetTaskDispatchDryRunPlan(stringOrNull(body.idempotencyKey) ?? targetTaskIdempotencyKey(targetTask)),
|
||||
dispatchPlan: targetTaskDispatchDryRunPlan(canonicalIdempotencyKey ?? ""),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -669,20 +689,39 @@ function targetTaskPreflightFromArgs(args: string[], aipod: string): AgentRunTar
|
||||
return preflightTargetTask(request, execution.markerEnv);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AgentRunTargetTaskPreflightError)) throw error;
|
||||
throw new AgentRunRestError("validation-failed", error.message, {
|
||||
details: {
|
||||
stage: error.stage,
|
||||
...error.details,
|
||||
mutation: false,
|
||||
recoveryActions: [
|
||||
"修复错误后用同一条 create task 命令重新执行;失败预检不会创建 task。",
|
||||
],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
});
|
||||
throw targetTaskRestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetTaskIdempotencyForSubmit(
|
||||
targetTask: AgentRunTargetTaskPreflight,
|
||||
operation: Parameters<typeof resolveTargetTaskIdempotency>[1],
|
||||
providedKey: string | null,
|
||||
): ReturnType<typeof resolveTargetTaskIdempotency> {
|
||||
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<string, unknown>, aipod: string): void {
|
||||
const workspaceRef = record(task.workspaceRef);
|
||||
const keys = Object.keys(workspaceRef).sort();
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user