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:
RenzoMXD
2026-05-07 21:28:48 +02:00
committed by Peter Steinberger
parent 6389059632
commit 79b292c2be
3 changed files with 147 additions and 6 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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 ?? "");
}