From 73ae58326326ce05f80a9b6ec82a2168b40bcbb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 12:30:49 -0700 Subject: [PATCH] refactor(tasks): simplify subagent activity tracking (#122198) * refactor(tasks): simplify subagent activity tracking * fix(android): single-source subagent activity expiry clock * chore(i18n): refresh native source baseline --- apps/.i18n/native-source.json | 4 +- .../ai/openclaw/app/chat/ChatController.kt | 5 +- .../OpenClawChatUI/ChatSubagentActivity.swift | 11 +- src/agents/embedded-agent-live-edit-diff.ts | 62 +------ src/agents/file-mutation-args.ts | 156 ++++++++++++++++++ src/tasks/task-registry-activity.ts | 113 +------------ 6 files changed, 169 insertions(+), 182 deletions(-) create mode 100644 src/agents/file-mutation-args.ts diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index a4ad2b100b1d..c45dd9112dfe 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -1939,7 +1939,7 @@ }, { "kind": "ui-call", - "line": 6051, + "line": 6052, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out waiting for a reply; try again or refresh.", "surface": "android", @@ -1947,7 +1947,7 @@ }, { "kind": "ui-call", - "line": 6268, + "line": 6269, "path": "apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", "source": "Timed out confirming the sent message; refresh to check delivery.", "surface": "android", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 24c311ea1cc0..2982ba5e3c1c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -5906,10 +5906,11 @@ class ChatController internal constructor( _subagentActivities.value = _subagentActivities.value + (taskId to activity) subagentActivityExpiryJobs.remove(taskId)?.cancel() if (!activity.isWorking) { + val expiresAt = (activity.endedAtMs ?: now) + SUBAGENT_ACTIVITY_RETENTION_MS + val expiryDelayMs = (expiresAt - now).coerceAtLeast(0L) subagentActivityExpiryJobs[taskId] = scope.launch { - val expiresAt = (activity.endedAtMs ?: now) + SUBAGENT_ACTIVITY_RETENTION_MS - delay((expiresAt - System.currentTimeMillis()).coerceAtLeast(0L)) + delay(expiryDelayMs) synchronized(subagentActivityLock) { if (_subagentActivities.value[taskId] == activity) { _subagentActivities.value = _subagentActivities.value - taskId diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivity.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivity.swift index 5cbd2ca0d41a..a708e38e8c06 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivity.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSubagentActivity.swift @@ -25,11 +25,8 @@ struct ChatSubagentActivity: Identifiable, Equatable, Sendable { let status: ChatSubagentActivityStatus let snippet: String? let diffStat: ChatToolDiffStat? - let startedAt: Double let updatedAt: Double - let endedAt: Double? let terminalObservedAt: Double? - let childSessionKey: String? let terminalSummary: String? } @@ -55,7 +52,7 @@ struct ChatSubagentActivityState: Equatable, Sendable { } else { fallbackSnippet ?? previous?.snippet } - let endedAt = Self.timestampMilliseconds(task.endedat) ?? previous?.endedAt + let endedAt = Self.timestampMilliseconds(task.endedat) let updatedAt = Self.timestampMilliseconds(task.updatedat) ?? previous?.updatedAt ?? endedAt @@ -72,14 +69,8 @@ struct ChatSubagentActivityState: Equatable, Sendable { status: status, snippet: snippet, diffStat: Self.diffStat(task.diffstat) ?? previous?.diffStat, - startedAt: Self.timestampMilliseconds(task.startedat) - ?? previous?.startedAt - ?? Self.timestampMilliseconds(task.createdat) - ?? nowMilliseconds, updatedAt: updatedAt, - endedAt: endedAt, terminalObservedAt: terminalObservedAt, - childSessionKey: Self.nonBlank(task.childsessionkey) ?? previous?.childSessionKey, terminalSummary: Self.nonBlank(task.terminalsummary) ?? previous?.terminalSummary) } diff --git a/src/agents/embedded-agent-live-edit-diff.ts b/src/agents/embedded-agent-live-edit-diff.ts index 7f22ac7ce583..6b528dfc5dc7 100644 --- a/src/agents/embedded-agent-live-edit-diff.ts +++ b/src/agents/embedded-agent-live-edit-diff.ts @@ -1,6 +1,7 @@ import { parseStreamingJson } from "@openclaw/ai/internal/runtime"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { resolveFileMutationToolName, type FileMutationToolName } from "./tool-mutation-names.js"; +import { countStreamingFileMutationLines } from "./file-mutation-args.js"; +import { resolveFileMutationToolName } from "./tool-mutation-names.js"; const LIVE_EDIT_DIFF_MIN_INTERVAL_MS = 250; const LIVE_EDIT_DIFF_MAX_PARTIAL_JSON_CHARS = 1024 * 1024; @@ -20,63 +21,6 @@ type LiveEditDiffProgress = { diff: { added: number; removed: number }; }; -function countNewlines(value: unknown): number { - if (typeof value !== "string") { - return 0; - } - let count = 0; - for (let index = value.indexOf("\n"); index >= 0; index = value.indexOf("\n", index + 1)) { - count += 1; - } - return count; -} - -function countEditLines(args: Record): { added: number; removed: number } { - const replacements = Array.isArray(args.edits) ? args.edits : [args]; - let added = 0; - let removed = 0; - for (const replacement of replacements) { - if (!replacement || typeof replacement !== "object" || Array.isArray(replacement)) { - continue; - } - const record = replacement as Record; - added += countNewlines(record.newText ?? record.new_string); - removed += countNewlines(record.oldText ?? record.old_string); - } - return { added, removed }; -} - -function countPatchLines(patch: unknown): { added: number; removed: number } { - if (typeof patch !== "string") { - return { added: 0, removed: 0 }; - } - let added = 0; - let removed = 0; - let lineStart = 0; - for (let lineEnd = patch.indexOf("\n"); lineEnd >= 0; lineEnd = patch.indexOf("\n", lineStart)) { - if (patch[lineStart] === "+") { - added += 1; - } else if (patch[lineStart] === "-") { - removed += 1; - } - lineStart = lineEnd + 1; - } - return { added, removed }; -} - -function countLiveEditDiff( - kind: FileMutationToolName, - args: Record, -): { added: number; removed: number } { - if (kind === "write") { - return { added: countNewlines(args.content), removed: 0 }; - } - if (kind === "edit") { - return countEditLines(args); - } - return countPatchLines(args.input ?? args.patch); -} - function readToolCallBlock(event: Record): Record | undefined { const contentIndex = event.contentIndex; const partial = event.partial; @@ -160,7 +104,7 @@ export function updateLiveEditDiffProgress( // Parsing is the expensive part. Rate-limit it before touching cumulative JSON // so fragmented large arguments cannot create quadratic work on the event path. progress.lastCheckedAtMs = now; - const counted = countLiveEditDiff(kind, parseStreamingJson(partialJson)); + const counted = countStreamingFileMutationLines(kind, parseStreamingJson(partialJson)); // Streaming parses are best effort. Never move a visible counter backwards if // an incomplete JSON boundary temporarily exposes less of the same arguments. progress.added = Math.max(progress.added, counted.added); diff --git a/src/agents/file-mutation-args.ts b/src/agents/file-mutation-args.ts new file mode 100644 index 000000000000..31eec61d130a --- /dev/null +++ b/src/agents/file-mutation-args.ts @@ -0,0 +1,156 @@ +import path from "node:path"; +import { + asOptionalObjectRecord, + readStringField, +} from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { extractApplyPatchTargetPaths } from "./apply-patch-paths.js"; +import type { FileMutationToolName } from "./tool-mutation-names.js"; + +type FileMutationLineCount = { added: number; removed: number }; +type FileMutationDelta = FileMutationLineCount & { files: string[] }; + +function readTarget(record: Record): string | undefined { + const target = normalizeOptionalString(record.path ?? record.file_path ?? record.filePath); + return target ? path.resolve(target) : undefined; +} + +function readEdits(args: Record): Record[] { + const candidates = Array.isArray(args.edits) ? args.edits : [args]; + return candidates.flatMap((candidate) => { + const edit = asOptionalObjectRecord(candidate); + return edit ? [edit] : []; + }); +} + +function countNewlines(value: unknown): number { + if (typeof value !== "string") { + return 0; + } + let count = 0; + for (let index = value.indexOf("\n"); index >= 0; index = value.indexOf("\n", index + 1)) { + count += 1; + } + return count; +} + +/** Counts only newline-terminated content so partial streamed JSON never guesses a line. */ +export function countStreamingFileMutationLines( + kind: FileMutationToolName, + args: Record, +): FileMutationLineCount { + if (kind === "write") { + return { added: countNewlines(readStringField(args, "content")), removed: 0 }; + } + if (kind === "edit") { + return readEdits(args).reduce( + (total, edit) => ({ + added: total.added + countNewlines(edit.newText ?? edit.new_string), + removed: total.removed + countNewlines(edit.oldText ?? edit.old_string), + }), + { added: 0, removed: 0 }, + ); + } + const patch = args.input ?? args.patch; + if (typeof patch !== "string") { + return { added: 0, removed: 0 }; + } + let added = 0; + let removed = 0; + let lineStart = 0; + for (let lineEnd = patch.indexOf("\n"); lineEnd >= 0; lineEnd = patch.indexOf("\n", lineStart)) { + added += Number(patch[lineStart] === "+"); + removed += Number(patch[lineStart] === "-"); + lineStart = lineEnd + 1; + } + return { added, removed }; +} + +function readCodexChangeDelta(args: Record): FileMutationDelta | undefined { + const files: string[] = []; + let added = 0; + let removed = 0; + for (const candidate of Array.isArray(args.changes) ? args.changes : []) { + const change = asOptionalObjectRecord(candidate); + const target = change ? readTarget(change) : undefined; + if (!change || !target) { + continue; + } + files.push(target); + const stat = asOptionalObjectRecord(change.stat); + added += + typeof stat?.added === "number" && Number.isFinite(stat.added) ? Math.max(0, stat.added) : 0; + removed += + typeof stat?.removed === "number" && Number.isFinite(stat.removed) + ? Math.max(0, stat.removed) + : 0; + } + return files.length > 0 ? { files, added, removed } : undefined; +} + +/** Reads complete tool arguments using task-fold line semantics. */ +export function readCompletedFileMutationDelta( + kind: FileMutationToolName, + args: Record, +): FileMutationDelta | undefined { + if (kind === "apply_patch") { + const patch = readStringField(args, "input"); + if (patch === undefined) { + return readCodexChangeDelta(args); + } + const files = extractApplyPatchTargetPaths(args); + if (files.length === 0) { + return undefined; + } + let added = 0; + let removed = 0; + let inBody = false; + for (const line of patch.split(/\r\n|\r|\n/)) { + if (/^\s*\*\*\* (?:Add|Update|Delete) File: /.test(line)) { + inBody = true; + } else if (!/^\s*\*\* /.test(line) && inBody) { + added += Number(line.startsWith("+")); + removed += Number(line.startsWith("-")); + } + } + return { files, added, removed }; + } + const target = readTarget(args); + if (!target) { + return undefined; + } + if (kind === "write") { + const content = readStringField(args, "content"); + return content === undefined + ? undefined + : { + files: [target], + added: content.length === 0 ? 0 : content.split(/\r\n|\r|\n/).length, + removed: 0, + }; + } + let added = 0; + let removed = 0; + let hasCompleteEdit = false; + for (const edit of readEdits(args)) { + const oldText = + typeof edit.oldText === "string" + ? edit.oldText + : typeof edit.old_string === "string" + ? edit.old_string + : undefined; + const newText = + typeof edit.newText === "string" + ? edit.newText + : typeof edit.new_string === "string" + ? edit.new_string + : undefined; + if (oldText === undefined || newText === undefined) { + continue; + } + hasCompleteEdit = true; + added += newText.length === 0 ? 0 : newText.split(/\r\n|\r|\n/).length; + removed += oldText.length === 0 ? 0 : oldText.split(/\r\n|\r|\n/).length; + } + return hasCompleteEdit ? { files: [target], added, removed } : undefined; +} diff --git a/src/tasks/task-registry-activity.ts b/src/tasks/task-registry-activity.ts index 7bc03afda472..96dec3c82633 100644 --- a/src/tasks/task-registry-activity.ts +++ b/src/tasks/task-registry-activity.ts @@ -1,15 +1,8 @@ -import path from "node:path"; -import { - asOptionalObjectRecord, - readStringField, -} from "@openclaw/normalization-core/record-coerce"; +import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { extractApplyPatchTargetPaths } from "../agents/apply-patch-paths.js"; -import { - resolveFileMutationToolName, - type FileMutationToolName, -} from "../agents/tool-mutation-names.js"; +import { readCompletedFileMutationDelta } from "../agents/file-mutation-args.js"; +import { resolveFileMutationToolName } from "../agents/tool-mutation-names.js"; import type { AgentEventPayload } from "../infra/agent-events.js"; import { isTerminalTaskStatus } from "./task-executor-policy.js"; import { cloneTaskRecord } from "./task-registry-records.js"; @@ -31,9 +24,6 @@ type TaskActivitySnapshot = { diffStat?: { files: number; added: number; removed: number }; }; -type DiffDelta = { files: string[]; added: number; removed: number }; -type EditPair = { oldText: string; newText: string }; - function activityFor(task: TaskRecord): TaskActivityOverlayState { const runId = task.runId ?? ""; const existing = taskActivityByTaskId.get(task.taskId); @@ -88,101 +78,6 @@ function updateStreamText( return lastLineSnippet(cumulative); } -function readTarget(record: Record): string | undefined { - const target = normalizeOptionalString(record.path ?? record.file_path ?? record.filePath); - return target ? path.resolve(target) : undefined; -} - -// Live progress is intentionally a best-effort count from submitted args, not a post-write diff. -function countLines(text: string): number { - return text.length === 0 ? 0 : text.split(/\r\n|\r|\n/).length; -} - -function readEditPairs(args: Record): EditPair[] { - const candidates = Array.isArray(args.edits) ? args.edits : [args]; - const pairs: EditPair[] = []; - for (const candidate of candidates) { - const edit = asOptionalObjectRecord(candidate); - if (!edit) { - continue; - } - const oldText = readStringField(edit, "oldText") ?? readStringField(edit, "old_string"); - const newText = readStringField(edit, "newText") ?? readStringField(edit, "new_string"); - if (oldText !== undefined && newText !== undefined) { - pairs.push({ oldText, newText }); - } - } - return pairs; -} - -function readPatchDelta(args: Record): DiffDelta | undefined { - if (typeof args.input !== "string") { - let added = 0; - let removed = 0; - const files: string[] = []; - for (const candidate of Array.isArray(args.changes) ? args.changes : []) { - const change = asOptionalObjectRecord(candidate); - const target = change ? readTarget(change) : undefined; - if (!change || !target) { - continue; - } - files.push(target); - const stat = asOptionalObjectRecord(change.stat); - added += - typeof stat?.added === "number" && Number.isFinite(stat.added) - ? Math.max(0, stat.added) - : 0; - removed += - typeof stat?.removed === "number" && Number.isFinite(stat.removed) - ? Math.max(0, stat.removed) - : 0; - } - return files.length > 0 ? { files, added, removed } : undefined; - } - const files = extractApplyPatchTargetPaths(args); - if (files.length === 0) { - return undefined; - } - let added = 0; - let removed = 0; - let inBody = false; - for (const line of args.input.split(/\r\n|\r|\n/)) { - if (/^\s*\*\*\* (?:Add|Update|Delete) File: /.test(line)) { - inBody = true; - } else if (!/^\s*\*\* /.test(line) && inBody) { - added += Number(line.startsWith("+")); - removed += Number(line.startsWith("-")); - } - } - return { files, added, removed }; -} - -function readDiffDelta( - kind: FileMutationToolName, - args: Record, -): DiffDelta | undefined { - if (kind === "apply_patch") { - return readPatchDelta(args); - } - const target = readTarget(args); - if (!target) { - return undefined; - } - if (kind === "write") { - return typeof args.content === "string" - ? { files: [target], added: countLines(args.content), removed: 0 } - : undefined; - } - const pairs = readEditPairs(args); - return pairs.length > 0 - ? { - files: [target], - added: pairs.reduce((total, pair) => total + countLines(pair.newText), 0), - removed: pairs.reduce((total, pair) => total + countLines(pair.oldText), 0), - } - : undefined; -} - function scheduleFlush(taskId: string, activity: TaskActivityOverlayState): void { if (activity.flushTimer) { return; @@ -238,7 +133,7 @@ export function recordTaskActivityEvent(task: TaskRecord, event: AgentEventPaylo const toolCallId = normalizeOptionalString(event.data.toolCallId); if (event.data.phase === "start") { const args = asOptionalObjectRecord(event.data.args); - const delta = args ? readDiffDelta(kind, args) : undefined; + const delta = args ? readCompletedFileMutationDelta(kind, args) : undefined; if (!toolCallId || !delta) { return false; }