Skip to content

Commit eb970bd

Browse files
committed
fix(tasks): repair terminal mirrored flow timestamps
1 parent 1184925 commit eb970bd

5 files changed

Lines changed: 156 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818
- Channels/Discord: ignore stale route-shaped conversation bindings after a Discord channel is reconfigured to another agent, while preserving explicit focus and subagent bindings. Fixes #73626. Thanks @ramitrkar-hash.
1919
- Agents/bootstrap: pass pending BOOTSTRAP.md contents through the first-run user prompt while keeping them out of privileged system context, and show limited bootstrap guidance when workspace file access is unavailable. Fixes #73622. Thanks @mark1010.
2020
- ACP/tasks: classify parent-owned ACP sessions as background work regardless of persistent runtime mode, and close terminal stale ACP sessions when no active binding remains, so delegated ACP output reports through the parent task notifier instead of acting like a normal foreground chat session. Refs #73609. Thanks @joerod26.
21+
- Tasks: keep terminal mirrored TaskFlow timestamps pinned to task completion time and let maintenance repair stale mirrors, so ACP terminal delivery updates no longer leave inconsistent flow audits. Refs #73609. Thanks @joerod26.
2122
- Gateway/sessions: add conservative stuck-session recovery that releases only stale session lanes while active embedded runs, reply operations, and lane tasks remain serialized, so queued follow-ups can drain without aborting legitimate long-running turns. Refs #73581, #73655, #73652, #73705, #73647, #73602, #73592, and #73601. Thanks @WS-Q0758, @bryangauvin, @spenceryang1996-dot, @bmilne1981, @mattmcintyre, @Vksh07, and @Spolen23.
2223
- Plugins: cache unchanged plugin manifest loads by file signature, reducing repeated JSON/JSON5 parsing and manifest normalization in bursty startup and runtime registry paths. Refs #73532 and #73647; carries forward #73678. Thanks @TheDutchRuler.
2324
- Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers.

src/tasks/task-flow-registry.maintenance.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { afterEach, describe, expect, it } from "vitest";
22
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
33
import { createRunningTaskRun } from "./task-executor.js";
44
import {
5+
createFlowRecord,
56
createManagedTaskFlow,
67
getTaskFlowById,
78
listTaskFlowRecords,
89
requestFlowCancel,
910
resetTaskFlowRegistryForTests,
1011
} from "./task-flow-registry.js";
1112
import {
13+
getInspectableTaskFlowAuditSummary,
1214
previewTaskFlowRegistryMaintenance,
1315
runTaskFlowRegistryMaintenance,
1416
} from "./task-flow-registry.maintenance.js";
@@ -109,6 +111,36 @@ describe("task-flow-registry maintenance", () => {
109111
});
110112
});
111113

