mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
8c567306ba
commit
73ae583263
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user