Merge pull request #2235 from pikasTech/issue/2229-workbench-terminal-projection

fix: seal Workbench admission failure projection
This commit is contained in:
Lyon
2026-06-27 17:55:50 +08:00
committed by GitHub
3 changed files with 104 additions and 4 deletions
@@ -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, {
@@ -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];
+92 -3
View File
@@ -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<ChatMessage["runnerTrace"]>;
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 } });