fix: keep terminal workbench timing sealed

This commit is contained in:
lyon
2026-06-26 07:30:23 +08:00
parent 4ba7b6a244
commit 79e38267f1
2 changed files with 109 additions and 3 deletions
@@ -574,6 +574,99 @@ test("workbench read model exposes session, messages, turn, and trace without wr
}
});
test("workbench terminal timing ignores late projection updatedAt after sealed result (#2132)", async () => {
const traceId = "trc_workbench_terminal_late_projection_update";
const session = {
id: "ses_workbench_terminal_late_projection_update",
projectId: "prj_hwpod_workbench",
agentId: "hwlab-code-agent",
status: "completed",
startedAt: "2026-06-25T23:07:10.153Z",
ownerUserId: ACTOR.id,
conversationId: "cnv_workbench_terminal_late_projection_update",
threadId: "thread-workbench-terminal-late-projection-update",
lastTraceId: traceId,
updatedAt: "2026-06-25T23:08:27.097Z",
session: {
sessionStatus: "completed",
lastTraceId: traceId,
finalResponse: "P5 durable facts smoke OK",
messages: [
{ role: "user", text: "请直接回答:P5 durable facts smoke OK", traceId, status: "sent", createdAt: "2026-06-25T23:07:10.153Z" },
{ role: "agent", text: "P5 durable facts smoke OK", traceId, status: "completed", createdAt: "2026-06-25T23:07:10.976Z", updatedAt: "2026-06-25T23:08:27.097Z" }
],
valuesRedacted: true,
secretMaterialStored: false
}
};
const facts = emptyFacts();
mergeFacts(facts, buildDurableFactsForSession({
session,
status: "completed",
finalText: "P5 durable facts smoke OK",
events: [
{ projectedSeq: 1, sourceSeq: 1, type: "backend", status: "running", label: "agentrun:backend:admitted", createdAt: "2026-06-25T23:07:10.153Z", updatedAt: "2026-06-25T23:07:10.153Z" },
{ projectedSeq: 31, sourceSeq: 31, type: "terminal", eventType: "terminal", status: "completed", label: "agentrun:result:completed", terminal: true, createdAt: "2026-06-25T23:07:32.418Z", occurredAt: "2026-06-25T23:07:32.418Z", updatedAt: "2026-06-25T23:08:27.127Z" }
],
lastProjectedSeq: 31
}));
const checkpointTiming = {
startedAt: "2026-06-25T23:07:10.153Z",
lastEventAt: "2026-06-25T23:07:32.418Z",
finishedAt: "2026-06-25T23:07:32.418Z",
durationMs: 22265,
valuesRedacted: true
};
const turnTiming = {
startedAt: "2026-06-25T23:07:10.976Z",
lastEventAt: "2026-06-25T23:08:27.097Z",
finishedAt: "2026-06-25T23:07:32.418Z",
durationMs: 21442,
valuesRedacted: true
};
for (const record of [...facts.sessions, ...facts.messages.filter((message) => message.role !== "user"), ...facts.turns]) {
Object.assign(record, turnTiming, { timing: turnTiming, updatedAt: "2026-06-25T23:08:27.097Z" });
}
Object.assign(facts.checkpoints[0], checkpointTiming, { timing: checkpointTiming, updatedAt: "2026-06-25T23:08:27.127Z" });
Object.assign(facts.traceEvents.at(-1), checkpointTiming, { timing: checkpointTiming, updatedAt: "2026-06-25T23:08:27.127Z" });
const runtimeStore = {
async queryWorkbenchFacts(params = {}) {
const filtered = filterFacts(facts, params);
return {
facts: filtered,
count: Object.values(filtered).reduce((sum, rows) => sum + rows.length, 0),
persistence: { adapter: "test-durable-workbench-facts", durable: true }
};
}
};
const accessController = {
async ensureBootstrap() {},
async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
};
const server = createCloudApiServer({ accessController, workbenchRuntime: runtimeStore, codeAgentChatResults: createCodeAgentChatResultStore() });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`);
assert.equal(turn.status, 200);
assert.equal(turn.body.turn.startedAt, "2026-06-25T23:07:10.153Z");
assert.equal(turn.body.turn.lastEventAt, "2026-06-25T23:07:32.418Z");
assert.equal(turn.body.turn.finishedAt, "2026-06-25T23:07:32.418Z");
assert.equal(turn.body.turn.durationMs, 22265);
assert.equal(turn.body.turn.trace.updatedAt, "2026-06-25T23:08:27.127Z");
const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=80`);
assert.equal(trace.status, 200);
assert.equal(trace.body.lastEventAt, "2026-06-25T23:07:32.418Z");
assert.equal(trace.body.finishedAt, "2026-06-25T23:07:32.418Z");
assert.equal(trace.body.durationMs, 22265);
assert.equal(trace.body.events.at(-1).updatedAt, "2026-06-25T23:08:27.127Z");
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
test("workbench read model recovers trace events from durable projection without duplicating final response", async () => {
const traceStore = createCodeAgentTraceStore();
const traceId = "trc_workbench_durable_reload";
+16 -3
View File
@@ -1276,9 +1276,12 @@ function factTraceTimingProjection(events = [], checkpoint = null, status = null
const observedAt = new Date().toISOString();
const eventStartTimes = factArray(events).flatMap((event) => [event?.createdAt, event?.occurredAt]);
const eventActivityTimes = factArray(events).flatMap((event) => [event?.createdAt, event?.occurredAt, event?.updatedAt]);
const terminalEventTimes = factArray(events)
.filter(factTraceEventIsTerminalAuthority)
.flatMap(factTraceEventTerminalTimes);
const startedAt = firstTimestampIso(checkpointTiming.startedAt, ...eventStartTimes);
const lastEventAt = latestTimestampIso(checkpointTiming.lastEventAt, ...eventActivityTimes);
const finishedAt = terminal ? latestTimestampIso(checkpointTiming.finishedAt, lastEventAt) : null;
const finishedAt = terminal ? latestTimestampIso(checkpointTiming.finishedAt, ...terminalEventTimes) : null;
const lastEventAt = terminal && finishedAt ? finishedAt : latestTimestampIso(checkpointTiming.lastEventAt, ...eventActivityTimes);
const durationMs = elapsedFactMs(startedAt, terminal ? finishedAt : observedAt);
const lastEventAgeMs = terminal ? null : elapsedFactMs(lastEventAt, observedAt);
return {
@@ -1293,6 +1296,16 @@ function factTraceTimingProjection(events = [], checkpoint = null, status = null
};
}
function factTraceEventIsTerminalAuthority(event = null) {
const eventType = textValue(event?.eventType ?? event?.type);
return event?.terminal === true || event?.sealed === true || eventType === "terminal";
}
function factTraceEventTerminalTimes(event = null) {
const source = objectValue(event?.timing);
return [source?.finishedAt, event?.finishedAt, source?.lastEventAt, event?.lastEventAt, event?.occurredAt, event?.createdAt];
}
function factTraceEventDto(event, index) {
const seq = factProjectedSeq(event);
if (!seq) return null;
@@ -1368,8 +1381,8 @@ function factCombinedTimingProjection(status = null, ...records) {
const observedAt = new Date().toISOString();
const timings = records.map((record) => factTimingSource(record)).filter(Boolean);
const startedAt = firstTimestampIso(...timings.map((timing) => timing.startedAt));
const lastEventAt = latestTimestampIso(...timings.map((timing) => timing.lastEventAt));
const finishedAt = terminal ? latestTimestampIso(...timings.map((timing) => timing.finishedAt)) : null;
const lastEventAt = terminal && finishedAt ? finishedAt : latestTimestampIso(...timings.map((timing) => timing.lastEventAt));
const durationMs = elapsedFactMs(startedAt, terminal ? finishedAt : observedAt);
const lastEventAgeMs = terminal ? null : elapsedFactMs(lastEventAt, observedAt);
return { startedAt, lastEventAt, finishedAt, durationMs, observedAt: terminal ? null : observedAt, lastEventAgeMs, valuesRedacted: true };