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
This commit is contained in:
Peter Steinberger
2026-08-11 12:30:49 -07:00
committed by GitHub
parent 8c567306ba
commit 73ae583263
6 changed files with 169 additions and 182 deletions
+2 -2
View File
@@ -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",
@@ -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
@@ -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)
}
+3 -59
View File
@@ -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<string, unknown>): { 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<string, unknown>;
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<string, unknown>,
): { 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<string, unknown>): Record<string, unknown> | 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);
+156
View File
@@ -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, unknown>): string | undefined {
const target = normalizeOptionalString(record.path ?? record.file_path ?? record.filePath);
return target ? path.resolve(target) : undefined;
}
function readEdits(args: Record<string, unknown>): Record<string, unknown>[] {
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<string, unknown>,
): FileMutationLineCount {
if (kind === "write") {
return { added: countNewlines(readStringField(args, "content")), removed: 0 };
}
if (kind === "edit") {
return readEdits(args).reduce<FileMutationLineCount>(
(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<string, unknown>): 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<string, unknown>,
): 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;
}
+4 -109
View File
@@ -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, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>,
): 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;
}