114+
it("repairs terminal mirrored flows whose delivery updates outlived endedAt", async () => {
115+
await withTaskFlowMaintenanceStateDir(async () => {
116+
const flow = createFlowRecord({
117+
syncMode: "task_mirrored",
118+
ownerKey: "agent:main:main",
119+
goal: "Failed ACP task",
120+
status: "failed",
121+
createdAt: 100,
122+
updatedAt: 250,
123+
endedAt: 200,
124+
});
125+
126+
expect(getInspectableTaskFlowAuditSummary().byCode.inconsistent_timestamps).toBe(1);
127+
expect(previewTaskFlowRegistryMaintenance()).toEqual({
128+
reconciled: 1,
129+
pruned: 0,
130+
});
131+
132+
expect(await runTaskFlowRegistryMaintenance()).toEqual({
133+
reconciled: 1,
134+
pruned: 0,
135+
});
136+
expect(getTaskFlowById(flow.flowId)).toMatchObject({
137+
endedAt: 200,
138+
updatedAt: 200,
139+
});
140+
expect(getInspectableTaskFlowAuditSummary().byCode.inconsistent_timestamps).toBe(0);
141+
});
142+
});
143+
112144
it("does not finalize cancel-requested flows while a child task is still active", async () => {
113145
await withTaskFlowMaintenanceStateDir(async () => {
114146
const flow = createManagedTaskFlow({

src/tasks/task-flow-registry.maintenance.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type TaskFlowRegistryMaintenanceSummary = {
2222
function isTerminalFlow(flow: TaskFlowRecord): boolean {
2323
return (
2424
flow.status === "succeeded" ||
25+
flow.status === "blocked" ||
2526
flow.status === "failed" ||
2627
flow.status === "cancelled" ||
2728
flow.status === "lost"
@@ -88,6 +89,40 @@ function finalizeCancelledFlow(flow: TaskFlowRecord, now: number): boolean {
8889
return false;
8990
}
9091

92+
function shouldRepairTerminalMirroredFlowTimestamp(flow: TaskFlowRecord): boolean {
93+
if (flow.syncMode !== "task_mirrored" || !isTerminalFlow(flow)) {
94+
return false;
95+
}
96+
if (flow.endedAt == null || flow.endedAt < flow.createdAt) {
97+
return false;
98+
}
99+
return flow.updatedAt > flow.endedAt;
100+
}
101+
102+
function repairTerminalMirroredFlowTimestamp(flow: TaskFlowRecord): boolean {
103+
let current = flow;
104+
for (let attempt = 0; attempt < 2; attempt += 1) {
105+
if (!shouldRepairTerminalMirroredFlowTimestamp(current)) {
106+
return false;
107+
}
108+
const result = updateFlowRecordByIdExpectedRevision({
109+
flowId: current.flowId,
110+
expectedRevision: current.revision,
111+
patch: {
112+
updatedAt: current.endedAt,
113+
},
114+
});
115+
if (result.applied) {
116+
return true;
117+
}
118+
if (result.reason === "not_found" || !result.current) {
119+
return false;
120+
}
121+
current = result.current;
122+
}
123+
return false;
124+
}
125+
91126
export function getInspectableTaskFlowAuditSummary(): TaskFlowAuditSummary {
92127
return summarizeTaskFlowAuditFindings(listTaskFlowAuditFindings());
93128
}
@@ -97,6 +132,10 @@ export function previewTaskFlowRegistryMaintenance(): TaskFlowRegistryMaintenanc
97132
let reconciled = 0;
98133
let pruned = 0;
99134
for (const flow of listTaskFlowRecords()) {
135+
if (shouldRepairTerminalMirroredFlowTimestamp(flow)) {
136+
reconciled += 1;
137+
continue;
138+
}
100139
if (shouldFinalizeCancelledFlow(flow)) {
101140
reconciled += 1;
102141
continue;
@@ -117,6 +156,12 @@ export async function runTaskFlowRegistryMaintenance(): Promise<TaskFlowRegistry
117156
if (!current) {
118157
continue;
119158
}
159+
if (shouldRepairTerminalMirroredFlowTimestamp(current)) {
160+
if (repairTerminalMirroredFlowTimestamp(current)) {
161+
reconciled += 1;
162+
}
163+
continue;
164+
}
120165
if (shouldFinalizeCancelledFlow(current)) {
121166
if (finalizeCancelledFlow(current, now)) {
122167
reconciled += 1;

src/tasks/task-flow-registry.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,46 @@ describe("task-flow-registry", () => {
299299
status: "blocked",
300300
blockedTaskId: "task-blocked",
301301
blockedSummary: "Writable session required.",
302+
endedAt: 200,
303+
updatedAt: 200,
304+
});
305+
306+
const delivered = syncFlowFromTask({
307+
taskId: "task-blocked",
308+
parentFlowId: mirrored.flowId,
309+
status: "succeeded",
310+
terminalOutcome: "blocked",
311+
notifyPolicy: "done_only",
312+
label: "Fix permissions",
313+
task: "Fix permissions",
314+
lastEventAt: 250,
315+
endedAt: 200,
316+
terminalSummary: "Writable session required.",
317+
});
318+
expect(delivered).toMatchObject({
319+
flowId: mirrored.flowId,
320+
status: "blocked",
321+
endedAt: 200,
322+
updatedAt: 200,
323+
});
324+
325+
const terminalCreated = createTaskFlowForTask({
326+
task: {
327+
ownerKey: "agent:main:main",
328+
taskId: "task-failed",
329+
notifyPolicy: "done_only",
330+
status: "failed",
331+
label: "Fail permissions",
332+
task: "Fail permissions",
333+
createdAt: 100,
334+
lastEventAt: 300,
335+
endedAt: 200,
336+
},
337+
});
338+
expect(terminalCreated).toMatchObject({
339+
status: "failed",
340+
endedAt: 200,
341+
updatedAt: 200,
302342
});
303343

304344
const managed = createManagedTaskFlow({

src/tasks/task-flow-registry.ts

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,27 @@ export function deriveTaskFlowStatusFromTask(
211211
return "failed";
212212
}
213213

214+
function isTerminalTaskFlowStatus(status: TaskFlowStatus): boolean {
215+
return (
216+
status === "succeeded" ||
217+
status === "blocked" ||
218+
status === "failed" ||
219+
status === "cancelled" ||
220+
status === "lost"
221+
);
222+
}
223+
224+
function resolveTaskMirroredFlowTiming(
225+
task: Pick<TaskRecord, "createdAt" | "lastEventAt" | "endedAt">,
226+
isTerminal: boolean,
227+
): { updatedAt: number; endedAt?: number } {
228+
if (!isTerminal) {
229+
return { updatedAt: task.lastEventAt ?? task.createdAt };
230+
}
231+
const endedAt = task.endedAt ?? task.lastEventAt ?? task.createdAt;
232+
return { updatedAt: endedAt, endedAt };
233+
}
234+
214235
function ensureFlowRegistryReady() {
215236
if (restoreAttempted) {
216237
return;
@@ -383,15 +404,10 @@ export function createTaskFlowForTask(params: {
383404
requesterOrigin?: TaskFlowRecord["requesterOrigin"];
384405
}): TaskFlowRecord {
385406
const terminalFlowStatus = deriveTaskFlowStatusFromTask(params.task);
386-
const isTerminal =
387-
terminalFlowStatus === "succeeded" ||
388-
terminalFlowStatus === "blocked" ||
389-
terminalFlowStatus === "failed" ||
390-
terminalFlowStatus === "cancelled" ||
391-
terminalFlowStatus === "lost";
392-
const endedAt = isTerminal
393-
? (params.task.endedAt ?? params.task.lastEventAt ?? params.task.createdAt)
394-
: undefined;
407+
const timing = resolveTaskMirroredFlowTiming(
408+
params.task,
409+
isTerminalTaskFlowStatus(terminalFlowStatus),
410+
);
395411
return createFlowRecord({
396412
syncMode: "task_mirrored",
397413
ownerKey: params.task.ownerKey,
@@ -404,8 +420,8 @@ export function createTaskFlowForTask(params: {
404420
terminalFlowStatus === "blocked" ? normalizeOptionalString(params.task.taskId) : undefined,
405421
blockedSummary: resolveFlowBlockedSummary(params.task),
406422
createdAt: params.task.createdAt,
407-
updatedAt: params.task.lastEventAt ?? params.task.createdAt,
408-
...(endedAt !== undefined ? { endedAt } : {}),
423+
updatedAt: timing.updatedAt,
424+
...(timing.endedAt !== undefined ? { endedAt: timing.endedAt } : {}),
409425
});
410426
}
411427

@@ -597,12 +613,15 @@ export function syncFlowFromTask(
597613
return flow;
598614
}
599615
const terminalFlowStatus = deriveTaskFlowStatusFromTask(task);
600-
const isTerminal =
601-
terminalFlowStatus === "succeeded" ||
602-
terminalFlowStatus === "blocked" ||
603-
terminalFlowStatus === "failed" ||
604-
terminalFlowStatus === "cancelled" ||
605-
terminalFlowStatus === "lost";
616+
const isTerminal = isTerminalTaskFlowStatus(terminalFlowStatus);
617+
const timing = resolveTaskMirroredFlowTiming(
618+
{
619+
createdAt: flow.createdAt,
620+
lastEventAt: task.lastEventAt,
621+
endedAt: task.endedAt,
622+
},
623+
isTerminal,
624+
);
606625
return updateFlowRecordByIdUnchecked(flowId, {
607626
status: terminalFlowStatus,
608627
notifyPolicy: task.notifyPolicy,
@@ -611,10 +630,10 @@ export function syncFlowFromTask(
611630
blockedSummary:
612631
terminalFlowStatus === "blocked" ? (resolveFlowBlockedSummary(task) ?? null) : null,
613632
waitJson: null,
614-
updatedAt: task.lastEventAt ?? Date.now(),
633+
updatedAt: timing.updatedAt,
615634
...(isTerminal
616635
? {
617-
endedAt: task.endedAt ?? task.lastEventAt ?? Date.now(),
636+
endedAt: timing.endedAt ?? timing.updatedAt,
618637
}
619638
: { endedAt: null }),
620639
});

0 commit comments

Comments
 (0)