From fd71852fe55bb05da7eb956fef071c6c613abaaf Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 27 Jun 2026 17:54:50 +0800 Subject: [PATCH] fix: seal Workbench admission failure projection --- .../scripts/workbench-server-state.test.ts | 11 +++ .../src/stores/workbench-server-state.ts | 2 +- web/hwlab-cloud-web/src/stores/workbench.ts | 95 ++++++++++++++++++- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts index 90093a39..7fc76569 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -131,6 +131,17 @@ test("Workbench realtime agent snapshots cannot create duplicate assistant messa assert.deepEqual(selectActiveMessages(state, "ses_steer").map((message) => message.messageId), ["msg_user", "msg_agent"]); }); +test("Workbench realtime agent snapshots can seed an empty session projection", () => { + let state = createWorkbenchServerState(); + state = reduceWorkbenchServerState(state, { + type: "message.snapshot", + sessionId: "ses_empty_snapshot", + message: { id: "msg_agent_seed", messageId: "msg_agent_seed", role: "agent", title: "Code Agent", text: "Failed to fetch", status: "failed", createdAt: "2026-06-24T00:00:01.000Z", sessionId: "ses_empty_snapshot", traceId: "trc_empty_snapshot" } + }); + + assert.deepEqual(selectActiveMessages(state, "ses_empty_snapshot").map((message) => `${message.role}:${message.messageId}:${message.text}`), ["agent:msg_agent_seed:Failed to fetch"]); +}); + test("Workbench session messages keep sealed terminal timing on bulk refresh", () => { let state = createWorkbenchServerState(); state = reduceWorkbenchServerState(state, { diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index fff3df7e..be160560 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -151,7 +151,7 @@ function reduceMessageSnapshot(state: WorkbenchServerState, sessionId: string | if (!id || !message) return state; const existing = state.messagesBySessionId[id] ?? state.sessionsById[id]?.messages ?? []; const index = existing.findIndex((item) => messageMatchesSnapshot(item, message)); - if (index < 0 && message.role === "agent") return state; + if (index < 0 && message.role === "agent" && existing.length > 0) return state; const merged = index >= 0 ? existing.map((item, itemIndex) => itemIndex === index ? mergeMessageSnapshot(item, message) : item) : [...existing, message]; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index bf09e4ea..90051a4b 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -831,9 +831,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project restartRealtime("steer-not-admitted"); return false; } - markMessage(traceId, { status: "failed", text: failureMessage, error: failureError, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, blocker: projection.blocker ?? null }); - applyProjectionDiagnostic(traceId, projection); - reduceServerState({ type: "turn.forget", traceId }); + projectLocalAdmissionFailure({ traceId, sessionId, threadId, message: failureMessage, error: failureError, projection, submittedAt }); failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error"); await clearActiveTrace(traceId, steerMode && response.status === 404 ? "steer-trace-not-found" : "submit-not-admitted"); chatPending.value = false; @@ -1591,6 +1589,97 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project scheduleSessionListRefresh(selectedSessionId.value, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); } + function projectLocalAdmissionFailure(input: { traceId: string; sessionId: string; threadId: string | null; message: string; error: ChatMessage["error"]; projection: ProjectionDiagnostic; submittedAt: string }): void { + const finishedAt = new Date().toISOString(); + const startedAt = firstNonEmptyString(input.submittedAt, finishedAt) ?? finishedAt; + const durationMs = positiveDurationBetween(startedAt, finishedAt) ?? 1000; + const timing = { startedAt, lastEventAt: finishedAt, finishedAt, durationMs, valuesRedacted: true } as WorkbenchTurnTimingProjection; + const event = { + projectedSeq: 1, + ts: finishedAt, + createdAt: finishedAt, + label: "workbench:admission:failed", + type: "event", + status: "failed", + message: input.message, + elapsedMs: durationMs, + terminal: true, + traceId: input.traceId, + sessionId: input.sessionId, + threadId: input.threadId, + source: "workbench-web", + projection: input.projection, + projectionStatus: input.projection.projectionStatus ?? null, + projectionHealth: input.projection.projectionHealth ?? null, + error: input.error, + valuesRedacted: true + } as TraceEvent; + const runnerTrace = { + traceId: input.traceId, + sessionId: input.sessionId, + threadId: input.threadId ?? undefined, + status: "failed", + traceStatus: "failed", + events: [event], + eventCount: 1, + eventsCompacted: false, + fullTraceLoaded: true, + hasMore: false, + truncated: false, + nextProjectedSeq: 1, + range: { afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 1, limit: TRACE_HYDRATION_PAGE_LIMIT, returned: 1, total: 1 }, + lastEventLabel: "workbench:admission:failed", + error: input.error ?? undefined, + projection: input.projection, + projectionStatus: input.projection.projectionStatus ?? null, + projectionHealth: input.projection.projectionHealth ?? null, + blocker: input.projection.blocker ?? null, + timing, + startedAt, + lastEventAt: finishedAt, + finishedAt, + durationMs, + eventSource: "workbench-local", + updatedAt: finishedAt + } as NonNullable; + const timingPatch = messageTimingPatchFromProjection(timing); + markMessage(input.traceId, { + status: "failed", + text: input.message, + error: input.error, + projection: input.projection, + projectionStatus: input.projection.projectionStatus ?? null, + projectionHealth: input.projection.projectionHealth ?? null, + blocker: input.projection.blocker ?? null, + runnerTrace, + traceAutoLifecycle: "terminal", + ...timingPatch + }); + rememberTraceAuthority(runnerTrace); + reduceServerState({ + type: "turn.status", + turn: { traceId: input.traceId, status: "failed", running: false, terminal: true, sessionId: input.sessionId, threadId: input.threadId, updatedAt: finishedAt, projection: input.projection, loadedAt: finishedAt } + }); + reduceServerState({ + type: "session.status", + session: { sessionId: input.sessionId, status: "failed", updatedAt: finishedAt, lastTraceId: input.traceId, projection: input.projection, loadedAt: finishedAt } + }); + const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null; + const projectedMessages = serverState.value.messagesBySessionId[input.sessionId] ?? messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId); + rememberSessionList(mergeSessionIntoList(sessions.value, { + ...(existing ?? {}), + sessionId: input.sessionId, + threadId: firstNonEmptyString(input.threadId, existing?.threadId), + status: "failed", + updatedAt: finishedAt, + lastTraceId: input.traceId, + messageCount: Math.max(existing?.messageCount ?? 0, projectedMessages.length), + messages: projectedMessages + } as WorkbenchSessionRecord)); + publishWorkbenchProjectionSignal(input.sessionId, input.traceId, "submit-admission-failed"); + scheduleSessionListRefresh(input.sessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); + } + function projectOptimisticRunningTurn(input: { sessionId: string; threadId: string | null; traceId: string; userText: string }): void { const now = new Date().toISOString(); reduceServerState({ type: "turn.status", turn: { traceId: input.traceId, status: "running", running: true, terminal: false, sessionId: input.sessionId, threadId: input.threadId, updatedAt: now, loadedAt: now } });