diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4ce7451a3..5902df20918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index 904ca191a0b..ce9b9a7e5ed 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -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); diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index d913446125f..52057865c02 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -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 ?? ""); }