fix(agentrun): 限定 post-task 幂等作用域

This commit is contained in:
Codex
2026-07-15 12:13:33 +02:00
parent 89c089762f
commit 96318db3a1
6 changed files with 324 additions and 30 deletions
+6
View File
@@ -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:0309:19:42 UTC 进入 cancelledfailure 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 metadataPR 自动交付后应从新的正常 create/replay 原入口确认对应 span/event 投影。
本任务没有新增锁、租约、第二状态库、数据库迁移或运行面围栏;没有取消任何现存 task,没有执行 runtime patch、人工 CI/CD 或 PR merge。
关联:[UniDesk #2176](https://github.com/pikasTech/unidesk/issues/2176)MDTODO `R9`
+81 -2
View File
@@ -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 () => {
+56 -17
View File
@@ -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();
+89 -11
View File
@@ -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");
}