mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 04:20:43 +00:00
fix(agents): self-heal cross-tool file-mutation in cron classifier
Recognize a successful file-mutation on the same path/oldpath target as recovery for an earlier failed file-mutation, even when the tool name differs (edit -> write, apply_patch -> write, etc). Previously isSameToolMutationAction required exact fingerprint equality, which includes tool=<name>, so an edit failure followed by a successful write to the same path was never recognized as recovery. The unresolved lastToolError then drove the cron classifier to flag a healthy self-healed run as fatal with the user-visible warning prefix from issue #79024. Limited to file-mutating tools (edit, write, apply_patch) and the stable path/oldpath segments of the action fingerprint; non-file-mutating tools and different paths still fail closed. Fixes #79024.
This commit is contained in:
committed by
Peter Steinberger
parent
6389059632
commit
79b292c2be
@@ -184,6 +184,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Cron/agents: recognize cross-tool same-target file-mutation as recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit`/`apply_patch` on the same path. Stops cron from reporting fatal failures when an agent self-heals across tools, while preserving same-tool fingerprint matching, blocking different-target writes, and ignoring non-file-mutating recovery tools. Fixes #79024.
|
||||
- Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context.
|
||||
- fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987.
|
||||
- fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987.
|
||||
|
||||
@@ -88,6 +88,95 @@ describe("tool mutation helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes cross-tool file-mutation recovery on the same target (#79024)", () => {
|
||||
expect(
|
||||
isSameToolMutationAction(
|
||||
{ toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" },
|
||||
{ toolName: "write", actionFingerprint: "tool=write|path=/tmp/a" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSameToolMutationAction(
|
||||
{ toolName: "write", actionFingerprint: "tool=write|path=/tmp/a" },
|
||||
{ toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSameToolMutationAction(
|
||||
{ toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" },
|
||||
{ toolName: "apply_patch", actionFingerprint: "tool=apply_patch|path=/tmp/a" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not cross-recover file mutations on different targets (#79024)", () => {
|
||||
expect(
|
||||
isSameToolMutationAction(
|
||||
{ toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" },
|
||||
{ toolName: "write", actionFingerprint: "tool=write|path=/tmp/b" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not cross-recover when the recovery tool is not file-mutating (#79024)", () => {
|
||||
expect(
|
||||
isSameToolMutationAction(
|
||||
{ toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" },
|
||||
{ toolName: "bash", actionFingerprint: "tool=bash|meta=cat /tmp/a" },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSameToolMutationAction(
|
||||
{ toolName: "edit", actionFingerprint: "tool=edit|path=/tmp/a" },
|
||||
{ toolName: "exec", actionFingerprint: "tool=exec|meta=touch /tmp/a" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores call-specific noise when comparing the cross-tool target (#79024)", () => {
|
||||
// `id=...` and `meta=...` segments must not block recovery when the
|
||||
// stable `path=...` target still matches.
|
||||
expect(
|
||||
isSameToolMutationAction(
|
||||
{
|
||||
toolName: "edit",
|
||||
actionFingerprint: "tool=edit|path=/tmp/a|id=42|meta=edit /tmp/a",
|
||||
},
|
||||
{
|
||||
toolName: "write",
|
||||
actionFingerprint: "tool=write|path=/tmp/a|id=99|meta=write /tmp/a",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires `oldpath` to agree across cross-tool recovery (#79024)", () => {
|
||||
expect(
|
||||
isSameToolMutationAction(
|
||||
{
|
||||
toolName: "apply_patch",
|
||||
actionFingerprint: "tool=apply_patch|path=/tmp/a|oldpath=/tmp/old",
|
||||
},
|
||||
{
|
||||
toolName: "write",
|
||||
actionFingerprint: "tool=write|path=/tmp/a|oldpath=/tmp/old",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSameToolMutationAction(
|
||||
{
|
||||
toolName: "apply_patch",
|
||||
actionFingerprint: "tool=apply_patch|path=/tmp/a|oldpath=/tmp/old",
|
||||
},
|
||||
{
|
||||
toolName: "apply_patch",
|
||||
actionFingerprint: "tool=apply_patch|path=/tmp/a|oldpath=/tmp/different",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps legacy name-only mutating heuristics for payload fallback", () => {
|
||||
expect(isLikelyMutatingToolName("sessions_spawn")).toBe(true);
|
||||
expect(isLikelyMutatingToolName("sessions_send")).toBe(true);
|
||||
|
||||
@@ -21,6 +21,17 @@ const MUTATING_TOOL_NAMES = new Set([
|
||||
"session_status",
|
||||
]);
|
||||
|
||||
// File-mutation tools that operate on the same `path`/`oldpath` target identity.
|
||||
// Recovery is allowed across these even when the tool name differs (e.g.
|
||||
// edit-fails-then-write-succeeds on the same path), because the user-visible
|
||||
// invariant is "the file at this path is in the desired state."
|
||||
const FILE_MUTATING_TOOL_NAMES = new Set(["edit", "write", "apply_patch"]);
|
||||
|
||||
// Stable target segments produced by `buildToolActionFingerprint` that identify
|
||||
// the file being mutated. Other segments (`tool=`, `action=`, `id=`, `meta=`)
|
||||
// are call-specific and excluded from cross-tool target comparison.
|
||||
const FILE_TARGET_FINGERPRINT_KEYS = new Set(["path", "oldpath"]);
|
||||
|
||||
const READ_ONLY_ACTIONS = new Set([
|
||||
"get",
|
||||
"list",
|
||||
@@ -214,14 +225,54 @@ export function buildToolMutationState(
|
||||
};
|
||||
}
|
||||
|
||||
function isFileMutatingToolName(rawName: string): boolean {
|
||||
return FILE_MUTATING_TOOL_NAMES.has(normalizeLowercaseStringOrEmpty(rawName));
|
||||
}
|
||||
|
||||
function extractFileTargetFingerprint(fingerprint: string | undefined): string | undefined {
|
||||
if (!fingerprint) {
|
||||
return undefined;
|
||||
}
|
||||
const segments: string[] = [];
|
||||
for (const segment of fingerprint.split("|")) {
|
||||
const eqIndex = segment.indexOf("=");
|
||||
if (eqIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
const key = segment.slice(0, eqIndex);
|
||||
if (FILE_TARGET_FINGERPRINT_KEYS.has(key)) {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
return segments.length > 0 ? segments.join("|") : undefined;
|
||||
}
|
||||
|
||||
export function isSameToolMutationAction(existing: ToolActionRef, next: ToolActionRef): boolean {
|
||||
if (existing.actionFingerprint != null || next.actionFingerprint != null) {
|
||||
// For mutating flows, fail closed: only clear when both fingerprints exist and match.
|
||||
return (
|
||||
existing.actionFingerprint != null &&
|
||||
next.actionFingerprint != null &&
|
||||
existing.actionFingerprint === next.actionFingerprint
|
||||
);
|
||||
// For mutating flows, fail closed: only clear when both fingerprints exist
|
||||
// and either match exactly or describe the same file-mutation target.
|
||||
if (existing.actionFingerprint == null || next.actionFingerprint == null) {
|
||||
return false;
|
||||
}
|
||||
if (existing.actionFingerprint === next.actionFingerprint) {
|
||||
return true;
|
||||
}
|
||||
// Cross-tool recovery: a successful file-mutation on the same `path`
|
||||
// (and `oldpath`, where applicable) clears an unresolved file-mutation
|
||||
// failure even when the tool name differs (e.g. edit→write self-heal).
|
||||
// Different paths or non-file-mutating tools never qualify.
|
||||
if (isFileMutatingToolName(existing.toolName) && isFileMutatingToolName(next.toolName)) {
|
||||
const existingTarget = extractFileTargetFingerprint(existing.actionFingerprint);
|
||||
const nextTarget = extractFileTargetFingerprint(next.actionFingerprint);
|
||||
if (
|
||||
existingTarget !== undefined &&
|
||||
nextTarget !== undefined &&
|
||||
existingTarget === nextTarget
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return existing.toolName === next.toolName && (existing.meta ?? "") === (next.meta ?? "");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user