fix: project workbench session current turn authority

This commit is contained in:
UniDesk Codex
2026-07-02 08:43:03 +08:00
parent 8b6c2eaa60
commit 683042900f
4 changed files with 302 additions and 35 deletions
@@ -1661,6 +1661,60 @@ test("workbench read model projects current turn running state for idle session
}
});
test("workbench read model projects newest running turn over stale completed session lastTraceId", async () => {
const oldTraceId = "trc_workbench_stale_completed_old";
const newTraceId = "trc_workbench_new_running_current";
const session = {
id: "ses_workbench_stale_completed_new_running",
projectId: "prj_hwpod_workbench",
agentId: "hwlab-code-agent",
status: "completed",
ownerUserId: ACTOR.id,
conversationId: "cnv_workbench_stale_completed_new_running",
threadId: "thread-workbench-stale-completed-new-running",
lastTraceId: oldTraceId,
updatedAt: "2026-07-01T00:00:02.000Z",
session: {
sessionStatus: "completed",
lastTraceId: oldTraceId,
messages: [
{ messageId: "msg_workbench_old_user", role: "user", text: "first", traceId: oldTraceId, status: "sent", projectedSeq: 10 },
{ messageId: "msg_workbench_old_agent", role: "agent", text: "first done", traceId: oldTraceId, status: "completed", projectedSeq: 20 }
]
}
};
const facts = emptyFacts();
mergeFacts(facts, buildDurableFactsForSession({ session, status: "completed", finalText: "first done", lastProjectedSeq: 20 }));
facts.messages.push(
{ messageId: "msg_workbench_new_user", sessionId: session.id, turnId: newTraceId, traceId: newTraceId, role: "user", status: "sent", text: "second", projectedSeq: 30, sourceSeq: 30, sourceEventId: "evt_new_user", terminal: false, sealed: false, updatedAt: "2026-07-01T00:00:03.000Z", valuesRedacted: true },
{ messageId: "msg_workbench_new_agent", sessionId: session.id, turnId: newTraceId, traceId: newTraceId, role: "agent", status: "running", text: "", projectedSeq: 40, sourceSeq: 40, sourceEventId: "evt_new_agent", terminal: false, sealed: false, updatedAt: "2026-07-01T00:00:04.000Z", valuesRedacted: true }
);
facts.turns.push({ turnId: newTraceId, sessionId: session.id, traceId: newTraceId, messageId: "msg_workbench_new_agent", status: "running", projectedSeq: 40, sourceSeq: 40, sourceEventId: "evt_new_turn", terminal: false, sealed: false, diagnostic: { projectionStatus: "projecting", projectionHealth: "healthy", valuesRedacted: true }, timing: { startedAt: "2026-07-01T00:00:03.000Z", lastEventAt: "2026-07-01T00:00:04.000Z", finishedAt: null, durationMs: null, valuesRedacted: true }, startedAt: "2026-07-01T00:00:03.000Z", lastEventAt: "2026-07-01T00:00:04.000Z", finishedAt: null, durationMs: null, updatedAt: "2026-07-01T00:00:04.000Z", valuesRedacted: true });
facts.checkpoints.push({ traceId: newTraceId, sessionId: session.id, turnId: newTraceId, projectedSeq: 40, sourceSeq: 40, sourceEventId: "evt_new_checkpoint", projectionStatus: "projecting", projectionHealth: "healthy", terminal: false, sealed: false, diagnostic: { projectionStatus: "projecting", projectionHealth: "healthy", valuesRedacted: true }, updatedAt: "2026-07-01T00:00:04.000Z", valuesRedacted: true });
const runtimeStore = createRuntimeStoreFromFacts(facts);
const accessController = {
store: { async listAgentSessionsForUser() { return [session]; } },
async ensureBootstrap() {},
async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; }
};
const server = createCloudApiServer({ accessController, traceStore: createCodeAgentTraceStore(), runtimeStore, codeAgentChatResults: createCodeAgentChatResultStore() });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const sessions = await getJson(port, `/v1/workbench/sessions?includeSessionId=${encodeURIComponent(session.id)}`);
assert.equal(sessions.status, 200);
assert.equal(sessions.body.sessions[0].lastTraceId, newTraceId);
assert.equal(sessions.body.sessions[0].status, "running");
assert.equal(sessions.body.sessions[0].running, true);
assert.equal(sessions.body.sessions[0].terminal, false);
assert.equal(sessions.body.sessions[0].turnSummary.traceId, newTraceId);
assert.equal(sessions.body.sessions[0].turnSummary.status, "running");
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
test("workbench read model projects completed current turn for idle session summary", async () => {
const traceStore = createCodeAgentTraceStore();
const results = createCodeAgentChatResultStore();
+52 -4
View File
@@ -872,8 +872,7 @@ function queryRequestsOnlySessionFacts(query = {}) {
async function queryFactsForSessionSummaries(readModel, sessions = []) {
const sessionIds = uniqueText(sessions.map(factSessionId));
const traceIds = uniqueText(sessions.map(factLastTraceId));
if (sessionIds.length === 0 && traceIds.length === 0) return { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
if (sessionIds.length === 0 && uniqueText(sessions.map(factLastTraceId)).length === 0) return { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
// Session list summary is hot and is also sampled by the two-page web-probe
// observer. Avoid parallel fan-out here so one list request cannot consume
// multiple durable-read connections while another page is refreshing.
@@ -881,6 +880,7 @@ async function queryFactsForSessionSummaries(readModel, sessions = []) {
? await readModel.queryFacts({ sessionIds, families: ["messages", "parts", "turns"] })
: { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
if (bySession.error) return bySession;
const traceIds = summaryTraceIds(sessions, bySession.facts);
const byTrace = traceIds.length > 0
? await readModel.queryFacts({ traceIds, families: ["checkpoints"] })
: { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
@@ -889,6 +889,13 @@ async function queryFactsForSessionSummaries(readModel, sessions = []) {
return { facts, count: Object.values(facts).reduce((sum, rows) => sum + rows.length, 0), durable: bySession.durable ?? byTrace.durable, persistence: bySession.persistence ?? byTrace.persistence ?? null };
}
function summaryTraceIds(sessions = [], facts = {}) {
return uniqueText([
...sessions.map(factLastTraceId),
...sessions.map((session) => factCurrentTraceForSession(facts, factSessionId(session), session)?.traceId)
]);
}
function visibleFactSessions(facts, actor) {
return hideShadowedAgentRunAliasSessions(factArray(facts?.sessions)
.filter((session) => canActorReadFactSession(session, actor))
@@ -945,7 +952,8 @@ function factSessionMatchesRouteId(session, routeId) {
function factSessionSummary(session, facts = {}) {
const sessionId = factSessionId(session);
if (!sessionId) return null;
const traceId = factLastTraceId(session) ?? factLatestTraceIdForSession(facts, sessionId);
const currentTrace = factCurrentTraceForSession(facts, sessionId, session);
const traceId = currentTrace?.traceId ?? factLastTraceId(session) ?? factLatestTraceIdForSession(facts, sessionId);
const projection = traceId ? factProjectionForTrace(facts, traceId) : null;
const turn = traceId ? factTurnForTrace(facts, traceId) : null;
const checkpoint = traceId ? factCheckpointForTrace(facts, traceId) : null;
@@ -957,7 +965,7 @@ function factSessionSummary(session, facts = {}) {
const messageTerminalStatus = factTerminalStatusFromMessageProjection(terminalMessage);
const turnStatus = normalizeStatus(turn?.status);
const launchContext = compactLaunchContext(session?.sessionJson?.launchContext);
const status = normalizeStatus(checkpointStatus ?? traceStatus ?? messageTerminalStatus ?? turnStatus ?? session?.status);
const status = normalizeStatus(checkpointStatus ?? traceStatus ?? messageTerminalStatus ?? nonUnknownStatus(turnStatus) ?? nonUnknownStatus(currentTrace?.status) ?? session?.status);
const timing = traceId
? factCombinedTimingProjection(status, checkpoint, messageTerminalStatus ? terminalMessage : null, turn, trace)
: factTimingProjection(session, status);
@@ -1613,6 +1621,46 @@ function factLatestTraceIdForSession(facts = {}, sessionId) {
return safeTraceId(turn?.traceId) ?? null;
}
function factCurrentTraceForSession(facts = {}, sessionId, session = null) {
const candidates = [];
const sessionTraceId = factLastTraceId(session);
if (sessionTraceId) candidates.push(factTraceCandidate(session, sessionTraceId, normalizeStatus(session?.status), 0));
for (const message of factArray(facts.messages)) {
if (message?.sessionId !== sessionId || !safeTraceId(message?.traceId)) continue;
candidates.push(factMessageTraceCandidate(message));
}
for (const turn of factArray(facts.turns)) {
if (turn?.sessionId !== sessionId || !safeTraceId(turn?.traceId)) continue;
candidates.push(factTraceCandidate(turn, safeTraceId(turn.traceId), normalizeStatus(turn?.status), 3));
}
return candidates.sort(compareTraceCandidatesDesc)[0] ?? null;
}
function factMessageTraceCandidate(message) {
const role = textValue(message?.role);
let status = normalizeStatus(message?.status);
let priority = 1;
if (isAssistantLikeRole(role)) {
priority = 2;
if (status === "unknown") status = "running";
} else if (safeTraceId(message?.traceId)) {
status = "running";
}
return factTraceCandidate(message, safeTraceId(message?.traceId), status, priority);
}
function factTraceCandidate(record, traceId, status, priority) {
return { traceId: safeTraceId(traceId) ?? null, status: normalizeStatus(status), seq: factSeq(record), updatedAt: factUpdatedAt(record), priority };
}
function compareTraceCandidatesDesc(left, right) {
return compareNumberDesc(left?.seq, right?.seq) || compareTimestampDesc(left?.updatedAt, right?.updatedAt) || compareNumberDesc(left?.priority, right?.priority);
}
function nonUnknownStatus(status) {
return normalizeStatus(status) === "unknown" ? null : status;
}
function factTurnId(turn, fallbackTraceId = null) {
return safeTurnId(turn?.turnId) ?? safeTraceId(turn?.turnId) ?? safeTraceId(fallbackTraceId) ?? null;
}
+158 -31
View File
@@ -1346,44 +1346,55 @@ func (s *Server) queryIncludedSession(ctx context.Context, routeID string) (map[
func (s *Server) querySummaryFacts(ctx context.Context, sessions []map[string]any) (map[string][]any, error) {
sessionIDs := uniqueStrings(mapSessions(sessions, sessionID))
traceIDs := uniqueStrings(mapSessions(sessions, lastTraceID))
facts := emptyFacts()
type summaryFactsResult struct {
kind string
facts map[string][]any
err error
}
resultCount := 0
results := make(chan summaryFactsResult, 2)
if len(sessionIDs) > 0 {
resultCount++
go func() {
messageSummaries, err := s.queryMessageSummaries(ctx, sessionIDs)
results <- summaryFactsResult{kind: "messageSummaries", facts: map[string][]any{"messageSummaries": messageSummaries}, err: err}
}()
messageSummaries, err := s.queryMessageSummaries(ctx, sessionIDs)
if err != nil {
return nil, err
}
facts = mergeFacts(facts, map[string][]any{"messageSummaries": messageSummaries})
bySession, err := s.queryFacts(ctx, factQuery{SessionIDs: sessionIDs, Families: []string{"messages", "turns"}, Limit: summarySessionFactLimit(len(sessionIDs)), MessagesOrder: "updated_desc", TurnsOrder: "updated_desc"})
if err != nil {
return nil, err
}
facts = mergeFacts(facts, bySession)
}
traceIDs := summaryTraceIDs(sessions, facts)
if len(traceIDs) > 0 {
resultCount++
go func() {
byTrace, err := s.queryFacts(ctx, factQuery{TraceIDs: traceIDs, Families: []string{"turns", "checkpoints"}, Limit: len(traceIDs), TurnsOrder: "updated_desc", CheckpointsOrder: "updated_desc"})
results <- summaryFactsResult{kind: "traceFacts", facts: byTrace, err: err}
}()
}
for i := 0; i < resultCount; i++ {
result := <-results
if result.err != nil {
return nil, result.err
}
switch result.kind {
case "messageSummaries":
facts = mergeFacts(facts, result.facts)
case "traceFacts":
facts = mergeFacts(facts, result.facts)
byTrace, err := s.queryFacts(ctx, factQuery{TraceIDs: traceIDs, Families: []string{"turns", "checkpoints"}, Limit: len(traceIDs), TurnsOrder: "updated_desc", CheckpointsOrder: "updated_desc"})
if err != nil {
return nil, err
}
facts = mergeFacts(facts, byTrace)
}
return facts, nil
}
func summarySessionFactLimit(sessionCount int) int {
if sessionCount <= 0 {
return 0
}
limit := sessionCount * 8
if limit < sessionCount {
return sessionCount
}
if limit > 1000 {
return 1000
}
return limit
}
func summaryTraceIDs(sessions []map[string]any, facts map[string][]any) []string {
values := mapSessions(sessions, lastTraceID)
for _, session := range sessions {
if candidate := currentTraceForSession(session, facts, sessionID(session)); candidate.traceID != "" {
values = append(values, candidate.traceID)
}
}
return uniqueStrings(values)
}
func (s *Server) queryFacts(ctx context.Context, query factQuery) (map[string][]any, error) {
families := factFamilySet(query)
facts := emptyFacts()
@@ -2047,7 +2058,8 @@ func sessionSummary(session map[string]any, facts map[string][]any) map[string]a
if sid == "" {
return nil
}
traceID := first(lastTraceID(session), latestTraceIDForSession(facts, sid))
currentTrace := currentTraceForSession(session, facts, sid)
traceID := currentTrace.traceID
var projection map[string]any
if traceID != "" {
projection = projectionForTrace(facts, traceID)
@@ -2066,7 +2078,7 @@ func sessionSummary(session map[string]any, facts map[string][]any) map[string]a
}
checkpointStatus := terminalStatus(text(checkpoint["status"]))
turnStatus := normalizeStatus(turn["status"])
status := normalizeStatus(first(checkpointStatus, turnStatus, text(session["status"])))
status := normalizeStatus(first(checkpointStatus, nonUnknownStatus(turnStatus), nonUnknownStatus(currentTrace.status), text(session["status"])))
timingSource := turn
if checkpointStatus != "" {
timingSource = checkpoint
@@ -2080,6 +2092,93 @@ func sessionSummary(session map[string]any, facts map[string][]any) map[string]a
return map[string]any{"sessionId": sid, "threadId": nullableText(session["threadId"]), "agentId": first(text(session["agentId"]), "hwlab-code-agent"), "title": title, "name": title, "status": status, "running": isRunning(status), "terminal": isTerminal(status), "lastTraceId": nullableText(traceID), "projection": projection, "projectionStatus": projection["projectionStatus"], "projectionHealth": projection["projectionHealth"], "staleMs": projection["staleMs"], "blocker": projection["blocker"], "providerProfile": nullableText(first(text(session["providerProfile"]), text(objectMap(session["sessionJson"])["providerProfile"]))), "messageCount": messageCount, "firstUserMessagePreview": firstUserMessagePreview, "updatedAt": updatedAt(session), "turnSummary": turnSummary, "valuesRedacted": true}
}
type traceCandidate struct {
traceID string
status string
seq int64
hasSeq bool
updatedAt string
priority int
}
func currentTraceForSession(session map[string]any, facts map[string][]any, sid string) traceCandidate {
best := traceCandidateFromRecord(session, lastTraceID(session), normalizeStatus(session["status"]), 0)
for _, row := range facts["messages"] {
item := objectMap(row)
if text(item["sessionId"]) != sid || text(item["traceId"]) == "" {
continue
}
if candidate := messageTraceCandidate(item); traceCandidateNewer(candidate, best) {
best = candidate
}
}
for _, row := range facts["turns"] {
item := objectMap(row)
if text(item["sessionId"]) != sid || text(item["traceId"]) == "" {
continue
}
candidate := traceCandidateFromRecord(item, text(item["traceId"]), normalizeStatus(item["status"]), 3)
if traceCandidateNewer(candidate, best) {
best = candidate
}
}
if best.traceID == "" {
best.traceID = latestTraceIDForSession(facts, sid)
}
return best
}
func messageTraceCandidate(message map[string]any) traceCandidate {
status := normalizeStatus(message["status"])
priority := 1
if text(message["role"]) == "agent" {
priority = 2
if status == "unknown" {
status = "running"
}
} else if text(message["traceId"]) != "" {
status = "running"
}
return traceCandidateFromRecord(message, text(message["traceId"]), status, priority)
}
func traceCandidateFromRecord(record map[string]any, traceID string, status string, priority int) traceCandidate {
seq, hasSeq := seqInt64(record)
return traceCandidate{traceID: traceID, status: status, seq: seq, hasSeq: hasSeq, updatedAt: text(updatedAt(record)), priority: priority}
}
func traceCandidateNewer(candidate traceCandidate, current traceCandidate) bool {
if candidate.traceID == "" {
return false
}
if current.traceID == "" {
return true
}
if candidate.hasSeq && current.hasSeq && candidate.seq != current.seq {
return candidate.seq > current.seq
}
if candidate.hasSeq != current.hasSeq {
return candidate.hasSeq
}
if candidate.updatedAt != "" && current.updatedAt != "" && candidate.updatedAt != current.updatedAt {
return candidate.updatedAt > current.updatedAt
}
if candidate.updatedAt != "" && current.updatedAt == "" {
return true
}
if candidate.priority != current.priority {
return candidate.priority > current.priority
}
return false
}
func nonUnknownStatus(status string) string {
if normalizeStatus(status) == "unknown" {
return ""
}
return status
}
func sessionDisplayTitle(session map[string]any, firstUserMessagePreview any) string {
sessionJSON := objectMap(session["sessionJson"])
if title := first(text(session["title"]), text(session["name"]), text(session["displayName"]), text(sessionJSON["title"]), text(sessionJSON["name"]), text(sessionJSON["displayName"])); title != "" {
@@ -2357,6 +2456,34 @@ func seq(record map[string]any) any {
return nil
}
func seqInt64(record map[string]any) (int64, bool) {
for _, key := range []string{"projectedSeq", "sourceSeq", "seq"} {
switch value := record[key].(type) {
case int64:
if value >= 0 {
return value, true
}
case int:
if value >= 0 {
return int64(value), true
}
case float64:
if value >= 0 {
return int64(value), true
}
case json.Number:
if parsed, err := value.Int64(); err == nil && parsed >= 0 {
return parsed, true
}
case string:
if parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil && parsed >= 0 {
return parsed, true
}
}
}
return 0, false
}
func timestamp(value any) string {
parsed, err := time.Parse(time.RFC3339Nano, text(value))
if err == nil {
+38
View File
@@ -18,3 +18,41 @@ func TestWorkbenchRuntimeReadIndexesCoverTraceFanout(t *testing.T) {
}
}
}
func TestSessionSummaryUsesNewestTurnFactsOverStaleSessionLastTrace(t *testing.T) {
sessionID := "ses_triad_current_trace"
oldTraceID := "trc_triad_completed"
newTraceID := "trc_triad_running"
facts := emptyFacts()
facts["messages"] = []any{
map[string]any{"messageId": "msg_old_user", "sessionId": sessionID, "traceId": oldTraceID, "role": "user", "status": "sent", "updatedAt": "2026-07-01T00:00:00Z", "projectedSeq": int64(10)},
map[string]any{"messageId": "msg_old_agent", "sessionId": sessionID, "traceId": oldTraceID, "role": "agent", "status": "completed", "text": "first done", "updatedAt": "2026-07-01T00:00:02Z", "projectedSeq": int64(20)},
map[string]any{"messageId": "msg_new_user", "sessionId": sessionID, "traceId": newTraceID, "role": "user", "status": "sent", "updatedAt": "2026-07-01T00:00:03Z", "projectedSeq": int64(30)},
map[string]any{"messageId": "msg_new_agent", "sessionId": sessionID, "traceId": newTraceID, "role": "agent", "status": "running", "updatedAt": "2026-07-01T00:00:04Z", "projectedSeq": int64(40)},
}
facts["turns"] = []any{
map[string]any{"turnId": oldTraceID, "sessionId": sessionID, "traceId": oldTraceID, "status": "completed", "terminal": true, "updatedAt": "2026-07-01T00:00:02Z", "projectedSeq": int64(20)},
map[string]any{"turnId": newTraceID, "sessionId": sessionID, "traceId": newTraceID, "status": "running", "terminal": false, "updatedAt": "2026-07-01T00:00:04Z", "projectedSeq": int64(40)},
}
summary := sessionSummary(map[string]any{
"sessionId": sessionID,
"ownerUserId": "usr_triad",
"status": "completed",
"lastTraceId": oldTraceID,
"updatedAt": "2026-07-01T00:00:02Z",
}, facts)
if got := text(summary["lastTraceId"]); got != newTraceID {
t.Fatalf("lastTraceId=%q, want %q; summary=%#v", got, newTraceID, summary)
}
if got := text(summary["status"]); got != "running" {
t.Fatalf("status=%q, want running; summary=%#v", got, summary)
}
turnSummary := objectMap(summary["turnSummary"])
if got := text(turnSummary["traceId"]); got != newTraceID {
t.Fatalf("turnSummary.traceId=%q, want %q; turnSummary=%#v", got, newTraceID, turnSummary)
}
if got := text(turnSummary["status"]); got != "running" {
t.Fatalf("turnSummary.status=%q, want running; turnSummary=%#v", got, turnSummary)
}
}