fix: 将续跑 provenance 漂移降级为告警

This commit is contained in:
AgentRun Codex
2026-07-21 02:55:10 +02:00
parent c1768bf4da
commit 3fc5d06957
2 changed files with 107 additions and 37 deletions
+91 -18
View File
@@ -56,6 +56,24 @@ interface ResourceSkillWarning extends JsonRecord {
valuesPrinted: false; valuesPrinted: false;
} }
interface PrimaryWorkspaceContractWarning extends JsonRecord {
code:
| "primary-workspace-declared-source-drift"
| "primary-workspace-assembly-contract-drift"
| "primary-workspace-resolution-authority-drift"
| "primary-workspace-declared-commit-drift"
| "primary-workspace-provenance-hash-drift"
| "primary-workspace-provenance-schema-drift"
| "primary-workspace-frozen-tree-drift"
| "primary-workspace-origin-drift";
severity: "warning";
scope: "resource-bundle.primaryWorkspace.provenance";
message: string;
action: "continue-with-frozen-provenance";
blocking: false;
valuesPrinted: false;
}
interface GitCheckout { interface GitCheckout {
repoUrl: string; repoUrl: string;
fetchRepoUrl: string; fetchRepoUrl: string;
@@ -136,7 +154,7 @@ interface PrimaryWorkspaceProvenance extends JsonRecord {
type PrimaryWorkspaceState = type PrimaryWorkspaceState =
| { kind: "uninitialized" } | { kind: "uninitialized" }
| { kind: "continuation"; provenance: PrimaryWorkspaceProvenance }; | { kind: "continuation"; provenance: PrimaryWorkspaceProvenance; warnings: PrimaryWorkspaceContractWarning[] };
const primaryWorkspaceProvenanceFile = "agentrun-primary-workspace.json"; const primaryWorkspaceProvenanceFile = "agentrun-primary-workspace.json";
@@ -161,9 +179,11 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
let materializedBundles: MaterializedGitBundle[]; let materializedBundles: MaterializedGitBundle[];
let checkoutPath: string | null = null; let checkoutPath: string | null = null;
let workspaceLifecycle: "first-materialization" | "continuation-reuse"; let workspaceLifecycle: "first-materialization" | "continuation-reuse";
let workspaceWarnings: PrimaryWorkspaceContractWarning[] = [];
if (initialState.kind === "continuation") { if (initialState.kind === "continuation") {
provenance = initialState.provenance; provenance = initialState.provenance;
workspaceWarnings = initialState.warnings;
materializedBundles = provenance.bundleProvenance; materializedBundles = provenance.bundleProvenance;
workspaceLifecycle = "continuation-reuse"; workspaceLifecycle = "continuation-reuse";
} else { } else {
@@ -197,6 +217,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
throw new AgentRunError("infra-failed", "primary workspace installation did not produce durable provenance", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), valuesPrinted: false } }); throw new AgentRunError("infra-failed", "primary workspace installation did not produce durable provenance", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), valuesPrinted: false } });
} }
provenance = installedState.provenance; provenance = installedState.provenance;
workspaceWarnings = installedState.warnings;
materializedBundles = provenance.bundleProvenance; materializedBundles = provenance.bundleProvenance;
workspaceLifecycle = installed ? "first-materialization" : "continuation-reuse"; workspaceLifecycle = installed ? "first-materialization" : "continuation-reuse";
if (!installed) checkoutPath = null; if (!installed) checkoutPath = null;
@@ -205,7 +226,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
} }
} }
const primaryWorkspace = await verifyPrimaryWorkspace(workspacePath, workspaceRef, provenance, workspaceLifecycle, false); const primaryWorkspace = await verifyPrimaryWorkspace(workspacePath, workspaceRef, provenance, workspaceLifecycle, false, workspaceWarnings);
const managedOverlayRoot = await verifyManagedOverlay(workspacePath, materializedBundles); const managedOverlayRoot = await verifyManagedOverlay(workspacePath, materializedBundles);
const tools = await prepareGitBundleTools(managedOverlayRoot, env); const tools = await prepareGitBundleTools(managedOverlayRoot, env);
const skills = await discoverGitBundleSkills(managedOverlayRoot, materializedBundles); const skills = await discoverGitBundleSkills(managedOverlayRoot, materializedBundles);
@@ -253,7 +274,11 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
tools: tools.event, tools: tools.event,
skillDirs: skills.event, skillDirs: skills.event,
requiredSkills: requiredSkills.event, requiredSkills: requiredSkills.event,
warnings: requiredSkills.warnings, warnings: {
count: workspaceWarnings.length + Number(requiredSkills.warnings.count ?? 0),
items: [...workspaceWarnings, ...((requiredSkills.warnings.items as JsonRecord[] | undefined) ?? [])],
valuesPrinted: false,
},
promptRefs: prompts.event, promptRefs: prompts.event,
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false }, initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
valuesPrinted: false, valuesPrinted: false,
@@ -447,12 +472,13 @@ async function inspectPrimaryWorkspace(workspacePath: string, declaredSource: Pr
throw new AgentRunError("infra-failed", "primary workspace directory cannot be read", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), error: fileErrorSummary(error), valuesPrinted: false } }); throw new AgentRunError("infra-failed", "primary workspace directory cannot be read", { httpStatus: 500, details: { workspacePath: pathSummary(workspacePath), error: fileErrorSummary(error), valuesPrinted: false } });
} }
if (entries.length === 0) return { kind: "uninitialized" }; if (entries.length === 0) return { kind: "uninitialized" };
const provenance = await readPrimaryWorkspaceProvenance(workspacePath); const parsed = await readPrimaryWorkspaceProvenance(workspacePath);
assertPrimaryWorkspaceProvenanceContract(provenance, declaredSource, assemblyContractHash, bundleContractHash); const provenance = parsed.provenance;
return { kind: "continuation", provenance }; const warnings = [...parsed.warnings, ...primaryWorkspaceProvenanceContractWarnings(provenance, declaredSource, assemblyContractHash, bundleContractHash)];
return { kind: "continuation", provenance, warnings };
} }
async function readPrimaryWorkspaceProvenance(workspacePath: string): Promise<PrimaryWorkspaceProvenance> { async function readPrimaryWorkspaceProvenance(workspacePath: string): Promise<{ provenance: PrimaryWorkspaceProvenance; warnings: PrimaryWorkspaceContractWarning[] }> {
const file = path.join(workspacePath, ".git", primaryWorkspaceProvenanceFile); const file = path.join(workspacePath, ".git", primaryWorkspaceProvenanceFile);
let text: string; let text: string;
try { try {
@@ -475,11 +501,19 @@ async function readPrimaryWorkspaceProvenance(workspacePath: string): Promise<Pr
const provenanceHash = requiredProvenanceString(parsed, "provenanceHash"); const provenanceHash = requiredProvenanceString(parsed, "provenanceHash");
const hashBody = { ...parsed }; const hashBody = { ...parsed };
delete hashBody.provenanceHash; delete hashBody.provenanceHash;
const warnings: PrimaryWorkspaceContractWarning[] = [];
if (stableHash(hashBody) !== provenanceHash) { if (stableHash(hashBody) !== provenanceHash) {
throw new AgentRunError("schema-invalid", "primary workspace provenance hash does not match its content", { httpStatus: 400, details: { provenancePath: pathSummary(file), valuesPrinted: false } }); warnings.push(primaryWorkspaceWarning("primary-workspace-provenance-hash-drift", "primary workspace provenance hash differs from its content", {
provenancePath: pathSummary(file),
declaredProvenanceHash: provenanceHash,
actualProvenanceHash: stableHash(hashBody),
}));
} }
if (parsed.schemaVersion !== 1) { if (parsed.schemaVersion !== 1) {
throw new AgentRunError("schema-invalid", "primary workspace provenance schemaVersion is unsupported", { httpStatus: 400, details: { schemaVersion: parsed.schemaVersion ?? null, valuesPrinted: false } }); warnings.push(primaryWorkspaceWarning("primary-workspace-provenance-schema-drift", "primary workspace provenance schemaVersion differs from the current reader", {
declaredSchemaVersion: parsed.schemaVersion ?? null,
supportedSchemaVersion: 1,
}));
} }
const declaredSourceRecord = requiredProvenanceObject(parsed, "declaredSource"); const declaredSourceRecord = requiredProvenanceObject(parsed, "declaredSource");
const resolvedSourceRecord = requiredProvenanceObject(parsed, "resolvedSource"); const resolvedSourceRecord = requiredProvenanceObject(parsed, "resolvedSource");
@@ -511,23 +545,55 @@ async function readPrimaryWorkspaceProvenance(workspacePath: string): Promise<Pr
bundleProvenance: parsed.bundleProvenance.map((item, index) => parseMaterializedBundleProvenance(item, index)), bundleProvenance: parsed.bundleProvenance.map((item, index) => parseMaterializedBundleProvenance(item, index)),
provenanceHash, provenanceHash,
}; };
return provenance; return { provenance, warnings };
} }
function assertPrimaryWorkspaceProvenanceContract(provenance: PrimaryWorkspaceProvenance, declaredSource: PrimarySourceContract, assemblyContractHash: string, bundleContractHash: string): void { function primaryWorkspaceWarning(code: PrimaryWorkspaceContractWarning["code"], message: string, details: JsonRecord): PrimaryWorkspaceContractWarning {
return {
code,
severity: "warning",
scope: "resource-bundle.primaryWorkspace.provenance",
message,
action: "continue-with-frozen-provenance",
blocking: false,
...details,
valuesPrinted: false,
};
}
function primaryWorkspaceProvenanceContractWarnings(provenance: PrimaryWorkspaceProvenance, declaredSource: PrimarySourceContract, assemblyContractHash: string, bundleContractHash: string): PrimaryWorkspaceContractWarning[] {
const warnings: PrimaryWorkspaceContractWarning[] = [];
const warning = (code: PrimaryWorkspaceContractWarning["code"], message: string, details: JsonRecord): void => {
warnings.push(primaryWorkspaceWarning(code, message, details));
};
if (stableHash(provenance.declaredSource) !== stableHash(declaredSource)) { if (stableHash(provenance.declaredSource) !== stableHash(declaredSource)) {
throw new AgentRunError("schema-invalid", "primary workspace declared source conflicts with frozen provenance", { httpStatus: 400, details: { expectedSourceHash: stableHash(declaredSource), actualSourceHash: stableHash(provenance.declaredSource), valuesPrinted: false } }); warning("primary-workspace-declared-source-drift", "primary workspace declared source differs from frozen provenance", {
declaredSourceHash: stableHash(declaredSource),
frozenSourceHash: stableHash(provenance.declaredSource),
});
} }
if (provenance.assemblyContractHash !== assemblyContractHash || provenance.bundleContractHash !== bundleContractHash) { if (provenance.assemblyContractHash !== assemblyContractHash || provenance.bundleContractHash !== bundleContractHash) {
throw new AgentRunError("schema-invalid", "primary workspace assembly contract conflicts with frozen provenance", { httpStatus: 400, details: { expectedAssemblyContractHash: assemblyContractHash, actualAssemblyContractHash: provenance.assemblyContractHash, expectedBundleContractHash: bundleContractHash, actualBundleContractHash: provenance.bundleContractHash, valuesPrinted: false } }); warning("primary-workspace-assembly-contract-drift", "primary workspace assembly contract differs from frozen provenance", {
declaredAssemblyContractHash: assemblyContractHash,
frozenAssemblyContractHash: provenance.assemblyContractHash,
declaredBundleContractHash: bundleContractHash,
frozenBundleContractHash: provenance.bundleContractHash,
});
} }
const expectedAuthority = declaredSource.requestedCommitId ? "declared-commit" : "runner-first-materialization"; const expectedAuthority = declaredSource.requestedCommitId ? "declared-commit" : "runner-first-materialization";
if (provenance.resolutionAuthority !== expectedAuthority) { if (provenance.resolutionAuthority !== expectedAuthority) {
throw new AgentRunError("schema-invalid", "primary workspace resolution authority conflicts with declared source", { httpStatus: 400, details: { expectedAuthority, actualAuthority: provenance.resolutionAuthority, valuesPrinted: false } }); warning("primary-workspace-resolution-authority-drift", "primary workspace resolution authority differs from frozen provenance", {
declaredAuthority: expectedAuthority,
frozenAuthority: provenance.resolutionAuthority,
});
} }
if (declaredSource.requestedCommitId && provenance.resolvedSource.commitId !== declaredSource.requestedCommitId) { if (declaredSource.requestedCommitId && provenance.resolvedSource.commitId !== declaredSource.requestedCommitId) {
throw new AgentRunError("schema-invalid", "primary workspace frozen commit conflicts with declared commit", { httpStatus: 400, details: { declaredCommit: declaredSource.requestedCommitId, frozenCommit: provenance.resolvedSource.commitId, valuesPrinted: false } }); warning("primary-workspace-declared-commit-drift", "primary workspace declared commit differs from frozen provenance", {
declaredCommit: declaredSource.requestedCommitId,
frozenCommit: provenance.resolvedSource.commitId,
});
} }
return warnings;
} }
function parseMaterializedBundleProvenance(value: unknown, index: number): MaterializedGitBundle { function parseMaterializedBundleProvenance(value: unknown, index: number): MaterializedGitBundle {
@@ -683,7 +749,7 @@ async function assertCleanPrimarySourceView(workspacePath: string): Promise<void
} }
} }
async function verifyPrimaryWorkspace(workspacePath: string, workspaceRef: WorkspaceRef, provenance: PrimaryWorkspaceProvenance, workspaceLifecycle: "first-materialization" | "continuation-reuse", requireExactSource: boolean): Promise<JsonRecord> { async function verifyPrimaryWorkspace(workspacePath: string, workspaceRef: WorkspaceRef, provenance: PrimaryWorkspaceProvenance, workspaceLifecycle: "first-materialization" | "continuation-reuse", requireExactSource: boolean, warnings: PrimaryWorkspaceContractWarning[] = []): Promise<JsonRecord> {
let rootStat; let rootStat;
let gitStat; let gitStat;
try { try {
@@ -711,12 +777,19 @@ async function verifyPrimaryWorkspace(workspacePath: string, workspaceRef: Works
} }
const frozenTreeId = (await git(["rev-parse", `${frozenCommit}^{tree}`], workspacePath)).stdout.trim(); const frozenTreeId = (await git(["rev-parse", `${frozenCommit}^{tree}`], workspacePath)).stdout.trim();
if (frozenTreeId !== provenance.resolvedSource.treeId) { if (frozenTreeId !== provenance.resolvedSource.treeId) {
throw new AgentRunError("schema-invalid", "primary workspace frozen source tree conflicts with provenance", { httpStatus: 400, details: { frozenCommit, expectedTreeId: provenance.resolvedSource.treeId, actualTreeId: frozenTreeId, valuesPrinted: false } }); warnings.push(primaryWorkspaceWarning("primary-workspace-frozen-tree-drift", "primary workspace frozen source tree differs from provenance", {
frozenCommit,
declaredTreeId: provenance.resolvedSource.treeId,
actualTreeId: frozenTreeId,
}));
} }
const branch = (await git(["branch", "--show-current"], workspacePath)).stdout.trim() || null; const branch = (await git(["branch", "--show-current"], workspacePath)).stdout.trim() || null;
const origin = (await git(["remote", "get-url", "origin"], workspacePath)).stdout.trim(); const origin = (await git(["remote", "get-url", "origin"], workspacePath)).stdout.trim();
if (origin !== provenance.declaredSource.repoUrl) { if (origin !== provenance.declaredSource.repoUrl) {
throw new AgentRunError("schema-invalid", "primary workspace origin conflicts with declared source authority", { httpStatus: 400, details: { declared: gitRemoteSummary(provenance.declaredSource.repoUrl), actual: gitRemoteSummary(origin), valuesPrinted: false } }); warnings.push(primaryWorkspaceWarning("primary-workspace-origin-drift", "primary workspace origin differs from provenance", {
declared: gitRemoteSummary(provenance.declaredSource.repoUrl),
actual: gitRemoteSummary(origin),
}));
} }
const probe = path.join(workspacePath, `.agentrun-write-probe-${stableHash({ workspacePath, actualCommit }).slice(0, 12)}`); const probe = path.join(workspacePath, `.agentrun-write-probe-${stableHash({ workspacePath, actualCommit }).slice(0, 12)}`);
try { try {
+16 -19
View File
@@ -5,7 +5,6 @@ import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { AgentRunError } from "../../common/errors.js"; import { AgentRunError } from "../../common/errors.js";
import type { JsonRecord, ResourceBundleRef, WorkspaceRef } from "../../common/types.js"; import type { JsonRecord, ResourceBundleRef, WorkspaceRef } from "../../common/types.js";
import { stableHash } from "../../common/validation.js";
import { ManagerClient } from "../../mgr/client.js"; import { ManagerClient } from "../../mgr/client.js";
import { startManagerServer } from "../../mgr/server.js"; import { startManagerServer } from "../../mgr/server.js";
import { MemoryAgentRunStore } from "../../mgr/store.js"; import { MemoryAgentRunStore } from "../../mgr/store.js";
@@ -115,24 +114,22 @@ const selfTest: SelfTestCase = async (context) => {
assert.match(replayStatus, / M tools\/source-tool/u); assert.match(replayStatus, / M tools\/source-tool/u);
assert.match(replayStatus, /\?\? dirty-from-previous-attempt\.txt/u); assert.match(replayStatus, /\?\? dirty-from-previous-attempt\.txt/u);
await assert.rejects( const sourceDrift = await materializeResourceBundle({ ...resourceBundleRef, ref: "changed-source-ref" }, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_changed_source_contract" });
() => materializeResourceBundle({ ...resourceBundleRef, ref: "changed-source-ref" }, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_changed_source_contract" }), assert.ok(sourceDrift);
(error) => error instanceof AgentRunError && error.failureKind === "schema-invalid" && /declared source conflicts/u.test(error.message), assert.equal((sourceDrift.event.primaryWorkspace as JsonRecord).sourceCommit, primary.commitId);
); assert.equal((sourceDrift.event.primaryWorkspace as JsonRecord).workspaceLifecycle, "continuation-reuse");
await assert.rejects( assert.ok((((sourceDrift.event.warnings as JsonRecord).items as JsonRecord[])).some((item) => item.code === "primary-workspace-declared-source-drift" && item.blocking === false));
() => materializeResourceBundle({ ...resourceBundleRef, bundles: [{ ...resourceBundleRef.bundles[0]!, targetPath: "alternate-tools" }] }, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_changed_assembly_contract" }), const assemblyDrift = await materializeResourceBundle({ ...resourceBundleRef, bundles: [{ ...resourceBundleRef.bundles[0]!, targetPath: "alternate-tools" }] }, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_changed_assembly_contract" });
(error) => error instanceof AgentRunError && error.failureKind === "schema-invalid" && /assembly contract conflicts/u.test(error.message), assert.ok(assemblyDrift);
); assert.equal((assemblyDrift.event.primaryWorkspace as JsonRecord).sourceCommit, primary.commitId);
assert.ok((((assemblyDrift.event.warnings as JsonRecord).items as JsonRecord[])).some((item) => item.code === "primary-workspace-assembly-contract-drift" && item.blocking === false));
const tamperedProvenance = JSON.parse(await readFile(provenancePath, "utf8")) as JsonRecord; const tamperedProvenance = JSON.parse(await readFile(provenancePath, "utf8")) as JsonRecord;
(tamperedProvenance.resolvedSource as JsonRecord).treeId = "0000000000000000000000000000000000000000"; (tamperedProvenance.resolvedSource as JsonRecord).treeId = "0000000000000000000000000000000000000000";
const tamperedBody = { ...tamperedProvenance };
delete tamperedBody.provenanceHash;
tamperedProvenance.provenanceHash = stableHash(tamperedBody);
await writeFile(provenancePath, `${JSON.stringify(tamperedProvenance, null, 2)}\n`, "utf8"); await writeFile(provenancePath, `${JSON.stringify(tamperedProvenance, null, 2)}\n`, "utf8");
await assert.rejects( const provenanceDrift = await materializeResourceBundle(resourceBundleRef, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_tampered_frozen_tree" });
() => materializeResourceBundle(resourceBundleRef, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_tampered_frozen_tree" }), assert.ok(provenanceDrift);
(error) => error instanceof AgentRunError && error.failureKind === "schema-invalid" && /frozen source tree conflicts/u.test(error.message), assert.ok((((provenanceDrift.event.warnings as JsonRecord).items as JsonRecord[])).some((item) => item.code === "primary-workspace-provenance-hash-drift" && item.blocking === false));
); assert.ok((((provenanceDrift.event.warnings as JsonRecord).items as JsonRecord[])).some((item) => item.code === "primary-workspace-frozen-tree-drift" && item.blocking === false));
const missingCredentialWorkspace = path.join(context.tmp, "primary-workspace-missing-credential"); const missingCredentialWorkspace = path.join(context.tmp, "primary-workspace-missing-credential");
await assert.rejects( await assert.rejects(
@@ -270,9 +267,9 @@ const selfTest: SelfTestCase = async (context) => {
"primary-workspace-continuation-does-not-fetch", "primary-workspace-continuation-does-not-fetch",
"primary-workspace-continuation-preserves-successor-commit", "primary-workspace-continuation-preserves-successor-commit",
"primary-workspace-continuation-reuses-bundle-provenance", "primary-workspace-continuation-reuses-bundle-provenance",
"primary-workspace-source-contract-conflict-fails-closed", "primary-workspace-source-contract-drift-warns-and-continues",
"primary-workspace-assembly-contract-conflict-fails-closed", "primary-workspace-assembly-contract-drift-warns-and-continues",
"primary-workspace-frozen-tree-provenance-verified", "primary-workspace-provenance-drift-warns-and-continues",
"primary-workspace-nonempty-unowned-root-fails-closed", "primary-workspace-nonempty-unowned-root-fails-closed",
"primary-workspace-credential-missing-typed-blocker", "primary-workspace-credential-missing-typed-blocker",
"primary-workspace-ref-not-found-first-error", "primary-workspace-ref-not-found-first-error",