feat: add web sentinel session invariance checks

This commit is contained in:
Codex
2026-06-26 12:00:13 +00:00
parent ad7043f3f4
commit dee36326b9
9 changed files with 622 additions and 25 deletions
@@ -354,6 +354,9 @@ async function processCommand(command) {
case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel");
case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider");
case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession");
case "refreshCurrentSession": return withObserverSync(await refreshCurrentSession(command), "refreshCurrentSession");
case "switchAwayAndBack": return withObserverSync(await switchAwayAndBack(command), "switchAwayAndBack");
case "assertSessionInvariant": return withObserverSync(await assertSessionInvariant(command), "assertSessionInvariant");
case "gotoProjectMdtodo": return withObserverSync(await gotoProjectMdtodo(), "gotoProjectMdtodo");
case "openMdtodoSourceConfig": return openMdtodoSourceConfig(command);
case "configureMdtodoHwpodSource": return configureMdtodoHwpodSource(command);
@@ -1535,6 +1538,295 @@ async function clickSession(sessionId) {
return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId };
}
async function refreshCurrentSession(command) {
const beforeUrl = currentPageUrl();
const before = await workbenchSessionSnapshot();
const sessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim();
if (!sessionId) throw new Error("refreshCurrentSession requires a current Workbench session");
const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
const after = await workbenchSessionSnapshot();
return {
beforeUrl,
afterUrl: currentPageUrl(),
type: "refreshCurrentSession",
afterRound: integerOrNull(command.afterRound),
canarySessionId: sessionId,
routeSessionId: after?.routeSessionId ?? null,
activeSessionId: after?.activeSessionId ?? null,
routeOk: after?.routeSessionId === sessionId,
activeOk: after?.activeSessionId === sessionId,
composerReady: after?.composerReady === true,
navigation,
pageId,
valuesRedacted: true,
};
}
async function switchAwayAndBack(command) {
const beforeUrl = currentPageUrl();
const before = await workbenchSessionSnapshot();
const canarySessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim();
if (!canarySessionId) throw new Error("switchAwayAndBack requires a current canary Workbench session");
const strategy = String(command.alternateSessionStrategy || command.strategy || "existing-or-create");
const beforeSessionIds = await visibleWorkbenchSessionIds();
let alternateSessionId = beforeSessionIds.find((item) => item && item !== canarySessionId) || null;
let alternateSource = alternateSessionId === null ? null : "existing";
let createResult = null;
if (alternateSessionId === null && strategy === "existing-or-create") {
createResult = await createSessionFromUi();
alternateSessionId = createResult?.sessionId || createResult?.createdSessionId || null;
alternateSource = "created";
}
if (!alternateSessionId) throw new Error("switchAwayAndBack could not find an alternate session with strategy=" + strategy);
const switchAway = await clickSession(alternateSessionId);
const away = await workbenchSessionSnapshot();
const switchBack = await gotoTarget("/workbench/sessions/" + encodeURIComponent(canarySessionId));
const after = await workbenchSessionSnapshot();
const routeOk = after?.routeSessionId === canarySessionId;
const activeOk = after?.activeSessionId === canarySessionId;
if (!routeOk || !activeOk) {
const error = new Error("switchAwayAndBack did not return to the canary session");
error.details = { canarySessionId, alternateSessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, pageId, valuesRedacted: true };
throw error;
}
return {
beforeUrl,
afterUrl: currentPageUrl(),
type: "switchAwayAndBack",
afterRound: integerOrNull(command.afterRound),
canarySessionId,
alternateSessionId,
alternateSessionStrategy: strategy,
alternateSource,
beforeSessionCount: beforeSessionIds.length,
createResult: createResult === null ? null : { sessionId: alternateSessionId, valuesRedacted: true },
switchAway,
away: {
routeSessionId: away?.routeSessionId ?? null,
activeSessionId: away?.activeSessionId ?? null,
messageCount: away?.messageCount ?? null,
valuesRedacted: true,
},
switchBack,
routeSessionId: after?.routeSessionId ?? null,
activeSessionId: after?.activeSessionId ?? null,
routeOk,
activeOk,
composerReady: after?.composerReady === true,
pageId,
valuesRedacted: true,
};
}
async function assertSessionInvariant(command) {
const beforeUrl = currentPageUrl();
const snapshot = await workbenchSessionSnapshot();
const canarySessionId = String(command.sessionId || command.canarySessionId || snapshot?.routeSessionId || snapshot?.activeSessionId || "").trim();
if (!canarySessionId) throw new Error("assertSessionInvariant requires a current canary Workbench session");
const routeOk = snapshot?.routeSessionId === canarySessionId;
const activeOk = snapshot?.activeSessionId === canarySessionId;
const composerReady = snapshot?.composerReady === true;
if (!routeOk || !activeOk) {
const error = new Error("assertSessionInvariant saw route/active session mismatch");
error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, pageId, valuesRedacted: true };
throw error;
}
if (command.requireComposerReady === true && !composerReady) {
const error = new Error("assertSessionInvariant requires composer ready for the next round");
error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, warning: snapshot?.warning ?? null, pageId, valuesRedacted: true };
throw error;
}
const messageOrder = await visibleMessageOrderSummary();
await samplePage("assert-session-invariant", { refreshObserver: false, screenshot: false }).catch((error) => appendJsonl(files.errors, eventRecord("assert-session-invariant-sample-error", { error: errorSummary(error), pageId, valuesRedacted: true })));
return {
beforeUrl,
afterUrl: currentPageUrl(),
type: "assertSessionInvariant",
afterRound: integerOrNull(command.afterRound),
canarySessionId,
routeSessionId: snapshot?.routeSessionId ?? null,
activeSessionId: snapshot?.activeSessionId ?? null,
routeOk,
activeOk,
composerReady,
expectedSentinelRange: command.expectedSentinelRange || null,
findingId: command.findingId || "workbench-message-order-user-clustered-after-navigation",
severity: command.severity || "amber",
blocking: command.blocking === true,
messageOrder,
sampleSeq,
pageRole: "control",
pageId,
valuesRedacted: true,
};
}
async function visibleWorkbenchSessionIds() {
return page.evaluate(() => {
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const sessionIdForElement = (element) => {
const direct = element.getAttribute("data-session-id");
if (direct) return direct;
const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || "";
const match = String(href || "").match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
return match ? decodeURIComponent(match[1] || "") : null;
};
const ids = [];
for (const element of Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))) {
if (!visible(element)) continue;
const id = sessionIdForElement(element);
if (id && !ids.includes(id)) ids.push(id);
}
return ids.slice(0, 50);
}).catch(() => []);
}
async function visibleMessageOrderSummary() {
const rawMessages = await page.evaluate(() => {
const trim = (value, limit = 1600) => String(value || "").replace(/\s+/gu, " ").trim().slice(0, limit);
const visible = (element) => {
if (!element) return false;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
const stableMessageText = (element) => {
const selectors = [".message-markdown.message-text", ".message-text", "[data-message-body]", "[data-testid='message-body']", "[data-testid*='message-text' i]", "[data-testid*='final-response' i]"];
const parts = [];
for (const selector of selectors) {
for (const candidate of Array.from(element.querySelectorAll(selector))) {
if (!visible(candidate)) continue;
const text = trim(candidate.textContent || "", 1200);
if (text && !parts.includes(text)) parts.push(text);
}
}
return parts.length > 0 ? parts.join(" ") : trim(element.textContent || "", 1200);
};
return Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).slice(-80).map((element, index) => ({
index,
dataRole: element.getAttribute("data-role") || null,
role: element.getAttribute("role") || null,
testId: element.getAttribute("data-testid") || null,
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
sessionId: element.getAttribute("data-session-id") || null,
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
traceId: element.getAttribute("data-trace-id") || null,
turnId: element.getAttribute("data-turn-id") || null,
text: stableMessageText(element),
}));
}).catch(() => []);
const entries = Array.isArray(rawMessages) ? rawMessages.map((item, index) => {
const text = String(item?.text || "");
const kind = messageKindForOrder(item, text);
const markers = sentinelMarkers(text);
return {
index: Number.isFinite(Number(item?.index)) ? Number(item.index) : index,
kind,
role: item?.dataRole || item?.role || null,
status: item?.status || null,
sessionId: item?.sessionId || null,
messageId: item?.messageId || null,
traceId: item?.traceId || null,
turnId: item?.turnId || null,
markers,
textHash: sha256Text(text),
textBytes: Buffer.byteLength(text),
valuesRedacted: true,
};
}) : [];
const clusters = userClusters(entries);
const maxCluster = clusters.slice().sort((a, b) => b.consecutiveUserMessageCount - a.consecutiveUserMessageCount)[0] || null;
return {
messageCount: entries.length,
sequence: entries.map((item) => ({
index: item.index,
kind: item.kind,
marker: item.markers[0] || null,
markerCount: item.markers.length,
traceId: item.traceId,
status: item.status,
textHash: item.textHash,
textBytes: item.textBytes,
valuesRedacted: true,
})).slice(-40),
userClustered: clusters.length > 0,
consecutiveUserMessageCount: maxCluster?.consecutiveUserMessageCount ?? 0,
sentinelRange: maxCluster?.sentinelRange ?? null,
traceIds: uniqueStrings(entries.map((item) => item.traceId)).slice(0, 12),
clusters,
valuesRedacted: true,
};
}
function messageKindForOrder(item, text) {
const signal = [item?.dataRole, item?.role, item?.testId].map((value) => String(value || "").toLowerCase()).join(" ");
if (/\b(user|human)\b/u.test(signal)) return "user";
if (/\b(assistant|agent|code-agent|system)\b/u.test(signal)) return "agent";
const body = String(text || "").trim();
if (/^Run\s+/iu.test(body) && /\bsentinel-(?:0[1-9]|10)\b/u.test(body)) return "user";
if (/\b(?:final response|assistant|code agent)\b/iu.test(body)) return "agent";
return "unknown";
}
function sentinelMarkers(text) {
return uniqueStrings(Array.from(String(text || "").matchAll(/\bsentinel-(?:0[1-9]|10)\b/giu)).map((match) => match[0].toLowerCase()));
}
function userClusters(entries) {
const clusters = [];
let current = [];
const flush = () => {
if (current.length >= 2) {
const markers = current.flatMap((item) => item.markers);
clusters.push({
startIndex: current[0].index,
endIndex: current[current.length - 1].index,
consecutiveUserMessageCount: current.length,
sentinelRange: sentinelRange(markers),
markers: uniqueStrings(markers).slice(0, 12),
messageTextHashes: current.map((item) => item.textHash).slice(0, 12),
traceIds: uniqueStrings(current.map((item) => item.traceId)).slice(0, 12),
valuesRedacted: true,
});
}
current = [];
};
for (const entry of entries) {
if (entry.kind === "user") {
current.push(entry);
} else {
flush();
}
}
flush();
return clusters.slice(0, 20);
}
function sentinelRange(markers) {
const unique = uniqueStrings(markers).sort((a, b) => sentinelMarkerNumber(a) - sentinelMarkerNumber(b));
if (unique.length === 0) return null;
return unique.length === 1 ? unique[0] : unique[0] + ".." + unique[unique.length - 1];
}
function sentinelMarkerNumber(marker) {
const match = String(marker || "").match(/sentinel-(\d+)/u);
return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
}
function uniqueStrings(values) {
return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean)));
}
function integerOrNull(value) {
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : null;
}
function ensureProjectManagementCommand(type) {
if (projectManagement.enabled !== true) throw new Error(type + " requires config/hwlab-node-lanes.yaml webProbe.projectManagement.enabled=true for the selected node/lane");
if (!projectManagement.commandAllowlist.includes(type)) throw new Error(type + " is not in webProbe.projectManagement.commandAllowlist for the selected node/lane");
@@ -2810,6 +3102,13 @@ function commandInputSummary(command) {
url: command.url ? safeUrl(command.url) : null,
sessionId: command.sessionId || command.value || null,
provider: command.provider || null,
afterRound: Number.isInteger(Number(command.afterRound)) ? Number(command.afterRound) : null,
severity: command.severity || null,
alternateSessionStrategy: command.alternateSessionStrategy || null,
expectedSentinelRange: command.expectedSentinelRange || null,
requireComposerReady: command.requireComposerReady === true,
findingId: command.findingId || null,
blocking: command.blocking === true ? true : command.blocking === false ? false : null,
sourceId: opaque(command.sourceId),
fileRef: opaque(command.fileRef),
taskRef: opaque(command.taskRef),