fix: resume workbench agentrun projection

This commit is contained in:
lyon
2026-06-19 12:46:27 +08:00
parent 037ecd552f
commit d2964d93a9
7 changed files with 451 additions and 14 deletions
+5
View File
@@ -310,6 +310,11 @@ lanes:
HWLAB_CODE_AGENT_TURN_STATUS_REFRESH_TIMEOUT_MS: "30000"
HWLAB_CODE_AGENT_AGENTRUN_NO_RESPONSE_TIMEOUT_MS: "600000"
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_SYNC_POLL_INTERVAL_MS: "1000"
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_ENABLED: "1"
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INITIAL_DELAY_MS: "5000"
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INTERVAL_MS: "60000"
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_MIN_AGE_MS: "30000"
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_BATCH_SIZE: "100"
observable: true
hwlab-user-billing:
runtimeKind: go-service
+154 -1
View File
@@ -1,4 +1,4 @@
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Cloud API AgentRun adapter and trace observability regression tests.
import assert from "node:assert/strict";
@@ -2493,6 +2493,159 @@ test("cloud api turn status reuses request session lookup without loading persis
}
});
test("cloud api resumes AgentRun projection from durable session after restart", async () => {
const calls = [];
const durableEvents = [];
const ownerWrites = [];
const traceId = "trc_projection_resume_restart";
const runId = "run_projection_resume_restart";
const commandId = "cmd_projection_resume_restart";
const sessionId = "ses_projection_resume_restart";
const conversationId = "cnv_projection_resume_restart";
const finalText = "OK from resumed AgentRun projection.";
const agentRunServer = createHttpServer(async (request, response) => {
const url = new URL(request.url || "/", "http://127.0.0.1");
calls.push({ method: request.method, path: url.pathname, search: url.search, afterSeq: url.searchParams.get("afterSeq") });
const send = (body, status = 200) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(`${JSON.stringify(body)}\n`);
};
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) {
assert.equal(url.searchParams.get("afterSeq"), "21");
return send({ items: [
{ id: "evt_projection_resume_22", runId, seq: 22, type: "assistant_message", payload: { commandId, text: finalText }, createdAt: "2026-06-18T16:31:11.000Z" },
{ id: "evt_projection_resume_23", runId, seq: 23, type: "terminal_status", payload: { commandId, terminalStatus: "completed" }, createdAt: "2026-06-18T16:31:12.000Z" }
] });
}
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/commands/${commandId}/result`) {
return send({
runId,
commandId,
attemptId: "attempt_projection_resume_restart",
runnerId: "runner_projection_resume_restart",
jobName: "agentrun-v02-runner-projection-resume",
namespace: "agentrun-v02",
status: "completed",
runStatus: "claimed",
commandState: "completed",
terminalStatus: "completed",
completed: true,
reply: finalText,
lastSeq: 23,
eventCount: 23,
sessionRef: { sessionId, conversationId, threadId: "thread-projection-resume-restart" }
});
}
return send({ ok: false, message: `unexpected ${request.method} ${url.pathname}` }, 404);
});
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
const agentRunPort = agentRunServer.address().port;
const session = testAgentSessionRecord({
sessionId,
conversationId,
projectId: "prj_hwpod_workbench",
status: "running",
traceId,
updatedAt: "2026-06-18T16:31:04.000Z",
session: {
traceResults: {
[traceId]: {
traceId,
status: "running",
sessionId,
conversationId,
updatedAt: "2026-06-18T16:31:04.000Z",
agentRun: {
adapter: "agentrun-v01",
managerUrl: `http://127.0.0.1:${agentRunPort}`,
runId,
commandId,
traceId,
lastSeq: 0,
status: "running",
commandState: "running",
backendProfile: "codex",
valuesPrinted: false
},
valuesRedacted: true
}
}
}
});
durableEvents.push({
traceId,
seq: 27,
source: "agentrun",
sourceSeq: 21,
type: "backend",
status: "running",
label: "agentrun:backend:mcpServer/startupStatus/updated",
commandId,
runId,
createdAt: "2026-06-18T16:31:04.200Z",
valuesPrinted: false
});
const runtimeStore = {
async queryAgentTraceEvents(params = {}) {
return { events: durableEvents.filter((event) => event.traceId === params.traceId), count: durableEvents.length };
},
async writeAgentTraceEvent(params = {}) {
const event = params.event ?? params.traceEvent ?? params;
durableEvents.push(event);
return { written: true, traceEvent: event };
}
};
const accessController = {
required: false,
store: {
async listAgentSessionsForUser() { return [session]; },
async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; }
},
async authenticate() { return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; },
async recordAgentSessionOwner(input = {}) {
ownerWrites.push(input);
return { ...session, status: input.status ?? session.status, session: input.session ?? session.session };
}
};
const server = createCloudApiServer({
accessController,
runtimeStore,
codeAgentChatResults: new Map(),
env: {
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_ENABLED: "1",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INITIAL_DELAY_MS: "1",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INTERVAL_MS: "0",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_MIN_AGE_MS: "0",
HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_BATCH_SIZE: "10"
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
for (let index = 0; index < 80 && !ownerWrites.some((write) => write.status === "completed"); index += 1) await delay(10);
assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/events` && call.afterSeq === "21"));
assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/commands/${commandId}/result`));
const completedWrite = ownerWrites.find((write) => write.status === "completed");
assert.ok(completedWrite);
assert.equal(completedWrite.session?.traceResults?.[traceId]?.finalResponse?.text, finalText);
assert.ok(durableEvents.some((event) => event.label === "agentrun:result:completed"));
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
await new Promise((resolve, reject) => {
agentRunServer.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api trace skips AgentRun refresh when terminal snapshot is already complete (#1422)", async () => {
const calls = [];
const traceId = "trc_issue1422_trace_terminal_snapshot";
+243 -3
View File
@@ -1,4 +1,4 @@
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010205 HWLAB接入 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010205 HWLAB接入 draft-2026-06-17-r0; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: Code Agent HTTP turn admission, lifecycle compatibility wrappers, and Workbench projection writer/finalizer integration.
import { createHash, randomUUID } from "node:crypto";
@@ -40,6 +40,10 @@ const MAX_CODE_AGENT_TRACE_PAGE_LIMIT = 100;
const DEFAULT_CODE_AGENT_TURN_STATUS_REFRESH_TIMEOUT_MS = 2500;
const DEFAULT_CODE_AGENT_PROJECT_ID = "prj_hwpod_workbench";
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
const DEFAULT_AGENTRUN_PROJECTION_RESUME_INITIAL_DELAY_MS = 5000;
const DEFAULT_AGENTRUN_PROJECTION_RESUME_INTERVAL_MS = 60000;
const DEFAULT_AGENTRUN_PROJECTION_RESUME_MIN_AGE_MS = 30000;
const DEFAULT_AGENTRUN_PROJECTION_RESUME_BATCH_SIZE = 100;
const CODE_AGENT_TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled"]);
const CODE_AGENT_PROVIDER_PROFILE_ALIASES = Object.freeze({ codex: "codex-api" });
const CODE_AGENT_PROVIDER_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u;
@@ -866,6 +870,242 @@ function scheduleAgentRunProjectionSync({ traceId, params = {}, options = {}, tr
});
}
export function startAgentRunProjectionResume({ options = {}, traceStore = defaultCodeAgentTraceStore, logger = console } = {}) {
const env = options.env ?? process.env;
if (!truthyFlag(env.HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_ENABLED)) {
return { started: false, reason: "disabled", stop() {}, valuesPrinted: false };
}
const initialDelayMs = parseNonNegativeInteger(env.HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INITIAL_DELAY_MS, DEFAULT_AGENTRUN_PROJECTION_RESUME_INITIAL_DELAY_MS);
const intervalMs = parseNonNegativeInteger(env.HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INTERVAL_MS, DEFAULT_AGENTRUN_PROJECTION_RESUME_INTERVAL_MS);
const minAgeMs = parseNonNegativeInteger(env.HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_MIN_AGE_MS, DEFAULT_AGENTRUN_PROJECTION_RESUME_MIN_AGE_MS);
const batchSize = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_BATCH_SIZE, DEFAULT_AGENTRUN_PROJECTION_RESUME_BATCH_SIZE);
let stopped = false;
let active = false;
let timer = null;
const schedule = (delayMs, reason) => {
if (stopped) return;
timer = setTimeout(() => {
timer = null;
void runAgentRunProjectionResumePass({ options, traceStore, logger, batchSize, minAgeMs, reason })
.catch((error) => logAgentRunProjectionResume(logger, {
event: "workbench_projection_resume",
ok: false,
status: "failed",
reason,
errorCode: error?.code ?? "projection_resume_pass_failed",
message: error?.message ?? "AgentRun projection resume pass failed.",
valuesRedacted: true
}))
.finally(() => {
active = false;
if (!stopped && intervalMs > 0) schedule(intervalMs, "interval");
});
active = true;
}, Math.max(0, Number(delayMs) || 0));
timer.unref?.();
};
schedule(initialDelayMs, "startup");
return {
started: true,
initialDelayMs,
intervalMs,
minAgeMs,
batchSize,
valuesPrinted: false,
stop() {
stopped = true;
if (timer) clearTimeout(timer);
timer = null;
},
active: () => active
};
}
async function runAgentRunProjectionResumePass({ options = {}, traceStore = defaultCodeAgentTraceStore, logger = console, batchSize, minAgeMs, reason = "manual" } = {}) {
const sessions = await listAgentRunProjectionResumeSessions(options, batchSize);
const candidates = sessions.flatMap((session) => agentRunProjectionResumeCandidates(session, { minAgeMs }));
let resumed = 0;
let terminal = 0;
let failed = 0;
for (const candidate of candidates) {
try {
const result = await resumeAgentRunProjectionCandidate({ candidate, options, traceStore });
resumed += 1;
if (isTraceCommandTerminalStatus(result?.status)) terminal += 1;
} catch (error) {
failed += 1;
appendProjectionResumeError(traceStore, candidate, error);
}
}
logAgentRunProjectionResume(logger, {
event: "workbench_projection_resume",
ok: failed === 0,
status: failed === 0 ? "completed" : "degraded",
reason,
scannedSessions: sessions.length,
candidates: candidates.length,
resumed,
terminal,
failed,
valuesRedacted: true
});
return { scannedSessions: sessions.length, candidates: candidates.length, resumed, terminal, failed };
}
async function listAgentRunProjectionResumeSessions(options = {}, batchSize = DEFAULT_AGENTRUN_PROJECTION_RESUME_BATCH_SIZE) {
const accessStore = options.accessController?.store ?? options.accessController ?? null;
if (typeof accessStore?.listAgentSessionsForUser !== "function") return [];
return await accessStore.listAgentSessionsForUser({
actorRole: "admin",
ownerScoped: false,
includeArchived: false,
limit: batchSize,
offset: 0
}) ?? [];
}
function agentRunProjectionResumeCandidates(session = {}, { minAgeMs = DEFAULT_AGENTRUN_PROJECTION_RESUME_MIN_AGE_MS, nowMs = Date.now() } = {}) {
const snapshot = session?.session && typeof session.session === "object" ? session.session : {};
const records = new Map();
const traceResults = snapshot.traceResults && typeof snapshot.traceResults === "object" ? snapshot.traceResults : null;
if (traceResults) {
for (const [traceId, record] of Object.entries(traceResults)) {
const safeId = safeTraceId(traceId);
if (safeId && record && typeof record === "object") records.set(safeId, { ...record, traceId: safeId });
}
}
const directTraceId = safeTraceId(snapshot.traceId ?? session.lastTraceId);
if (directTraceId && snapshot.agentRun && typeof snapshot.agentRun === "object") {
records.set(directTraceId, { ...snapshot, traceId: directTraceId });
}
return [...records.values()]
.map((record) => agentRunProjectionResumeCandidate(session, record, { minAgeMs, nowMs }))
.filter(Boolean);
}
function agentRunProjectionResumeCandidate(session = {}, record = {}, { minAgeMs = DEFAULT_AGENTRUN_PROJECTION_RESUME_MIN_AGE_MS, nowMs = Date.now() } = {}) {
const traceId = safeTraceId(record.traceId ?? session.lastTraceId);
const agentRun = record.agentRun && typeof record.agentRun === "object" ? record.agentRun : null;
if (!traceId || !agentRun?.runId || !agentRun?.commandId) return null;
if (isTraceCommandTerminalStatus(record.status ?? agentRun.terminalStatus ?? agentRun.commandState ?? agentRun.status)) return null;
const updatedAtMs = Date.parse(record.updatedAt ?? session.updatedAt ?? "");
if (minAgeMs > 0 && Number.isFinite(updatedAtMs) && nowMs - updatedAtMs < minAgeMs) return null;
const sessionId = safeSessionId(record.sessionId ?? session.id) || null;
const conversationId = safeConversationId(record.conversationId ?? session.conversationId) || null;
const threadId = safeOpaqueId(record.threadId ?? session.threadId) || null;
const ownerUserId = textValue(record.ownerUserId ?? session.ownerUserId);
const ownerRole = textValue(record.ownerRole ?? session.ownerRole) || "user";
return {
traceId,
params: {
traceId,
sessionId,
conversationId,
threadId,
projectId: record.projectId ?? session.projectId ?? null,
ownerUserId,
ownerRole
},
result: {
...record,
traceId,
sessionId,
conversationId,
threadId,
ownerUserId,
ownerRole,
status: record.status || session.status || "running",
finalResponse: null,
traceSummary: null,
agentRun: {
...agentRun,
traceId,
providerTrace: agentRun.providerTrace ?? { traceId, runId: agentRun.runId, commandId: agentRun.commandId, valuesPrinted: false },
lastSeq: Number.isFinite(Number(agentRun.lastSeq)) ? Number(agentRun.lastSeq) : 0,
valuesPrinted: false
},
valuesRedacted: true
}
};
}
async function resumeAgentRunProjectionCandidate({ candidate, options = {}, traceStore = defaultCodeAgentTraceStore } = {}) {
const currentResult = await agentRunProjectionResumeResultWithCursor(candidate, options);
const synced = await syncAgentRunChatResult({
traceId: candidate.traceId,
currentResult,
options,
traceStore,
forceResultSync: true,
refreshEvents: true
});
const payload = synced?.result ?? currentResult;
if (isTraceCommandTerminalStatus(payload?.status)) {
await recordCodeAgentTerminalTurnStatusEffects({ payload, params: candidate.params, options, preserveLastTraceId: true });
}
return payload;
}
async function agentRunProjectionResumeResultWithCursor(candidate, options = {}) {
const readModel = createWorkbenchReadModel(options, { id: candidate.params.ownerUserId || "projection-resume", role: "admin" });
const trace = await readModel.traceSnapshot(candidate.traceId);
const durableSourceSeq = agentRunProjectionLastSourceSeq(trace, candidate.result.agentRun.commandId);
const existingLastSeq = Number(candidate.result.agentRun.lastSeq ?? 0);
const lastSeq = Math.max(Number.isFinite(existingLastSeq) ? existingLastSeq : 0, durableSourceSeq);
return {
...candidate.result,
traceSummary: null,
agentRun: {
...candidate.result.agentRun,
lastSeq,
valuesPrinted: false
}
};
}
function agentRunProjectionLastSourceSeq(trace = {}, commandId = null) {
const events = Array.isArray(trace?.events) ? trace.events : [];
return events.reduce((max, event) => {
if (commandId && event?.commandId && event.commandId !== commandId) return max;
const sourceSeq = Number(event?.sourceSeq ?? 0);
return Number.isFinite(sourceSeq) && sourceSeq > max ? Math.trunc(sourceSeq) : max;
}, 0);
}
function appendProjectionResumeError(traceStore, candidate, error) {
const traceId = safeTraceId(candidate?.traceId);
if (!traceId) return;
traceStore.append(traceId, {
type: "turn-status",
status: "degraded",
label: "projection-resume:sync-failed",
errorCode: error?.code ?? "projection_resume_sync_failed",
message: error?.message ?? "AgentRun projection resume failed.",
runId: candidate?.result?.agentRun?.runId ?? null,
commandId: candidate?.result?.agentRun?.commandId ?? null,
waitingFor: "agentrun-result-resume",
terminal: false,
valuesPrinted: false
});
}
function logAgentRunProjectionResume(logger, payload) {
try {
const line = JSON.stringify(payload);
if (payload.ok === false && typeof logger?.warn === "function") logger.warn(line);
else if (typeof logger?.log === "function") logger.log(line);
} catch {
// Projection resume logging must not affect the projector.
}
}
function parseNonNegativeInteger(value, fallback) {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
}
function agentRunProjectionActivitySignature(result, runnerTrace) {
const agentRun = result?.agentRun && typeof result.agentRun === "object" ? result.agentRun : {};
const trace = runnerTrace && typeof runnerTrace === "object" ? runnerTrace : result?.runnerTrace;
@@ -2617,7 +2857,7 @@ function paginateTraceSnapshot(snapshot, { sinceSeq, limit }) {
const pageItems = indexedEvents.slice(startIndex, startIndex + limit);
const events = pageItems.map((item) => item.event);
const fromSeq = pageItems.length > 0 ? pageItems[0].seq : null;
const toSeq = pageItems.length > 0 ? pageItems[pageItems.length - 1].seq : sinceSeq;
const toSeq = pageItems.length > 0 ? pageItems[pageItems.length - 1].seq : null;
const hasMore = startIndex + pageItems.length < indexedEvents.length;
const totalEvents = snapshot?.eventCount ?? indexedEvents.length;
return {
@@ -2634,7 +2874,7 @@ function paginateTraceSnapshot(snapshot, { sinceSeq, limit }) {
},
truncated: hasMore,
hasMore,
nextSinceSeq: toSeq,
nextSinceSeq: pageItems.length > 0 ? toSeq : sinceSeq,
fullTraceLoaded: !hasMore
};
}
+29 -1
View File
@@ -1,4 +1,4 @@
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1.
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-18-r1.
// Responsibility: Workbench read model, durable projection diagnostics, and pure-GET regression tests.
import assert from "node:assert/strict";
@@ -36,6 +36,27 @@ test("workbench projection diagnostics exposes AgentRun refresh errors without t
assert.equal(diagnostics.sourceCommandId, "cmd_projection_refresh_degraded");
});
test("workbench projection diagnostics keeps projecting health distinct from caught-up", () => {
const traceId = "trc_projection_health_projecting";
const result = {
traceId,
status: "running",
agentRun: { runId: "run_projection_health_projecting", commandId: "cmd_projection_health_projecting", status: "running" }
};
const trace = {
traceId,
status: "running",
events: [{ traceId, seq: 7, sourceSeq: 7, type: "backend_status", status: "running", createdAt: "2026-06-19T04:40:00.000Z" }],
eventCount: 1,
updatedAt: "2026-06-19T04:40:00.000Z"
};
const projection = createWorkbenchTurnProjection({ traceId, result, trace });
const diagnostics = projectionDiagnostics({ traceId, result, trace, projection });
assert.equal(projection.status, "running");
assert.equal(diagnostics.projectionStatus, "projecting");
assert.equal(diagnostics.projectionHealth, "projecting");
});
test("workbench read model exposes session, messages, turn, and trace without write repair", async () => {
const traceStore = createCodeAgentTraceStore();
const results = createCodeAgentChatResultStore();
@@ -138,6 +159,13 @@ test("workbench read model exposes session, messages, turn, and trace without wr
assert.equal(trace.body.events.length, 1);
assert.equal(trace.body.hasMore, true);
const emptyTracePage = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?cursor=seq:99`);
assert.equal(emptyTracePage.status, 200);
assert.equal(emptyTracePage.body.events.length, 0);
assert.equal(emptyTracePage.body.range.fromSeq, null);
assert.equal(emptyTracePage.body.range.toSeq, null);
assert.equal(emptyTracePage.body.nextSeq, 99);
assert.deepEqual(writes, { recordAgentSessionOwner: 0, updateWorkspace: 0 });
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
+3 -3
View File
@@ -1,5 +1,5 @@
/*
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
* 职责: Workbench REST read model session/message/turn/trace facts AgentRun syncbilling finalize workspace repair
*/
import { createHash } from "node:crypto";
@@ -663,7 +663,7 @@ function traceEventPage(snapshot, options) {
const offset = startIndex >= 0 ? startIndex : indexed.length;
const page = indexed.slice(offset, offset + options.limit);
const events = page.map((item) => ({ ...item.event, seq: item.seq }));
const toSeq = page.length ? page[page.length - 1].seq : options.afterSeq;
const toSeq = page.length ? page[page.length - 1].seq : null;
const hasMore = offset + page.length < indexed.length;
return {
events,
@@ -677,7 +677,7 @@ function traceEventPage(snapshot, options) {
total: indexed.length
},
hasMore,
nextSeq: toSeq,
nextSeq: page.length ? toSeq : options.afterSeq,
nextCursor: hasMore ? `seq:${toSeq}` : null,
traceStatus: snapshot?.status ?? "missing",
updatedAt: snapshot?.updatedAt ?? null
+12 -3
View File
@@ -1,5 +1,5 @@
/*
* SPEC: PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-17-r0; PJ2026-010401 Web工作台 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
* 职责: Cloud API REST/SSE route dispatcherWorkbench read model / compat wrapper dispatcher
*/
import { createServer } from "node:http";
@@ -65,7 +65,8 @@ import {
handleCodeAgentSessionsHttp,
handleCodeAgentSteerHttp,
handleCodeAgentTurnHttp,
handleCodeAgentTraceHttp
handleCodeAgentTraceHttp,
startAgentRunProjectionResume
} from "./server-code-agent-http.ts";
import { handleWorkbenchReadModelHttp, handleWorkbenchRealtimeHttp } from "./server-workbench-http.ts";
import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts";
@@ -117,6 +118,11 @@ export function createCloudApiServer(options = {}) {
accessController.configureCodeAgentWorkspaceContext({ env, fetchImpl: options.fetchImpl, traceStore, codeAgentChatResults });
}
const workbenchEmptySessionGc = startWorkbenchEmptySessionGc({ env, accessController, logger: console });
const agentRunProjectionResume = startAgentRunProjectionResume({
options: { ...options, env, runtimeStore, accessController, traceStore, codeAgentChatResults },
traceStore,
logger: console
});
const server = createServer(async (request, response) => {
const backendPerformance = backendPerformanceStore.beginHttpRequest(request, response);
await withBackendPerformanceContext(backendPerformance, async () => {
@@ -135,7 +141,10 @@ export function createCloudApiServer(options = {}) {
}
});
});
server.on("close", () => workbenchEmptySessionGc.stop());
server.on("close", () => {
workbenchEmptySessionGc.stop();
agentRunProjectionResume.stop();
});
return server;
}
+5 -3
View File
@@ -1,5 +1,5 @@
/*
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-010403 API契约 draft-2026-06-18-r1.
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p0-projector-resume; PJ2026-010403 API契约 draft-2026-06-18-r1.
* 职责: 生成唯一 Workbench turn projectiontrace event turn lifecycle
*/
@@ -188,13 +188,15 @@ function normalizeProjectionStatus(value) {
function normalizeProjectionHealth(value) {
const text = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-");
return ["caught-up", "degraded", "stalled", "unavailable", "unknown"].includes(text) ? text : null;
return ["caught-up", "projecting", "degraded", "stalled", "unavailable", "unknown"].includes(text) ? text : null;
}
function projectionHealthFor({ status, turn, hasProjectionInput, blocker }) {
if (blocker?.code === "projection_store_unavailable") return "unavailable";
if (blocker) return status === "stalled" ? "stalled" : "degraded";
if (turn.terminal || status === "caught-up" || hasProjectionInput) return "caught-up";
if (turn.terminal || status === "caught-up") return "caught-up";
if (status === "stalled") return "stalled";
if (status === "projecting" || hasProjectionInput) return "projecting";
return "unknown";
}