Merge pull request #1023 from pikasTech/feat/issue-1020-web-sentinel-invariance

feat: 增强 Web 哨兵刷新切换消息顺序探测
This commit is contained in:
Lyon
2026-06-26 20:02:20 +08:00
committed by GitHub
9 changed files with 622 additions and 25 deletions
@@ -12,4 +12,15 @@ sentinel:
promptSourceRef: hwlab/web-probe-sentinel-dsflash-go.env
promptSourceKey: DSFLASH_GO_TOOL_CALL_10X_PROMPTS_JSON
promptCount: 10
expectedMarkers:
- sentinel-01
- sentinel-02
- sentinel-03
- sentinel-04
- sentinel-05
- sentinel-06
- sentinel-07
- sentinel-08
- sentinel-09
- sentinel-10
redaction: hash-and-byte-count
@@ -24,3 +24,34 @@ sentinel:
- type: sendPrompt
promptSource: promptSet
repeat: 10
sessionInvarianceChecks:
- id: after-round-1-navigation-invariance
afterRound: 1
refreshCurrent: true
switchAwayAndBack: true
alternateSessionStrategy: existing-or-create
assertSessionInvariant: true
expectedSentinelRange: sentinel-01..sentinel-01
findingId: workbench-message-order-user-clustered-after-navigation
severity: amber
blocking: false
- id: after-round-5-navigation-invariance
afterRound: 5
refreshCurrent: true
switchAwayAndBack: true
alternateSessionStrategy: existing-or-create
assertSessionInvariant: true
requireComposerReady: true
expectedSentinelRange: sentinel-01..sentinel-05
findingId: workbench-message-order-user-clustered-after-navigation
severity: amber
blocking: false
- id: after-round-10-refresh-invariance
afterRound: 10
refreshCurrent: true
switchAwayAndBack: false
assertSessionInvariant: true
expectedSentinelRange: sentinel-01..sentinel-10
findingId: workbench-message-order-user-clustered-after-navigation
severity: amber
blocking: false
@@ -506,6 +506,10 @@ HWLAB runtime 发布 Pipeline 应在 Argo sync 前调用当前哨兵 `maintenanc
调度器每隔 YAML 声明 cadence 创建新的 HWLAB Workbench session;每个 run 通过同一 `web-probe observe start` 启动采样,并通过同一 `observe command` 下发 `newSession`、provider 选择和十轮 prompt。prompt 原文必须由 prompt sourceRef 管理,报告只展示 prompt id/hash/bytes 和轮次编号。
第一 canary 必须在十轮连续 prompt 中插入 YAML 声明的 `sessionInvarianceChecks`。固定检查点为第 1 轮后、第 5 轮后和第 10 轮后:第 1/5 轮后执行显式 `refreshCurrentSession``switchAwayAndBack``assertSessionInvariant` control command;第 10 轮后只刷新当前 canary session 并断言最终回读。切换窗口必须进入 `control.jsonl`,不得由哨兵服务私自驱动第二套 Playwright,也不得靠刷新、切 session 或 result polling 修复 Workbench 投影。
刷新/切换检查只检测同一 canary session 的消息/trace/final 投影顺序。受控切走和切回窗口内的 `session-route-changed` / `active-session-changed` 不构成业务异常;切回后仍存在 route/active mismatch、trace 丢失、final 缺失或 command failure 时沿用既有 blocker 规则。若同一 session 可见消息出现多个 user message cards 连续展示,且这些 user cards 之间缺少 assistant/agent/code-agent terminal 或 response card`observe analyze` 必须产生 `workbench-message-order-user-clustered-after-navigation`severity=`amber`blocking=`false`,并记录 afterRound、canarySessionId、routeSessionId、activeSessionId、连续 user 数量、sentinel marker 范围、sample seq、traceId 列表、pageRole/pageId 和 redacted message order 摘要。
每一轮任务都必须需要工具调用。验收报告要证明十轮在同一 session 内完成,记录每轮 traceId、terminal status、tool-call evidence/count、耗时、慢 API、network/console/requestfailed finding、trace 顺序异常、terminal-not-last、session mismatch 和 final-response flicker。
`dsflash-go` 是 AgentRun backend profile。实现必须验证 profile-scoped SecretRef、config 和 `model-catalog.json` presence/fingerprint;缺失时结构化失败,不允许 fallback 到 `codex``deepseek``minimax-m3` 或其他 profile。
@@ -97,7 +97,7 @@ const runnerErrors = errors.slice(-8).map((item) => {
});
const commandFailures = summarizeCommandFailures(control);
const toolFindings = buildToolFindings({ manifest, heartbeat, commandState });
const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures)];
const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures, manifest)];
if (jsonlReadIssues.length > 0) findings.unshift({ id: "jsonl-read-issues", severity: "red", summary: "observer analyzer hit JSONL read/parse issues", count: jsonlReadIssues.length, issues: jsonlReadIssues.slice(0, 20) });
const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, manifest });
const commandTimeline = control.filter((item) => item.phase === "completed" || item.phase === "failed").map((item) => ({ ts: item.ts, phase: item.phase, commandId: item.commandId, type: item.type, input: item.input, afterUrl: item.afterUrl }));
@@ -1264,18 +1264,140 @@ function maxNumber(values) {
return numeric.length > 0 ? Math.max(...numeric) : 0;
}
function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = []) {
function buildSessionInvariantFindings(control, manifest = {}) {
const findings = [];
for (const row of control || []) {
if (row?.type !== "assertSessionInvariant" || row?.phase !== "completed") continue;
const detail = objectValue(row.detail);
const messageOrder = objectValue(detail.messageOrder);
if (messageOrder.userClustered !== true) continue;
const afterRound = numberOrNull(detail.afterRound ?? row.input?.afterRound);
const consecutiveUserMessageCount = numberOrNull(messageOrder.consecutiveUserMessageCount) ?? 0;
const sentinelRange = stringOrNull(messageOrder.sentinelRange) ?? stringOrNull(detail.expectedSentinelRange);
const traceIds = arrayStrings(messageOrder.traceIds).slice(0, 12);
const findingId = stringOrNull(detail.findingId) ?? "workbench-message-order-user-clustered-after-navigation";
const severity = stringOrNull(detail.severity) ?? "amber";
findings.push({
id: findingId,
severity,
summary: "controlled refresh/switch-back afterRound=" + (afterRound ?? "-") + " left consecutive user message cards without interleaved assistant/code-agent terminal cards" + (sentinelRange ? " (" + sentinelRange + ")" : ""),
count: Math.max(1, consecutiveUserMessageCount),
blocking: detail.blocking === true ? true : false,
afterRound,
canarySessionId: stringOrNull(detail.canarySessionId),
routeSessionId: stringOrNull(detail.routeSessionId),
activeSessionId: stringOrNull(detail.activeSessionId),
consecutiveUserMessageCount,
sentinelRange,
sampleSeq: numberOrNull(detail.sampleSeq),
traceIds,
pageRole: stringOrNull(detail.pageRole) ?? "control",
pageId: stringOrNull(detail.pageId),
observerId: stringOrNull(manifest.jobId),
stateDir: stringOrNull(manifest.stateDir),
commandId: stringOrNull(row.commandId),
commandTs: stringOrNull(row.ts),
messageOrder: {
sequence: Array.isArray(messageOrder.sequence) ? messageOrder.sequence.slice(-20) : [],
clusters: Array.isArray(messageOrder.clusters) ? messageOrder.clusters.slice(0, 8) : [],
valuesRedacted: true,
},
valuesRedacted: true,
});
}
return findings;
}
function sessionInvariantNavigationWindows(control) {
const started = new Map();
const windows = [];
for (const row of control || []) {
if (row?.type !== "switchAwayAndBack") continue;
const commandId = stringOrNull(row.commandId) ?? String(row.seq ?? "");
if (row.phase === "started") {
started.set(commandId, row);
continue;
}
if (row.phase !== "completed") continue;
const detail = objectValue(row.detail);
const canarySessionId = stringOrNull(detail.canarySessionId);
const alternateSessionId = stringOrNull(detail.alternateSessionId);
const startRow = started.get(commandId);
const startMs = timestampMs(startRow?.ts ?? row.ts);
const endMs = timestampMs(row.ts);
if (!canarySessionId || !Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
windows.push({
commandId,
afterRound: numberOrNull(detail.afterRound ?? row.input?.afterRound),
startMs: Math.max(0, startMs - 1000),
endMs: endMs + 5000,
startAt: new Date(startMs).toISOString(),
endAt: new Date(endMs).toISOString(),
canarySessionId,
alternateSessionId,
routeOk: detail.routeOk === true,
activeOk: detail.activeOk === true,
valuesRedacted: true,
});
}
return windows;
}
function sessionChangeSamplesOutsideControlledNavigation(samples, key, windows) {
const canaryIds = new Set((windows || []).map((item) => item.canarySessionId).filter(Boolean));
if (canaryIds.size === 0) return samples.filter((item) => item?.[key]);
return (samples || []).filter((sample) => {
const value = stringOrNull(sample?.[key]);
if (!value) return false;
if (canaryIds.has(value)) return false;
return !sampleInControlledNavigationWindow(sample, windows);
});
}
function sampleInControlledNavigationWindow(sample, windows) {
const ms = timestampMs(sample?.ts);
if (!Number.isFinite(ms)) return false;
return (windows || []).some((window) => ms >= window.startMs && ms <= window.endMs);
}
function objectValue(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function stringOrNull(value) {
return typeof value === "string" && value.length > 0 ? value : null;
}
function numberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function arrayStrings(value) {
return Array.isArray(value) ? value.map((item) => String(item || "")).filter(Boolean) : [];
}
function timestampMs(value) {
const parsed = Date.parse(String(value || ""));
return Number.isFinite(parsed) ? parsed : NaN;
}
function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = [], manifest = {}) {
const findings = [];
if (commandFailures.length > 0) findings.push({ id: "observer-command-failed", severity: "red", summary: "observer control commands failed; analyze must surface command failure instead of hiding it in command artifacts", count: commandFailures.length, commands: commandFailures.slice(0, 20) });
findings.push(...buildSessionInvariantFindings(control, manifest));
const commandTimes = control
.filter((item) => item.phase === "completed" || item.phase === "started" || item.type === "observer-periodic-refresh")
.map((item) => Date.parse(item.ts))
.filter(Number.isFinite);
const controlledNavigationWindows = sessionInvariantNavigationWindows(control);
const routeSessions = new Set(samples.map((item) => item.routeSessionId).filter(Boolean));
const activeSessions = new Set(samples.map((item) => item.activeSessionId).filter(Boolean));
if (routeSessions.size > 1) findings.push({ id: "session-route-changed", severity: "amber", summary: "route session changed during observation", routeSessionCount: routeSessions.size, samples: sampleRefs(samples, (item) => item.routeSessionId) });
if (activeSessions.size > 1) findings.push({ id: "active-session-changed", severity: "amber", summary: "active session changed during observation", activeSessionCount: activeSessions.size, samples: sampleRefs(samples, (item) => item.activeSessionId) });
const mismatches = samples.filter((item) => item.routeSessionId && item.activeSessionId && item.routeSessionId !== item.activeSessionId);
const routeSessionUnexpected = sessionChangeSamplesOutsideControlledNavigation(samples, "routeSessionId", controlledNavigationWindows);
const activeSessionUnexpected = sessionChangeSamplesOutsideControlledNavigation(samples, "activeSessionId", controlledNavigationWindows);
if (routeSessions.size > 1 && routeSessionUnexpected.length > 0) findings.push({ id: "session-route-changed", severity: "amber", summary: "route session changed outside controlled session-invariance navigation windows", routeSessionCount: routeSessions.size, samples: sampleRefs(routeSessionUnexpected, (item) => item.routeSessionId) });
if (activeSessions.size > 1 && activeSessionUnexpected.length > 0) findings.push({ id: "active-session-changed", severity: "amber", summary: "active session changed outside controlled session-invariance navigation windows", activeSessionCount: activeSessions.size, samples: sampleRefs(activeSessionUnexpected, (item) => item.activeSessionId) });
const mismatches = samples.filter((item) => item.routeSessionId && item.activeSessionId && item.routeSessionId !== item.activeSessionId && !sampleInControlledNavigationWindow(item, controlledNavigationWindows));
if (mismatches.length > 0) findings.push({ id: "route-active-session-mismatch", severity: "red", summary: "routeSessionId and activeSessionId diverged", count: mismatches.length, samples: mismatches.slice(0, 10).map(ref) });
const uncommandedChanges = [];
const commandedPromptSeqs = new Set((sampleMetrics?.timeline ?? []).filter((item) => Number(item?.promptIndex) > 0).map((item) => item.seq).filter((seq) => seq !== null && seq !== undefined));
@@ -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),
+77 -1
View File
@@ -1654,6 +1654,7 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
});
}
let promptIndex = 0;
const sessionInvarianceChecks = sessionInvarianceChecksByRound(scenario);
for (const item of arrayAt(scenario, "commandSequence").map(record)) {
const type = stringAt(item, "type");
const repeat = Math.max(1, typeof item.repeat === "number" && Number.isFinite(item.repeat) ? Math.trunc(item.repeat) : 1);
@@ -1711,6 +1712,21 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
warnings: mergeWarnings(Array.isArray(waitResult.warnings) ? waitResult.warnings : [], elapsedWarnings()),
}));
}
const invariantResult = runQuickVerifySessionInvarianceChecks(state, observerId, sessionInvarianceChecks.get(promptIndex) ?? [], deadline, promptIndex, steps);
if (invariantResult.ok !== true) {
return recordQuickVerify(state, finalizeQuickVerifyFailure(state, {
runId,
scenarioId,
reason,
observerId,
promptIndex,
steps,
failure: text(invariantResult.failure ?? "observe-session-invariance-check-failed"),
promptSource: prompts.summary,
elapsedMs: elapsedMs(),
warnings: elapsedWarnings(),
}));
}
}
}
}
@@ -1756,6 +1772,65 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
});
}
function sessionInvarianceChecksByRound(scenario: Record<string, unknown>): Map<number, Record<string, unknown>[]> {
const checks = new Map<number, Record<string, unknown>[]>();
const items = Array.isArray(scenario.sessionInvarianceChecks) ? scenario.sessionInvarianceChecks.map(record) : [];
for (const item of items) {
const afterRound = typeof item.afterRound === "number" && Number.isInteger(item.afterRound) ? item.afterRound : null;
if (afterRound === null || afterRound < 0) continue;
const list = checks.get(afterRound) ?? [];
list.push(item);
checks.set(afterRound, list);
}
return checks;
}
function runQuickVerifySessionInvarianceChecks(
state: SentinelCicdState,
observerId: string,
checks: readonly Record<string, unknown>[],
deadline: number,
promptIndex: number,
steps: Record<string, unknown>[],
): Record<string, unknown> {
for (const check of checks) {
const checkId = nonEmptyString(check.id) ?? `after-round-${promptIndex}`;
const commands: { readonly type: string; readonly enabled: boolean }[] = [
{ type: "refreshCurrentSession", enabled: check.refreshCurrent === true },
{ type: "switchAwayAndBack", enabled: check.switchAwayAndBack === true },
{ type: "assertSessionInvariant", enabled: check.assertSessionInvariant !== false },
];
for (const command of commands) {
if (!command.enabled) continue;
const args = [
"web-probe", "observe", "command", observerId,
"--node", state.spec.nodeId,
"--lane", state.spec.lane,
"--type", command.type,
"--after-round", String(promptIndex),
"--wait-ms", "55000",
"--command-timeout-seconds", String(remainingSeconds(deadline, 55)),
];
const severity = nonEmptyString(check.severity);
const findingId = nonEmptyString(check.findingId);
const expectedSentinelRange = nonEmptyString(check.expectedSentinelRange);
const alternateSessionStrategy = nonEmptyString(check.alternateSessionStrategy);
if (severity !== null) args.push("--severity", severity);
if (findingId !== null) args.push("--finding-id", findingId);
if (expectedSentinelRange !== null) args.push("--expected-sentinel-range", expectedSentinelRange);
if (command.type === "switchAwayAndBack" && alternateSessionStrategy !== null) args.push("--alternate-session-strategy", alternateSessionStrategy);
if (command.type === "assertSessionInvariant" && check.requireComposerReady === true) args.push("--require-composer-ready");
args.push(check.blocking === true ? "--blocking" : "--non-blocking");
const result = runChildCli(args, remainingSeconds(deadline, 60));
steps.push({ phase: `observe-session-invariance-${command.type}`, ok: result.ok, promptIndex, checkId, result: result.result });
if (!result.ok) {
return { ok: false, failure: `observe-session-invariance-${command.type}-failed`, checkId, promptIndex, valuesRedacted: true };
}
}
}
return { ok: true, promptIndex, checkCount: checks.length, valuesRedacted: true };
}
function finalizeQuickVerifyFailure(state: SentinelCicdState, input: {
readonly runId: string;
readonly scenarioId: string;
@@ -2172,7 +2247,7 @@ function readAnalysisSummaryFromWorkspace(state: SentinelCicdState, stateDir: st
"let artifactCount=0; let screenshot=null;",
"function walk(dir){let entries=[]; try{entries=fs.readdirSync(dir,{withFileTypes:true})}catch{return}; for(const e of entries){const p=path.join(dir,e.name); if(e.isDirectory()) walk(p); else { artifactCount++; if(/\\.png$/i.test(e.name)){const b=read(p); screenshot={path:p,sha256:sha(b),bytes:b?b.length:0}; } } }}",
"walk(stateDir);",
"const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220)};});",
"const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220),blocking:v.blocking===true,afterRound:v.afterRound??null,canarySessionId:clip(v.canarySessionId,80),routeSessionId:clip(v.routeSessionId,80),activeSessionId:clip(v.activeSessionId,80),consecutiveUserMessageCount:v.consecutiveUserMessageCount??null,sentinelRange:clip(v.sentinelRange,80),sampleSeq:v.sampleSeq??null,traceIds:arr(v.traceIds).slice(0,8).map((x)=>clip(x,80)),pageRole:clip(v.pageRole,32),pageId:clip(v.pageId,80),observerId:clip(v.observerId,80),stateDir:clip(v.stateDir,160),commandId:clip(v.commandId,80),valuesRedacted:true};});",
"const slow=arr(report?.pagePerformanceSlowApi ?? report?.archivePagePerformanceSlowApi).slice(0,8).map((item)=>{const v=rec(item); return {path:clip(v.path??v.route,120),sampleCount:v.sampleCount??null,p95Ms:v.p95Ms??null,maxMs:v.maxMs??null,overFiveSecondCount:v.overFiveSecondCount??null};});",
"console.log(JSON.stringify({ok:!!report,reportOk:!!report&&report.ok!==false,stateDir,reportJsonPath:reportPath,reportJsonSha256:sha(jsonBuf),reportMdPath,reportMdSha256:sha(read(reportMdPath)),findingCount:Number(report?.findingCount??findings.length),artifactCount,screenshot,findings,counts:rec(report?.counts),analysisWindow:rec(report?.analysisWindow??report?.windows?.recent?.summary),pagePerformanceSlowApi:slow,valuesRedacted:true}));",
"NODE",
@@ -2438,6 +2513,7 @@ function readPromptSetForScenario(scenario: Record<string, unknown>): { ok: true
summary: {
...summary,
promptCount: parsed.length,
promptMarkers: parsed.map((item) => Array.from(new Set(Array.from(item.matchAll(/\bsentinel-(?:0[1-9]|10)\b/giu)).map((match) => match[0].toLowerCase())))),
promptTextHashes: parsed.map((item) => `sha256:${createHash("sha256").update(item).digest("hex").slice(0, 16)}`),
promptTextBytes: parsed.map((item) => Buffer.byteLength(item)),
valuesRedacted: true,
+10 -2
View File
@@ -372,11 +372,19 @@ function summarizeTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target:
if (key === "scenarios" && Array.isArray(target)) {
const ids = target.map((item) => stringAt(item, "id")).filter((item): item is string => item !== null).slice(0, 4);
const cadences = target.map((item) => stringAt(item, "cadence")).filter((item): item is string => item !== null).slice(0, 4);
return `items=${target.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"}`;
const checks = target.flatMap((item) => arrayAt(item, "sessionInvarianceChecks"));
const afterRounds = checks
.map((item) => {
const value = isRecord(item) ? item.afterRound : null;
return typeof value === "number" ? String(value) : null;
})
.filter((item): item is string => item !== null)
.slice(0, 8);
return `items=${target.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"} sessionInvarianceChecks=${checks.length} afterRound=${afterRounds.join(",") || "-"}`;
}
if (!isRecord(target)) return `kind=${targetKindOf(target)}`;
if (key === "runtime") return `namespace=${textAt(target, "namespace")} service=${textAt(target, "serviceName")} image=${short(textAt(target, "imageRef"), 48)}`;
if (key === "promptSet") return `id=${textAt(target, "id")} provider=${textAt(target, "providerProfile")} prompts=${textAt(target, "promptCount")} source=${textAt(target, "promptSourceRef")}:${textAt(target, "promptSourceKey")}`;
if (key === "promptSet") return `id=${textAt(target, "id")} provider=${textAt(target, "providerProfile")} prompts=${textAt(target, "promptCount")} markers=${arrayAt(target, "expectedMarkers").slice(0, 12).join(",") || "-"} source=${textAt(target, "promptSourceRef")}:${textAt(target, "promptSourceKey")}`;
if (key === "reportViews") return `default=${textAt(target, "defaultView")} views=${arrayAt(target, "views").length}`;
if (key === "publicExposure") return `enabled=${textAt(target, "enabled")} mode=${textAt(target, "mode")} url=${textAt(target, "publicBaseUrl")}`;
if (key === "cicd") return `gitops=${textAt(target, "gitopsPath")} image=${textAt(target, "image.repository")}:${textAt(target, "image.tagSource")}`;
+10
View File
@@ -122,6 +122,9 @@ export type NodeWebProbeObserveCommandType =
| "cancel"
| "selectProvider"
| "clickSession"
| "refreshCurrentSession"
| "switchAwayAndBack"
| "assertSessionInvariant"
| "selectProjectSource"
| "selectMdtodoSource"
| "selectMdtodoFile"
@@ -186,6 +189,13 @@ export interface NodeWebProbeObserveOptions {
commandLabel: string | null;
commandSessionId: string | null;
commandProvider: string | null;
commandAfterRound: number | null;
commandSeverity: string | null;
commandAlternateSessionStrategy: string | null;
commandExpectedSentinelRange: string | null;
commandRequireComposerReady: boolean;
commandFindingId: string | null;
commandBlocking: boolean | null;
commandSourceId: string | null;
commandFileRef: string | null;
commandTaskRef: string | null;
+53 -17
View File
@@ -200,22 +200,27 @@ export function parseNodeWebProbeObserveOptions(
"--text",
"--path",
"--label",
"--session-id",
"--provider",
"--source-id",
"--file-ref",
"--task-ref",
"--task",
"--task-id",
"--title",
"--body",
"--status",
"--hwpod-id",
"--node-id",
"--workspace-root",
"--workspace-root-ref",
"--root",
]), new Set(["--force", "--full", "--raw", "--text-stdin"]));
"--session-id",
"--provider",
"--after-round",
"--severity",
"--alternate-session-strategy",
"--expected-sentinel-range",
"--finding-id",
"--source-id",
"--file-ref",
"--task-ref",
"--task",
"--task-id",
"--title",
"--body",
"--status",
"--hwpod-id",
"--node-id",
"--workspace-root",
"--workspace-root-ref",
"--root",
]), new Set(["--force", "--full", "--raw", "--text-stdin", "--require-composer-ready", "--blocking", "--non-blocking"]));
const commandTypeRaw = optionValue(args, "--type") ?? null;
const commandType = commandTypeRaw === null ? null : parseNodeWebProbeObserveCommandType(commandTypeRaw);
const stateDir = optionValue(args, "--state-dir") ?? indexed?.stateDir ?? null;
@@ -274,7 +279,21 @@ export function parseNodeWebProbeObserveOptions(
const commandNodeId = optionValue(args, "--node-id") ?? null;
const commandWorkspaceRoot = optionValue(args, "--workspace-root") ?? optionValue(args, "--workspace-root-ref") ?? null;
const commandRoot = optionValue(args, "--root") ?? null;
const commandAfterRoundRaw = optionValue(args, "--after-round") ?? null;
const commandAfterRound = commandAfterRoundRaw === null ? null : Number(commandAfterRoundRaw);
if (commandAfterRound !== null && (!Number.isInteger(commandAfterRound) || commandAfterRound < 0 || commandAfterRound > 1000)) {
throw new Error("unsafe web-probe observe --after-round: expected integer 0-1000");
}
const commandSeverity = optionValue(args, "--severity") ?? null;
const commandAlternateSessionStrategy = optionValue(args, "--alternate-session-strategy") ?? null;
const commandExpectedSentinelRange = optionValue(args, "--expected-sentinel-range") ?? null;
const commandFindingId = optionValue(args, "--finding-id") ?? null;
const commandBlocking = args.includes("--blocking") ? true : args.includes("--non-blocking") ? false : null;
for (const [label, value] of [
["--severity", commandSeverity],
["--alternate-session-strategy", commandAlternateSessionStrategy],
["--expected-sentinel-range", commandExpectedSentinelRange],
["--finding-id", commandFindingId],
["--source-id", commandSourceId],
["--file-ref", commandFileRef],
["--task-ref", commandTaskRef],
@@ -331,6 +350,13 @@ export function parseNodeWebProbeObserveOptions(
commandLabel: optionValue(args, "--label") ?? null,
commandSessionId: optionValue(args, "--session-id") ?? null,
commandProvider: optionValue(args, "--provider") ?? null,
commandAfterRound,
commandSeverity,
commandAlternateSessionStrategy,
commandExpectedSentinelRange,
commandRequireComposerReady: args.includes("--require-composer-ready"),
commandFindingId,
commandBlocking,
commandSourceId,
commandFileRef,
commandTaskRef,
@@ -356,6 +382,9 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "cancel"
|| value === "selectProvider"
|| value === "clickSession"
|| value === "refreshCurrentSession"
|| value === "switchAwayAndBack"
|| value === "assertSessionInvariant"
|| value === "gotoProjectMdtodo"
|| value === "selectProjectSource"
|| value === "selectMdtodoSource"
@@ -379,7 +408,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "mark"
|| value === "stop"
) return value;
throw new Error(`web-probe observe command --type must be login, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, screenshot, mark, or stop; got ${value}`);
throw new Error(`web-probe observe command --type must be login, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, screenshot, mark, or stop; got ${value}`);
}
export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {
@@ -1331,6 +1360,13 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
label: options.commandLabel,
sessionId: options.commandSessionId,
provider: options.commandProvider,
afterRound: options.commandAfterRound,
severity: options.commandSeverity,
alternateSessionStrategy: options.commandAlternateSessionStrategy,
expectedSentinelRange: options.commandExpectedSentinelRange,
requireComposerReady: options.commandRequireComposerReady,
findingId: options.commandFindingId,
blocking: options.commandBlocking,
sourceId: options.commandSourceId,
fileRef: options.commandFileRef,
taskRef: options.commandTaskRef,